aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/crypto.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js127
1 files changed, 118 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 5ebf624..21bf05d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -230,24 +230,133 @@ function b64encode(bytes) {
return btoa(String.fromCharCode(...bytes));
}
+// ── Admin operation transcript ───────────────────────────────────────────────
+// Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these
+// bytes independently; they are never taken off the wire.
+//
+// Finding H5: the client used to sign 32 raw random bytes chosen by the node — a
+// blind signing oracle. It now reconstructs a domain-separated, length-prefixed
+// transcript naming the operation, subject, node and group, so the UI can show the
+// user what they are authorizing and a signature cannot be reused elsewhere.
+
+const ADMIN_TRANSCRIPT_PREFIX = new TextEncoder().encode('meshbay:admin:v1');
+
+function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) {
+ const enc = new TextEncoder();
+ const fields = [
+ enc.encode(op),
+ enc.encode(nodePkB64),
+ enc.encode(groupId),
+ enc.encode(subject),
+ b64decode(nonceB64),
+ enc.encode(String(ts)),
+ ];
+ let total = ADMIN_TRANSCRIPT_PREFIX.length;
+ for (const f of fields) total += 4 + f.length;
+
+ const out = new Uint8Array(total);
+ out.set(ADMIN_TRANSCRIPT_PREFIX, 0);
+ let off = ADMIN_TRANSCRIPT_PREFIX.length;
+ for (const f of fields) {
+ new DataView(out.buffer).setUint32(off, f.length, false);
+ off += 4;
+ out.set(f, off);
+ off += f.length;
+ }
+ return out;
+}
+
// ── GEK proof (HMAC-SHA256 for handshake challenge) ─────────────────────────
-async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) {
- const nonce = b64decode(nonceB64);
- const data = concatBuffers([
- nonce,
- offerFp || new Uint8Array(0),
- answerFp || new Uint8Array(0),
+// Mirrors meshbay_common/handshake.py. Every field length-prefixed and the role
+// bound in, so a client proof can never be replayed as a node proof and a missing
+// fingerprint cannot silently degrade the proof to nonce-only (L4).
+const HANDSHAKE_PREFIX = new TextEncoder().encode('meshbay:mnp:handshake:v1');
+
+function _lenPrefixed(parts) {
+ let total = 0;
+ for (const p of parts) total += 4 + p.length;
+ const out = new Uint8Array(total);
+ const view = new DataView(out.buffer);
+ let off = 0;
+ for (const p of parts) {
+ view.setUint32(off, p.length, false);
+ off += 4;
+ out.set(p, off);
+ off += p.length;
+ }
+ return out;
+}
+
+function webrtcBinding(offerFp, answerFp) {
+ if (!offerFp || !offerFp.length || !answerFp || !answerFp.length) {
+ throw new Error('Channel binding unavailable — refusing to handshake');
+ }
+ return _lenPrefixed([offerFp, answerFp]);
+}
+
+function handshakeTranscript(role, groupId, nonceClient, nonceNode, binding) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(role), enc.encode(groupId), nonceClient, nonceNode, binding,
]);
+ const out = new Uint8Array(HANDSHAKE_PREFIX.length + body.length);
+ out.set(HANDSHAKE_PREFIX, 0);
+ out.set(body, HANDSHAKE_PREFIX.length);
+ return out;
+}
+
+async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, binding) {
+ const transcript = handshakeTranscript(role, groupId, nonceClient, nonceNode, binding);
const key = await crypto.subtle.importKey(
'raw', gekRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
- const sig = await crypto.subtle.sign('HMAC', key, data);
- return b64encode(new Uint8Array(sig));
+ const sig = await crypto.subtle.sign('HMAC', key, transcript);
+ return new Uint8Array(sig);
+}
+
+// ── Join / pairing transcript ───────────────────────────────────────────────
+
+// Mirrors meshbay_common/join.py. Signing both of our public keys together binds
+// the X25519 key to the Ed25519 identity the node pins, so the node can safely
+// wrap the group key for a key that came over the wire instead of one fetched
+// from the hub's directory (H3). nonce_node ties it to this connection.
+const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1');
+
+function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64),
+ enc.encode(groupId),
+ enc.encode(userId),
+ enc.encode(pkEdB64),
+ enc.encode(pkXB64),
+ nonceNode,
+ enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(JOIN_PREFIX.length + body.length);
+ out.set(JOIN_PREFIX, 0);
+ out.set(body, JOIN_PREFIX.length);
+ return out;
+}
+
+function constantTimeEqual(a, b) {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
+ return diff === 0;
+}
+
+/** Verify the node's Ed25519 signature over the handshake transcript (C3). */
+async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
+ const raw = b64decode(nodePkB64);
+ const key = await crypto.subtle.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']);
+ return crypto.subtle.verify('Ed25519', key, b64decode(sigB64), transcript);
}
// Export for use in app.js
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
- hmacGEK,
+ adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
+ joinTranscript, verifyNodeSignature, constantTimeEqual,
};