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');