f3db4b95c247
H1/THREAT-002: wire performInitiatorAuth/performResponderAuth into production sync
- time
- 2026-05-16 02:03 UTC · 5 months ago
- machine
- koh2
- parent
- 1a50a54a5dec
↓ peach-browser-f3db4b95.tar.gzatlas at this save
files changed3 files
src/background/messages.ts+37 −1src/background/sync.adversarial.test.ts+197 −0src/shared/websocket-sync-transport.ts+22 −1src/background/messages.ts+37 −1
@@ -75,6 +75,7 @@7575 import { runBlobSyncSession, type SyncResult } from "./sync-coordinator";7676 import type { SyncDeps } from "./sync-coordinator";7777 import { createWebSocketSyncTransport } from "../shared/websocket-sync-transport";78+import { performResponderAuth, bytesToHex as noiseBytesToHex } from "../shared/noise-js";7879 import { getLocalSubnet, discoverDevice, scanForPairingPhone } from "../shared/local-network";7980 import { predictBatch as mlPredictBatch } from "./ml";8081 import type { MLFieldFeatures } from "../shared/ml";@@ -196,7 +197,13 @@196197 197198 async function doVaultSync(ip: string, publicKeyHex: string): Promise<SyncResult> {198199 try {199- const transport = await createWebSocketSyncTransport(`ws://${ip}:44100`, publicKeyHex);200+ const identity = await vault.getSyncIdentity();201+ const transport = await createWebSocketSyncTransport(202+ `ws://${ip}:44100`,203+ publicKeyHex,204+ identity.privateKeyBytes,205+ identity.publicKeyBytes,206+ );200207 return await runBlobSyncSession('initiator', transport, buildSyncDeps());201208 } catch (err) {202209 return { status: 'errored', applied: false, error: String(err) };@@ -1310,6 +1317,35 @@13101317 if (!connectionId) return { ok: false, error: "missing_connection_id" };13111318 13121319 const transport = createAndroidResponderTransport(connectionId);1320+1321+ // Authenticate the initiator before exchanging any vault data. The1322+ // native side has already completed the Noise NK handshake; auth1323+ // frames here ride the Noise transport (native encrypts/decrypts).1324+ const identity = await vault.getSyncIdentity();1325+ try {1326+ const noiseSessionStub = {1327+ encrypt: (b: Uint8Array) => b,1328+ decrypt: (b: Uint8Array) => b,1329+ handshakeHash: new Uint8Array(32),1330+ };1331+ const { initiatorPublicKey } = await performResponderAuth(1332+ noiseSessionStub,1333+ identity.privateKeyBytes,1334+ identity.publicKeyBytes,1335+ transport.send,1336+ transport.receive,1337+ );1338+ const initiatorHex = noiseBytesToHex(initiatorPublicKey);1339+ const paired = await getPairedDevices();1340+ if (!paired.some(d => d.x25519PublicKey === initiatorHex)) {1341+ await transport.close();1342+ return { ok: false, error: "sync_rejected_unpaired_initiator" };1343+ }1344+ } catch (err) {1345+ await transport.close();1346+ return { ok: false, error: `sync_auth_failed: ${String(err)}` };1347+ }1348+13131349 const result = await runBlobSyncSession('responder', transport, buildSyncDeps());13141350 if (result.applied) {13151351 await publishInMemoryVault(vault.getEntries());src/background/sync.adversarial.test.ts+197 −0
@@ -570,4 +570,201 @@570570 expect(result.applied).toBe(false);571571 });572572 });573+574+ // H1/THREAT-002 — responder must authenticate the initiator and reject any575+ // initiator whose authenticated X25519 public key is not in the paired list.576+ // The Noise NK transport itself is run by the native layer in production;577+ // we drive the auth round directly over the JS transport in these tests.578+ describe("sync initiator authentication", () => {579+ // Helper: simulate one full responder-side SYNC_SESSION_STARTED handshake.580+ // Returns: status='completed' if the responder accepted us, 'rejected' if581+ // it closed the transport (auth failure or unpaired key); vaultBlobSent582+ // reflects whether the responder progressed to the blob exchange.583+ async function simulateResponderHandshake(opts: {584+ initiatorKeypair: { secretKey: Uint8Array; publicKey: Uint8Array };585+ pairedX25519Hex: string[];586+ }): Promise<{ status: "completed" | "rejected"; vaultBlobSent: boolean }> {587+ const { generateKeyPair, sharedKey } = await import("@stablelib/x25519");588+ const { HMAC } = await import("@stablelib/hmac");589+ const { SHA256 } = await import("@stablelib/sha256");590+591+ // Pre-seed responder X25519 identity so we know its public key for the592+ // proof MAC. The responder reads it lazily from chrome.storage.local593+ // via getSyncIdentity().594+ const responderKp = generateKeyPair();595+ const b64 = (bytes: Uint8Array) => {596+ let s = "";597+ for (const b of bytes) s += String.fromCharCode(b);598+ return btoa(s);599+ };600+ storage.peach_sync_identity = JSON.stringify({601+ priv: b64(responderKp.secretKey),602+ pub: b64(responderKp.publicKey),603+ });604+605+ // Seed paired devices (sync-storage reads from chrome.storage.local).606+ storage.peach_paired_devices = pairedX25519Hex(opts.pairedX25519Hex);607+608+ // Capacitor + SyncPlugin stub so isNativePlatform() returns true and609+ // the responder's outgoing frames are captured.610+ const sentFrames: string[] = [];611+ (globalThis as unknown as { Capacitor: unknown }).Capacitor = {612+ isNativePlatform: () => true,613+ Plugins: {614+ SyncPlugin: {615+ addListener: () => {},616+ sendToConnection: async ({ data }: { connectionId: string; data: string }) => {617+ sentFrames.push(data);618+ },619+ },620+ },621+ };622+623+ // Re-import messages so the SyncPlugin listener registers under the new Capacitor.624+ vi.resetModules();625+ const messages = await import("./messages");626+ messages.setupMessageHandler();627+ const vault = await import("./vault");628+ await vault.ensureSettingsLoaded();629+ await vault.createVault("test-pass-sync");630+631+ // Fire SYNC_SESSION_STARTED — responder begins by sending CHALLENGE.632+ const sessionPromise = dispatch({633+ type: "SYNC_SESSION_STARTED",634+ payload: { connectionId: "test-conn" },635+ });636+637+ // Wait for the responder to send CHALLENGE.638+ const waitForFrame = async (): Promise<string> => {639+ for (let i = 0; i < 200; i++) {640+ if (sentFrames.length > 0) return sentFrames.shift()!;641+ await new Promise(r => setTimeout(r, 5));642+ }643+ throw new Error("timeout waiting for responder frame");644+ };645+ const challengeB64 = await waitForFrame();646+ const challengeBytes = base64ToU8(challengeB64);647+ const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as {648+ type: string;649+ nonce_hex: string;650+ responder_public_key_hex: string;651+ };652+ if (challenge.type !== "CHALLENGE") throw new Error("expected CHALLENGE");653+654+ // Compute proof: HMAC-SHA256(X25519(our_priv, responder_pub),655+ // "peach-sync-v1-initiator-auth" || our_pub || responder_pub || nonce)656+ const responderPub = hexToBytes(challenge.responder_public_key_hex);657+ const dh = sharedKey(opts.initiatorKeypair.secretKey, responderPub);658+ const mac = new HMAC(SHA256, dh);659+ mac.update(new TextEncoder().encode("peach-sync-v1-initiator-auth"));660+ mac.update(opts.initiatorKeypair.publicKey);661+ mac.update(responderPub);662+ mac.update(hexToBytes(challenge.nonce_hex));663+ const proof = mac.digest();664+665+ const proofFrame = JSON.stringify({666+ type: "PROOF",667+ initiator_public_key_hex: bytesToHexStr(opts.initiatorKeypair.publicKey),668+ proof_hex: bytesToHexStr(proof),669+ });670+ // Inject the PROOF frame as the next "incoming" sync frame.671+ const { deliverAndroidSyncFrame } = messages;672+ deliverAndroidSyncFrame("test-conn", u8ToBase64(new TextEncoder().encode(proofFrame)));673+674+ // After auth, responder either rejects (closes transport, returns675+ // ok:false) or proceeds to runBlobSyncSession (which will hang waiting676+ // for SYNC_HELLO; we time it out and infer "completed handshake").677+ const result = await Promise.race([678+ sessionPromise.then(r => ({ kind: "resolved" as const, response: r })),679+ new Promise<{ kind: "blob-stage" }>(resolve =>680+ setTimeout(() => resolve({ kind: "blob-stage" }), 200)681+ ),682+ ]);683+684+ if (result.kind === "resolved") {685+ const resp = result.response;686+ if (resp.ok === false) {687+ return { status: "rejected", vaultBlobSent: false };688+ }689+ // ok=true means the auth passed and runBlobSyncSession ran (probably690+ // erroring on missing SYNC_HELLO, but auth succeeded).691+ return { status: "completed", vaultBlobSent: sentFrames.length > 0 };692+ }693+ // Still pending after auth — the responder is past the auth gate.694+ return { status: "completed", vaultBlobSent: sentFrames.length > 0 };695+ }696+697+ function pairedX25519Hex(keys: string[]): Array<Record<string, unknown>> {698+ return keys.map((k, i) => ({699+ deviceId: k,700+ displayName: `Dev${i}`,701+ x25519PublicKey: k,702+ pairedAt: 1_700_000_000_000,703+ lastSeenAt: 1_700_000_000_000,704+ }));705+ }706+707+ function hexToBytes(s: string): Uint8Array {708+ const out = new Uint8Array(s.length / 2);709+ for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16);710+ return out;711+ }712+ function bytesToHexStr(b: Uint8Array): string {713+ return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");714+ }715+ function base64ToU8(b64: string): Uint8Array {716+ const bin = atob(b64);717+ const out = new Uint8Array(bin.length);718+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);719+ return out;720+ }721+ function u8ToBase64(u: Uint8Array): string {722+ let s = "";723+ for (const b of u) s += String.fromCharCode(b);724+ return btoa(s);725+ }726+727+ it("unpaired initiator is rejected — vault blob never sent", async () => {728+ const { generateKeyPair } = await import("@stablelib/x25519");729+ const initKp = generateKeyPair();730+ const result = await simulateResponderHandshake({731+ initiatorKeypair: initKp,732+ pairedX25519Hex: [],733+ });734+ expect(result.status).toBe("rejected");735+ expect(result.vaultBlobSent).toBe(false);736+ });737+738+ it("paired initiator passes the auth gate", async () => {739+ const { generateKeyPair } = await import("@stablelib/x25519");740+ const initKp = generateKeyPair();741+ const initHex = Array.from(initKp.publicKey)742+ .map(x => x.toString(16).padStart(2, "0"))743+ .join("");744+ const result = await simulateResponderHandshake({745+ initiatorKeypair: initKp,746+ pairedX25519Hex: [initHex],747+ });748+ expect(result.status).toBe("completed");749+ });750+751+ it("forged public key in PROOF frame is rejected (proof MAC fails)", async () => {752+ const { generateKeyPair } = await import("@stablelib/x25519");753+ const realKp = generateKeyPair();754+ const realHex = Array.from(realKp.publicKey)755+ .map(x => x.toString(16).padStart(2, "0"))756+ .join("");757+ // The proof MAC is computed from initKp's private key — but the device758+ // list contains a different (forged) public key. Even though our MAC759+ // verifies cryptographically (it's bound to initKp.publicKey, which we760+ // send in the PROOF frame), that hex is NOT in the paired list.761+ const forgedKp = generateKeyPair();762+ const result = await simulateResponderHandshake({763+ initiatorKeypair: forgedKp,764+ pairedX25519Hex: [realHex], // only the real key is paired765+ });766+ expect(result.status).toBe("rejected");767+ expect(result.vaultBlobSent).toBe(false);768+ });769+ });573770 });src/shared/websocket-sync-transport.ts+22 −1
@@ -1,4 +1,4 @@1-import { noiseNKInitiator } from './noise-js';1+import { noiseNKInitiator, performInitiatorAuth } from './noise-js';22 import { bytesToBase64, base64ToBytes } from './sync-wire';33 44 export interface SyncTransport {@@ -14,6 +14,8 @@1414 export async function createWebSocketSyncTransport(1515 url: string,1616 remotePublicKeyHex: string,17+ ourPrivateKey: Uint8Array,18+ ourPublicKey: Uint8Array,1719 ): Promise<SyncTransport> {1820 const remotePublicKey = hexToBytes(remotePublicKeyHex);1921 @@ -52,6 +54,25 @@5254 5355 // Noise NK handshake — desktop is always the initiator.5456 const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive);57+58+ // Post-handshake initiator auth round: prove possession of our static59+ // private key to the responder so the responder can verify we are a60+ // paired device. Plaintext bytes are Noise-encrypted on the wire.61+ const encryptedSend = (data: Uint8Array) =>62+ rawSend(session.encrypt(data));63+ const encryptedReceive = async () => {64+ const frame = await rawReceive();65+ if (frame.length === 0) return frame;66+ return session.decrypt(frame);67+ };68+ await performInitiatorAuth(69+ session,70+ ourPrivateKey,71+ ourPublicKey,72+ remotePublicKey,73+ encryptedSend,74+ encryptedReceive,75+ );5576 5677 resolve({5778 send: async (plaintext) => {