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:
parent
236661b573
commit
c76cb397ba
1 changed files with 48 additions and 6 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue