diff --git a/src/App.tsx b/src/App.tsx index a00627b..543564b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import CameraEntropy from './components/CameraEntropy'; import DiceEntropy from './components/DiceEntropy'; import { InteractionEntropy } from './lib/interactionEntropy'; +import AudioEntropy from './AudioEntropy'; console.log("OpenPGP.js version:", openpgp.config.versionString); interface StorageItem { @@ -736,6 +737,17 @@ function App() { )} + {/* Audio Entropy Component */} + {entropySource === 'audio' && !generatedSeed && ( + setEntropySource(null)} + interactionEntropy={interactionEntropyRef.current} + /> + )} + {/* Generated Seed Display + Destination Selector */} {generatedSeed && (
diff --git a/src/AudioEntropy.tsx b/src/AudioEntropy.tsx new file mode 100644 index 0000000..e90a029 --- /dev/null +++ b/src/AudioEntropy.tsx @@ -0,0 +1,705 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Mic, X, CheckCircle2 } from 'lucide-react'; +import { InteractionEntropy } from './lib/interactionEntropy'; + +interface AudioStats { + sampleRate: number; + duration: number; + peakAmplitude: number; + rmsAmplitude: number; + zeroCrossings: number; + frequencyBands: number[]; + spectralEntropy: number; + interactionSamples: number; + totalBits: number; +} + +interface AudioEntropyProps { + wordCount: 12 | 24; + onEntropyGenerated: (mnemonic: string, stats: AudioStats) => void; + onCancel: () => void; + interactionEntropy: InteractionEntropy; +} + +const AudioEntropy: React.FC = ({ + wordCount, + onEntropyGenerated, + onCancel, + interactionEntropy +}) => { + const [step, setStep] = useState<'permission' | 'capture' | 'processing' | 'stats'>('permission'); + const [stream, setStream] = useState(null); + const [audioLevel, setAudioLevel] = useState(0); + const [captureEnabled, setCaptureEnabled] = useState(false); + const [stats, setStats] = useState(null); + const [generatedMnemonic, setGeneratedMnemonic] = useState(''); + const [error, setError] = useState(''); + const [captureProgress, setCaptureProgress] = useState(0); + + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const animationRef = useRef(); + const audioDataRef = useRef([]); + const audioLevelLoggedRef = useRef(false); + const scriptProcessorRef = useRef(null); + const rawAudioDataRef = useRef([]); + const frameCounterRef = useRef(0); + + const teardownAudio = async () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = undefined; + } + + if (stream) { + stream.getTracks().forEach(t => t.stop()); + setStream(null); + } + + if (scriptProcessorRef.current) { + (scriptProcessorRef.current as any).onaudioprocess = null; + try { scriptProcessorRef.current.disconnect(); } catch {} + scriptProcessorRef.current = null; + } + + analyserRef.current = null; + + const ctx = audioContextRef.current; + audioContextRef.current = null; + if (ctx && ctx.state !== 'closed') { + try { await ctx.close(); } catch {} + } + }; + + const requestMicrophoneAccess = async () => { + try { + console.log('🎤 Requesting microphone access...'); + + // Clean up any existing audio context first + await teardownAudio(); + + const mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + sampleRate: { ideal: 44100 }, // Safari prefers this + channelCount: 1, + }, + }); + + console.log('✅ Microphone access granted'); + + // Set up Web Audio API + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const analyser = audioContext.createAnalyser(); + + // Back to normal analyser settings + analyser.fftSize = 2048; // back to normal + analyser.smoothingTimeConstant = 0.3; + analyser.minDecibels = -100; + analyser.maxDecibels = 0; + analyser.channelCount = 1; + + const source = audioContext.createMediaStreamSource(mediaStream); + + // Silent sink that still "pulls" the graph (no speaker output) + const silentGain = audioContext.createGain(); + silentGain.gain.value = 0; + + const silentSink = audioContext.createMediaStreamDestination(); + + // IMPORTANT: analyser must be in the pulled path + source.connect(analyser); + analyser.connect(silentGain); + silentGain.connect(silentSink); + + // Safari fallback: ScriptProcessor gets RAW mic PCM + try { + const scriptProcessor = (audioContext as any).createScriptProcessor(1024, 1, 1); + + scriptProcessor.onaudioprocess = (event: AudioProcessingEvent) => { + const inputBuffer = event.inputBuffer.getChannelData(0); // RAW MIC DATA! + + // Append for entropy + rawAudioDataRef.current.push(new Float32Array(inputBuffer)); + + // Calc RMS from raw data + let sum = 0; + for (let i = 0; i < inputBuffer.length; i++) { + sum += inputBuffer[i] * inputBuffer[i]; + } + const rawRms = Math.sqrt(sum / inputBuffer.length); + + // Update state via postMessage (React-safe) + if (Math.random() < 0.1) { // Throttle + setAudioLevel(Math.min(rawRms * 2000, 100)); + } + + // Deterministic logging every 30 frames + if (frameCounterRef.current++ % 30 === 0) { + console.log('🎙️ RAW mic RMS:', rawRms.toFixed(4), 'Sample:', inputBuffer.slice(0,5)); + } + }; + + // ScriptProcessor branch also pulled + source.connect(scriptProcessor); + scriptProcessor.connect(silentGain); // pull it via the same sink path + scriptProcessorRef.current = scriptProcessor; + + console.log('✅ ScriptProcessor active (Safari fallback)'); + } catch (e) { + console.log('⚠️ ScriptProcessor not supported'); + } + + console.log('🎧 Pipeline primed:', { + sampleRate: audioContext.sampleRate, + state: audioContext.state, + fftSize: analyser.fftSize, + channels: analyser.channelCount, + }); + + audioContextRef.current = audioContext; + analyserRef.current = analyser; + setStream(mediaStream); + + // Resume context + if (audioContext.state === 'suspended') { + await audioContext.resume(); + console.log('▶️ Audio context resumed:', audioContext.state); + } + + // Give pipeline 300ms to fill buffer + setTimeout(() => { + if (analyserRef.current) { + console.log('▶️ Starting analysis after buffer fill'); + startAudioAnalysis(); + setStep('capture'); + } + }, 300); + + } catch (err: any) { + console.error('❌ Microphone error:', err); + setError(`Microphone access denied: ${err.message}`); + setTimeout(() => onCancel(), 2000); + } + }; + + const startAudioAnalysis = () => { + if (!analyserRef.current) { + console.error('❌ No analyser'); + return; + } + + console.log('✅ Analysis loop started'); + + const analyze = () => { + if (!analyserRef.current) return; + + // Use FLOAT data (more precise than Byte) + const bufferLength = analyserRef.current.frequencyBinCount; + const timeData = new Float32Array(bufferLength); + const freqData = new Float32Array(bufferLength); + + analyserRef.current.getFloatTimeDomainData(timeData); + analyserRef.current.getFloatFrequencyData(freqData); + + // RMS from time domain (-1 to 1 range) + let sum = 0; + for (let i = 0; i < bufferLength; i++) { + sum += timeData[i] * timeData[i]; + } + const rms = Math.sqrt(sum / bufferLength); + const level = Math.min(rms * 2000, 100); // Scale for visibility + + // Proper dBFS to linear energy + let freqEnergy = 0; + let activeBins = 0; + for (let i = 0; i < bufferLength; i++) { + const dB = freqData[i]; + if (dB > -100) { // Ignore silence floor + const linear = Math.pow(10, dB / 20); // dB → linear amplitude + freqEnergy += linear * linear; // Power + activeBins++; + } + } + const freqRms = activeBins > 0 ? Math.sqrt(freqEnergy / activeBins) : 0; + const freqLevel = Math.min(freqRms * 1000, 50); + + const finalLevel = Math.max(level, freqLevel); + + // CLAMP + const clampedLevel = Math.min(Math.max(finalLevel, 0), 100); + + // Log first few + random + if (!audioLevelLoggedRef.current) { + audioLevelLoggedRef.current = true; + console.log('📊 First frame:', { + rms: rms.toFixed(4), + level: level.toFixed(1), + timeSample: timeData.slice(0, 5), + freqSample: freqData.slice(0, 5) + }); + } else if (Math.random() < 0.03) { + console.log('🎵 Level:', clampedLevel.toFixed(1), 'RMS:', rms.toFixed(4)); + } + + setAudioLevel(clampedLevel); + setCaptureEnabled(clampedLevel > 1); // Lower threshold + + animationRef.current = requestAnimationFrame(analyze); + }; + + analyze(); + }; + + // Auto-start analysis when analyser is ready + useEffect(() => { + if (analyserRef.current && step === 'capture' && !animationRef.current) { + console.log('🎬 useEffect: Starting audio analysis'); + startAudioAnalysis(); + } + }, [analyserRef.current, step]); + + const captureAudioEntropy = async () => { + // Ensure audio context is running + if (audioContextRef.current && audioContextRef.current.state === 'suspended') { + await audioContextRef.current.resume(); + console.log('▶️ Audio context resumed on capture'); + } + + setStep('processing'); + setCaptureProgress(0); + + console.log('🎙️ Capturing audio entropy...'); + + // Capture 3 seconds of audio data + const captureDuration = 3000; // 3 seconds + const sampleInterval = 50; // Sample every 50ms + const totalSamples = captureDuration / sampleInterval; + + audioDataRef.current = []; + rawAudioDataRef.current = []; + + for (let i = 0; i < totalSamples; i++) { + await new Promise(resolve => setTimeout(resolve, sampleInterval)); + + // Try to get data from analyser first, fall back to raw audio data + if (analyserRef.current) { + const bufferLength = analyserRef.current!.frequencyBinCount; + const timeData = new Float32Array(bufferLength); + + analyserRef.current!.getFloatTimeDomainData(timeData); + + // Store Float32Array directly (no conversion needed) + audioDataRef.current.push(new Float32Array(timeData)); + } + + setCaptureProgress(((i + 1) / totalSamples) * 100); + } + + // Use raw audio data if available (from ScriptProcessor) + if (rawAudioDataRef.current.length > 0) { + console.log('✅ Using raw audio data from ScriptProcessor:', rawAudioDataRef.current.length, 'samples'); + audioDataRef.current = rawAudioDataRef.current.slice(-totalSamples); // Use most recent samples + } + + console.log('✅ Audio captured:', audioDataRef.current.length, 'samples'); + + // Analyze captured audio + const audioStats = await analyzeAudioEntropy(); + const mnemonic = await generateMnemonicFromAudio(audioStats); + + setStats(audioStats); + setGeneratedMnemonic(mnemonic); + setStep('stats'); + + // Use teardownAudio for proper cleanup + await teardownAudio(); + }; + + const analyzeAudioEntropy = async (): Promise => { + // Convert Float32Array[] to number[] by flattening and converting each Float32Array to array + const allSamples: number[] = audioDataRef.current.flatMap(arr => Array.from(arr)); + const sampleRate = audioContextRef.current?.sampleRate || 48000; + + // Peak amplitude + const peakAmplitude = Math.max(...allSamples.map(Math.abs)); + + // RMS amplitude + const sumSquares = allSamples.reduce((sum, val) => sum + val * val, 0); + const rmsAmplitude = Math.sqrt(sumSquares / allSamples.length); + + // Zero crossings (measure of frequency content) + let zeroCrossings = 0; + for (let i = 1; i < allSamples.length; i++) { + if ((allSamples[i] >= 0 && allSamples[i - 1] < 0) || + (allSamples[i] < 0 && allSamples[i - 1] >= 0)) { + zeroCrossings++; + } + } + + // Frequency analysis (simplified) + const frequencyBands = Array(8).fill(0); // 8 bands + for (const frame of audioDataRef.current) { + const bufferLength = frame.length; + const bandSize = Math.floor(bufferLength / 8); + + for (let band = 0; band < 8; band++) { + const start = band * bandSize; + const end = start + bandSize; + let bandEnergy = 0; + + for (let i = start; i < end && i < bufferLength; i++) { + bandEnergy += Math.abs(frame[i]); + } + + frequencyBands[band] += bandEnergy / bandSize; + } + } + + // Normalize frequency bands + const maxBand = Math.max(...frequencyBands); + if (maxBand > 0) { + for (let i = 0; i < frequencyBands.length; i++) { + frequencyBands[i] = (frequencyBands[i] / maxBand) * 100; + } + } + + // Spectral entropy (simplified) + let spectralEntropy = 0; + const total = frequencyBands.reduce((a, b) => a + b, 0); + if (total > 0) { + for (const band of frequencyBands) { + if (band > 0) { + const p = band / total; + spectralEntropy -= p * Math.log2(p); + } + } + } + + return { + sampleRate, + duration: audioDataRef.current.length * 50, // milliseconds + peakAmplitude, + rmsAmplitude, + zeroCrossings, + frequencyBands, + spectralEntropy, + interactionSamples: interactionEntropy.getSampleCount().total, + totalBits: 256, + }; + }; + + const generateMnemonicFromAudio = async (audioStats: AudioStats): Promise => { + // Mix audio data with other entropy sources + // Convert Float32Array[] to a single Float32Array by concatenating all arrays + const allAudioData = audioDataRef.current.flatMap(arr => Array.from(arr)); + const audioHash = await crypto.subtle.digest( + 'SHA-256', + new Float32Array(allAudioData).buffer + ); + + const interactionBytes = await interactionEntropy.getEntropyBytes(); + const cryptoBytes = crypto.getRandomValues(new Uint8Array(32)); + + const combined = [ + Array.from(new Uint8Array(audioHash)).join(','), + audioStats.zeroCrossings.toString(), + audioStats.peakAmplitude.toString(), + performance.now().toString(), + Array.from(interactionBytes).join(','), + Array.from(cryptoBytes).join(','), + ].join('|'); + + const encoder = new TextEncoder(); + const data = encoder.encode(combined); + const hash = await crypto.subtle.digest('SHA-256', data); + + const { entropyToMnemonic } = await import('bip39'); + const entropyLength = wordCount === 12 ? 16 : 32; + const finalEntropy = new Uint8Array(hash).slice(0, entropyLength); + const entropyHex = Buffer.from(finalEntropy).toString('hex'); + return entropyToMnemonic(entropyHex); + }; + + useEffect(() => { + return () => { + teardownAudio(); + }; + }, []); + + const getStatusMessage = () => { + if (audioLevel > 10) { + return { text: '✅ Excellent audio - ready!', color: '#39ff14' }; + } else if (audioLevel > 5) { + return { text: '🟡 Good - speak or make noise', color: '#ffd700' }; + } else if (audioLevel > 2) { + return { text: '🟠 Low - louder noise needed', color: '#ff9500' }; + } else { + return { text: '🔴 Too quiet - tap desk/speak', color: '#ff006e' }; + } + }; + + return ( +
+ {/* Permission Screen */} + {step === 'permission' && ( +
+
+ +

Microphone Permission Needed

+ + Beta Feature + +
+ +
+

To generate entropy, we need:

+
    +
  • Microphone access to capture ambient noise
  • +
  • Audio data processed locally (never transmitted)
  • +
  • 3 seconds of audio capture
  • +
  • Microphone auto-closes after use
  • +
+
+ +
+ + +
+
+ )} + + {/* Capture Screen */} + {step === 'capture' && ( +
+ {/* Waveform Visualization */} +
+
+ {/* Animated audio level bars */} +
+ {[...Array(20)].map((_, i) => ( +
+ ))} +
+
+
+ + {/* Status */} +
+
+

Instructions:

+

Make noise: tap desk, rustle paper, speak, or play music

+
+ +
+
+ Audio Level: + {audioLevel.toFixed(1)}% +
+
+
+
+
+ {getStatusMessage().text} +
+
+ +
+ + +
+
+
+ )} + + {/* Processing Screen */} + {step === 'processing' && ( +
+
+
+ +
+

Capturing audio entropy...

+
+
+
+

{captureProgress.toFixed(0)}%

+
+ )} + + {/* Stats Display */} + {step === 'stats' && stats && ( +
+
+ +

Audio Entropy Analysis

+
+ +
+
+

Primary Source:

+

Microphone Ambient Noise

+
+ +
+

AUDIO METRICS:

+
+
Sample Rate:
+
{stats.sampleRate} Hz
+ +
Duration:
+
{stats.duration}ms
+ +
Peak Amplitude:
+
{stats.peakAmplitude.toFixed(3)}
+ +
RMS Amplitude:
+
{stats.rmsAmplitude.toFixed(3)}
+ +
Zero Crossings:
+
{stats.zeroCrossings.toLocaleString()}
+ +
Spectral Entropy:
+
{stats.spectralEntropy.toFixed(2)}/3.00
+
+
+ +
+

FREQUENCY DISTRIBUTION:

+
+ {stats.frequencyBands.map((val, i) => ( +
+ ))} +
+
+ Low + High +
+
+ +
+

GENERATED SEED:

+
+

+ {generatedMnemonic} +

+

+ 👆 Hover to reveal - Write this down securely +

+
+
+ +
+

HOW SEED IS GENERATED:

+
+
1. Captured {stats.duration}ms of audio ({(audioDataRef.current.flat().length / 1024).toFixed(1)}KB)
+
2. Analyzed {stats.zeroCrossings.toLocaleString()} zero crossings
+
3. Extracted frequency spectrum (8 bands)
+
4. Mixed with {stats.interactionSamples} interaction samples
+
5. Enhanced with crypto.getRandomValues() (32 bytes)
+
6. Final hash → {wordCount === 12 ? '128' : '256'} bits → {wordCount} BIP39 words
+
+
+ +
+

MIXED WITH:

+
+
- crypto.getRandomValues() ✓
+
- performance.now() ✓
+
- Interaction timing ({stats.interactionSamples} samples) ✓
+
+
+ +
+
+ Total Entropy: + {stats.totalBits} bits +
+
+
+ +
+ + + +
+
+ )} + + {error && ( +
+

{error}

+
+ )} +
+ ); +}; + +export default AudioEntropy; \ No newline at end of file