diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index dc6dbde..83bd378 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1375,6 +1375,16 @@ async function signBytes(bytes){ const sig = await crypto.subtle.sign('Ed25519', myKeys.privateKey, bytes); return b64(sig); } +/* ed25519 verify against a raw 32-byte pubkey (base64). Returns false + * on any error so callers can treat unsigned / malformed / wrong-pubkey + * uniformly without throwing. */ +async function verifyEd25519(pubB64, msgBytes, sigB64){ + try { + const pubKey = await crypto.subtle.importKey( + 'raw', unb64(pubB64), { name: 'Ed25519' }, false, ['verify']); + return await crypto.subtle.verify('Ed25519', pubKey, unb64(sigB64), msgBytes); + } catch(_){ return false; } +} /* canonical sig inputs — must match the Go side byte-for-byte */ function sigJoin(roomID, nonce, pubB64, handle){ @@ -2903,8 +2913,22 @@ async function sendEncSDP(toUUID, kind, desc){ send({ type:'sdp-to', to:toUUID, kind, data: b64(await aesEncrypt(sigKey, JSON.stringify(desc))) }); } async function sendMicState(){ - if (!sigKey) return; - try { send({ type:'mic-state', data: b64(await aesEncrypt(sigKey, JSON.stringify({muted}))) }); } catch(_){} + if (!sigKey || !myKeys) return; + /* Anti-spoof: sign the (room_id, payload) tuple with our identity + * key so receivers verify the message originated from the pubkey + * the server attached as me.uuid -> mm.pubkey. Anti-replay: include + * a fresh timestamp; receivers reject anything older than 30s or + * not newer than the last accepted mic-state from this peer. */ + const blob = JSON.stringify({ muted, t: Date.now() }); + const sigInput = new TextEncoder().encode('mic-state|' + roomID + '|' + blob); + const sig = await signBytes(sigInput); + try { + send({ + type: 'mic-state', + data: b64(await aesEncrypt(sigKey, blob)), + sig, + }); + } catch(_){} } function setStatus(msg, cls){ const e=$('call-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:''); } @@ -3073,8 +3097,26 @@ async function handleSignal(raw){ catch(e){ logLine('err','sdp from '+m.from+' failed: '+e.message); } break; case 'mic-state': - try { const s = JSON.parse(await aesDecrypt(sigKey, unb64(m.data))); - const mm = members.get(m.uuid); if (mm){ mm.muted = !!s.muted; renderRoom(); } } catch(_){} + try { + const mm = members.get(m.uuid); + if (!mm || !mm.pubkey) break; + if (!m.sig){ logLine('err', 'mic-state from '+m.uuid+' unsigned — ignoring'); break; } + const decrypted = await aesDecrypt(sigKey, unb64(m.data)); + const sigInput = new TextEncoder().encode('mic-state|' + roomID + '|' + decrypted); + if (!(await verifyEd25519(mm.pubkey, sigInput, m.sig))){ + logLine('err', 'mic-state from '+m.uuid+' bad signature — ignoring'); + break; + } + const s = JSON.parse(decrypted); + /* anti-replay: drop messages older than 30s OR not newer than + * the last one we accepted from this peer */ + const now = Date.now(); + if (typeof s.t !== 'number' || Math.abs(now - s.t) > 30000) break; + if (mm._micT && s.t <= mm._micT) break; + mm._micT = s.t; + mm.muted = !!s.muted; + renderRoom(); + } catch(_){} break; case 'spotlight': /* who's looking at what — drives thumbnail popularity sort + log @@ -3932,8 +3974,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');