zebra-spaces: sign + verify mic-state, anti-replay window

Three-layer defense for the mic-state channel:

1. Authenticity: send signs over 'mic-state|' + room_id + payload
   with the identity ed25519 key. Receivers verify against the
   member's roster pubkey (which the server tied to me.uuid at join
   time). Missing or invalid sig -> drop the message + log err line
   to telemetry.

2. Freshness: payload carries a wall-clock timestamp. Receivers reject
   anything older than 30s in either direction (covers clock drift).

3. Monotonicity: each peer's last-accepted timestamp is tracked on
   mm._micT; an old or equal-timestamp message is silently dropped,
   so a replay of a previously-valid mic-state can't reset state to
   a stale value.

Pairs with signal 59ad814.
This commit is contained in:
Russell Ballestrini 2026-06-03 12:36:14 -04:00
parent 236661b573
commit c76cb397ba
No known key found for this signature in database

View file

@ -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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-03</span><br>
md5 <span class="stamp-md5">4eea6662436f9989ee36bae6060af6e8</span><br>
sha256 <span class="stamp-sha">6a61928bf78af3bfde2e35902f0eca8d8fbb00e08ca56c596b25a0264a96df00</span><br>
md5 <span class="stamp-md5">fc29c8bf692b0c2c0621d23df9f46a75</span><br>
sha256 <span class="stamp-sha">c05107efe2e7971407d4bfc3971016a2bd49cc4dc34cd4fbe9d7e33090853156</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>