28e4cc72b69c

record the 8-digit SAS wire contract, its cross-platform KATs, the stale assertions the handoff left behind, and the relay redeploy still owed

time
2026-07-26 07:51 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
SAS and rate-limit hardening
parent
d37e6a6763d5
19 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing.test.ts+7 −7src/background/code-pairing.ts+7 −3src/background/paired-devices.ts+5 −1src/background/sync-runner.ts+5 −1src/background/sync-vault-adapter.ts+5 −1src/popup/components/AdoptionConsentModal.test.tsx+2 −2src/popup/components/AdoptionConsentModal.tsx+1 −1src/popup/styles/popup.css+3 −1src/popup/views/PairDevice.test.tsx+8 −8src/popup/views/PairDevice.tsx+1 −1src/shared/constant-time.test.ts+44 −0src/shared/constant-time.ts+25 −0src/shared/pairing-dial.test.ts+1 −1src/shared/pairing-dial.ts+1 −1src/shared/pairing-handshake.test.ts+13 −8src/shared/pairing-handshake.ts+11 −11
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.1",4+  "version": "4.1.2",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.1",4+  "version": "4.1.2",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.1.1",3+  "version": "4.1.2",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/code-pairing.test.ts+7 −7
@@ -187,24 +187,24 @@187187     expect(deps.persistDevice).not.toHaveBeenCalled();188188   });189189 190-  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {191-    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake192-    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;193-    // 263895245 mod 1_000_000 = 895245.190+  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 44907520", () => {191+    // Cross-impl KAT, matched byte-for-byte by peach-android's SASDerivation:192+    // the first eight bytes 0f ba b8 cd 00 00 00 00 read as an unsigned193+    // big-endian integer are 1133421446844907520; mod 1e8 = 44907520.194194     const hash = Uint8Array.from([195195       0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),196196     ]);197-    expect(derivePairingSAS(hash)).toBe("895245");197+    expect(derivePairingSAS(hash)).toBe("44907520");198198   });199199 200200   it("surfaces that same SAS through the pairing flow", async () => {201-    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.201+    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 44907520.202202     const { deps } = makeDeps();203203     const { cb, sas } = makeCallbacks();204204 205205     await pairWithCode(PHONE_CODE, cb, deps, async () => true);206206 207-    expect(sas.value).toBe("895245");207+    expect(sas.value).toBe("44907520");208208   });209209 210210   it("persists the phone identity on SAS confirm", async () => {

view file

src/background/code-pairing.ts+7 −3
@@ -17,6 +17,7 @@1717 import type { LocalIdentity } from "../shared/pairing-handshake";1818 import type { DeviceType, PairedDevice } from "../shared/sync-types";1919 import { bytesToHex, normalizeRelayUrl } from "../shared/rendezvous";20+import { constantTimeEqualHex } from "../shared/constant-time";2021 2122 /** Default sync port a peer announces; used when ws_port is absent. Same2223  *  fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */@@ -48,7 +49,7 @@4849 }4950 5051 export interface CodePairingCallbacks {51-  /** Show the 6-digit SAS; the popup must compare it against the peer's. */52+  /** Show the 8-digit SAS; the popup must compare it against the peer's. */5253   onSasReady(sas: string): void;5354   /** Pairing succeeded and the device was persisted. */5455   onComplete(device: PairedDevice): void;@@ -135,7 +136,7 @@135136 136137   try {137138     const { identity, privateKey, publicKey } = local;138-    if (bytesToHex(publicKey) !== identity.longTermPublicKeyHex) {139+    if (!constantTimeEqualHex(bytesToHex(publicKey), identity.longTermPublicKeyHex)) {139140       throw new Error("local pairing key does not match advertised identity");140141     }141142     // E1: prove the private half of the advertised initiator identity before@@ -158,7 +159,10 @@158159     await channel.send(encodeJson(ourFrame));159160 160161     const theirFrame = decodePairIdentity(await channel.receive());161-    if (theirFrame.long_term_public_key_hex !== bytesToHex(remoteStaticPublicKey)) {162+    if (!constantTimeEqualHex(163+      theirFrame.long_term_public_key_hex,164+      bytesToHex(remoteStaticPublicKey),165+    )) {162166       throw new Error("peer identity does not match Noise responder key");163167     }164168 

view file

src/background/paired-devices.ts+5 −1
@@ -21,6 +21,7 @@2121 // boundary (see removePairedDeviceById).2222 2323 import type { PairedDevice } from "../shared/sync-types";24+import { constantTimeEqualHex } from "../shared/constant-time";2425 2526 const REGISTRY_KEY = "peach_paired_devices_v3";2627 const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";@@ -45,7 +46,10 @@4546 4647 export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {4748   const devices = await listPairedDevices();48-  return devices.find((d) => d.longTermPublicKeyHex === pubkeyHex) ?? null;49+  return devices.find((d) =>50+    d.longTermPublicKeyHex !== undefined &&51+    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)52+  ) ?? null;4953 }5054 5155 /**

view file

src/background/sync-runner.ts+5 −1
@@ -37,6 +37,7 @@3737 import { getLocalSubnet } from "../shared/local-network";3838 import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";3939 import { PAIRING_PORT } from "../shared/pairing-dial";40+import { constantTimeEqualHex } from "../shared/constant-time";4041 import { getRootKeyHex, isUnlocked } from "./vault";4142 import type { PairedDevice } from "../shared/sync-types";4243 import type { SyncRunResult } from "./sync-trigger";@@ -125,7 +126,10 @@125126   const masterKey = hexToBytesLocal(masterKeyHex);126127   const publicIdentity = await buildLocalIdentity(masterKey);127128   const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);128-  if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localKeys.publicKey)) {129+  if (!constantTimeEqualHex(130+    publicIdentity.longTermPublicKeyHex,131+    bytesToHex(localKeys.publicKey),132+  )) {129133     masterKey.fill(0);130134     localKeys.secretKey.fill(0);131135     throw new Error("derived reconnect identity mismatch");

view file

src/background/sync-vault-adapter.ts+5 −1
@@ -34,6 +34,7 @@3434   buildSignedDeviceManifest,3535   verifyDeviceManifest,3636 } from "../shared/device-manifest";37+import { constantTimeEqualHex } from "../shared/constant-time";3738 import {3839   applyMergedRemoteEntries,3940   getDeletedEntries,@@ -316,7 +317,10 @@316317     // popup) and live (the broadcast updates an open one).317318     if (318319       existing.longTermPublicKeyHex &&319-      existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()320+      !constantTimeEqualHex(321+        existing.longTermPublicKeyHex,322+        entry.longTermPublicKeyHex,323+      )320324     ) {321325       notifyPopup({322326         type: "DEVICE_KEY_ROTATED",

view file

src/popup/components/AdoptionConsentModal.test.tsx+2 −2
@@ -15,7 +15,7 @@1515 function mount(overrides: Partial<Parameters<typeof AdoptionConsentModal>[0]> = {}) {1616   const props = {1717     receiverDeviceName: "June's iPhone",18-    sas: "895245",18+    sas: "89524517",1919     verifyMasterPassword: vi.fn(async () => true),2020     onCancel: vi.fn(),2121     onApprove: vi.fn(async () => {}),@@ -42,7 +42,7 @@4242     const { container } = mounted();4343     expect(container.textContent).toContain("June's iPhone");4444     expect(container.textContent).toContain("copies your entire vault including all passwords and Pro");45-    expect(container.textContent).toContain("8 9 5 2 4 5");45+    expect(container.textContent).toContain("89524517");4646   });4747 4848   it("T-A17: Share vault button is disabled until a password is entered", () => {

view file

src/popup/components/AdoptionConsentModal.tsx+1 −1
@@ -49,7 +49,7 @@4949         </p>5050         <div class="peach-adoption-consent-sas">5151           <span>Confirm the code matches</span>52-          <strong>{props.sas.replace(/(\d)(?=\d)/g, "$1 ")}</strong>52+          <strong>{props.sas}</strong>5353         </div>5454         <label class="peach-label" for="adoption-consent-password">5555           Enter your master password to approve

view file

src/popup/styles/popup.css+3 −1
@@ -7608,9 +7608,11 @@76087608   background: rgba(245, 158, 11, 0.12);76097609 }76107610 7611+/* Tracking, not interleaved spaces: eight digits with per-digit spaces no7612+   longer fit beside the label at 320px. */76117613 .peach-adoption-consent-sas strong {76127614   font-variant-numeric: tabular-nums;7613-  letter-spacing: 0;7615+  letter-spacing: 0.18em;76147616   white-space: nowrap;76157617 }76167618 

view file

src/popup/views/PairDevice.test.tsx+8 −8
@@ -120,11 +120,11 @@120120     });121121 122122     pushFromBackground("ADOPTION_SOURCE_SAS", {123-      sas: "482193",123+      sas: "48219371",124124       receiverDeviceName: "June's phone",125125     });126126     expect(container.textContent).toContain("June's phone");127-    expect(container.textContent).toContain("4 8 2 1 9 3");127+    expect(container.textContent).toContain("48219371");128128 129129     inputText(130130       container.querySelector("#adoption-consent-password") as HTMLInputElement,@@ -254,10 +254,10 @@254254     buttonByText(container, "Connect")!.click();255255     await Promise.resolve();256256 257-    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });257+    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });258258 259259     expect(container.textContent).toContain("Confirm this code matches on your phone:");260-    expect(container.textContent).toContain("895245");260+    expect(container.textContent).toContain("89524517");261261     expect(buttonByText(container, "Confirm")).toBeTruthy();262262     expect(buttonByText(container, "Cancel")).toBeTruthy();263263   });@@ -268,7 +268,7 @@268268     inputText(codeInput(container), VALID_CODE);269269     buttonByText(container, "Connect")!.click();270270     await Promise.resolve();271-    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });271+    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });272272 273273     buttonByText(container, "Confirm")!.click();274274     await Promise.resolve();@@ -284,7 +284,7 @@284284     inputText(codeInput(container), VALID_CODE);285285     buttonByText(container, "Connect")!.click();286286     await Promise.resolve();287-    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });287+    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });288288 289289     buttonByText(container, "Cancel")!.click();290290     await Promise.resolve();@@ -327,7 +327,7 @@327327     inputText(codeInput(container), VALID_CODE);328328     buttonByText(container, "Connect")!.click();329329     await Promise.resolve();330-    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });330+    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });331331     buttonByText(container, "Confirm")!.click();332332     await Promise.resolve();333333 @@ -346,7 +346,7 @@346346       inputText(codeInput(container), VALID_CODE);347347       buttonByText(container, "Connect")!.click();348348       await Promise.resolve();349-      pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });349+      pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });350350       buttonByText(container, "Confirm")!.click();351351       await Promise.resolve();352352       pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });

view file

src/popup/views/PairDevice.tsx+1 −1
@@ -377,7 +377,7 @@377377             {sas()}378378           </div>379379           <div class="peach-pair-sub" style="max-width:240px;">380-            Only continue if this 6-digit code is identical on both screens. This is380+            Only continue if this 8-digit code is identical on both screens. This is381381             what proves you're talking to your own device.382382           </div>383383           <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">

view file

src/shared/constant-time.test.ts+44 −0
@@ -0,0 +1,44 @@1+import { describe, expect, it } from "vitest";2+import { constantTimeEqualBytes, constantTimeEqualHex } from "./constant-time";3+4+describe("constantTimeEqualBytes", () => {5+  it("matches identical contents and rejects a single differing byte", () => {6+    const a = Uint8Array.from([1, 2, 3, 4]);7+    expect(constantTimeEqualBytes(a, Uint8Array.from([1, 2, 3, 4]))).toBe(true);8+    expect(constantTimeEqualBytes(a, Uint8Array.from([1, 2, 3, 5]))).toBe(false);9+    expect(constantTimeEqualBytes(a, Uint8Array.from([9, 2, 3, 4]))).toBe(false);10+  });11+12+  it("rejects a length mismatch even when the shared prefix matches", () => {13+    // The out-of-range reads fold in as 0, so a trailing zero byte must not14+    // make a longer value compare equal to its own prefix.15+    const prefix = Uint8Array.from([1, 2, 3]);16+    expect(constantTimeEqualBytes(prefix, Uint8Array.from([1, 2, 3, 0]))).toBe(false);17+    expect(constantTimeEqualBytes(Uint8Array.from([1, 2, 3, 0]), prefix)).toBe(false);18+    expect(constantTimeEqualBytes(new Uint8Array(0), new Uint8Array(0))).toBe(true);19+  });20+21+  it("scans the whole buffer rather than stopping at the first difference", () => {22+    // Same number of iterations regardless of where the mismatch sits — the23+    // property the callers depend on. Verified by behaviour, not by timing.24+    const left = new Uint8Array(64);25+    const early = new Uint8Array(64);26+    early[0] = 1;27+    const late = new Uint8Array(64);28+    late[63] = 1;29+    expect(constantTimeEqualBytes(left, early)).toBe(false);30+    expect(constantTimeEqualBytes(left, late)).toBe(false);31+  });32+});33+34+describe("constantTimeEqualHex", () => {35+  it("compares hex case-insensitively", () => {36+    expect(constantTimeEqualHex("ABcd12", "abCD12")).toBe(true);37+    expect(constantTimeEqualHex("abcd12", "abcd13")).toBe(false);38+  });39+40+  it("rejects differing lengths", () => {41+    expect(constantTimeEqualHex("abcd", "abcd00")).toBe(false);42+    expect(constantTimeEqualHex("", "")).toBe(true);43+  });44+});

view file

src/shared/constant-time.ts+25 −0
@@ -0,0 +1,25 @@1+/**2+ * Compare fixed-format cryptographic values without data-dependent early exit.3+ *4+ * Length remains observable, so callers should validate public wire formats5+ * first. Peach's keys and tokens have fixed lengths at every authenticated6+ * comparison site.7+ */8+export function constantTimeEqualBytes(9+  left: Uint8Array,10+  right: Uint8Array,11+): boolean {12+  let difference = left.length ^ right.length;13+  const length = Math.max(left.length, right.length);14+  for (let index = 0; index < length; index += 1) {15+    difference |= (left[index] ?? 0) ^ (right[index] ?? 0);16+  }17+  return difference === 0;18+}19+20+export function constantTimeEqualHex(left: string, right: string): boolean {21+  return constantTimeEqualBytes(22+    new TextEncoder().encode(left.toLowerCase()),23+    new TextEncoder().encode(right.toLowerCase()),24+  );25+}

view file

src/shared/pairing-dial.test.ts+1 −1
@@ -84,7 +84,7 @@8484     const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });8585 8686     expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });87-    expect(result.ok && result.dialled.sas).toBe("895245");87+    expect(result.ok && result.dialled.sas).toBe("44907520");8888     expect(result.ok && result.dialled.host).toBe("192.168.4.77");8989     expect(result.ok && result.dialled.rendezvousSecretHex).toBe(9090       "a8977cd86e944e72e9cb569dbba00a7b0abbf7f456c745299aa8f66157859143",

view file

src/shared/pairing-dial.ts+1 −1
@@ -62,7 +62,7 @@6262 export interface DialledPairingChannel {6363   /** Encrypted channel — everything written to it is Noise-protected. */6464   channel: PairingTransport;65-  /** The 6-digit code the user must confirm matches the listener's screen. */65+  /** The 8-digit code the user must confirm matches the listener's screen. */6666   sas: string;6767   /** The address actually connected to, for the reconnect cache. */6868   host: string;

view file

src/shared/pairing-handshake.test.ts+13 −8
@@ -56,9 +56,9 @@5656     expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);5757     expect(respLearned.trustSource).toBe("direct");5858 59-    // Both ends derive the SAME 6-digit SAS from the shared handshake hash —59+    // Both ends derive the SAME 8-digit SAS from the shared handshake hash —6060     // this is what the user compares across devices.61-    expect(initResult.sas).toMatch(/^\d{6}$/);61+    expect(initResult.sas).toMatch(/^\d{8}$/);6262     expect(initResult.sas).toBe(respResult.sas);6363   });6464 @@ -161,10 +161,10 @@161161 });162162 163163 describe("derivePairingSAS (out-of-band MITM-detection code)", () => {164-  it("returns a deterministic 6-digit code", () => {164+  it("returns a deterministic 8-digit code", () => {165165     const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));166166     const sas = derivePairingSAS(h);167-    expect(sas).toMatch(/^\d{6}$/);167+    expect(sas).toMatch(/^\d{8}$/);168168     expect(derivePairingSAS(h)).toBe(sas);169169   });170170 @@ -176,12 +176,17 @@176176   });177177 178178   it("differs for different handshake hashes", () => {179-    expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0))).not.toBe(180-      derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff)),179+    expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0))).not.toBe(180+      derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)),181181     );182182   });183183 184-  it("handles undersized input without throwing", () => {185-    expect(derivePairingSAS(new Uint8Array(2))).toBe("000000");184+  it("pins the cross-platform big-endian 64-bit vector", () => {185+    expect(derivePairingSAS(Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7))).toBe("52306183");186+    expect(derivePairingSAS(new Uint8Array(8).fill(0xff))).toBe("09551615");187+  });188+189+  it("rejects undersized input", () => {190+    expect(() => derivePairingSAS(new Uint8Array(7))).toThrow(/too short/);186191   });187192 });

view file

src/shared/pairing-handshake.ts+11 −11
@@ -220,23 +220,23 @@220220 // ───────────────────────────────────────────────────────────────────────────221221 222222 /**223- * Derive a 6-digit Short Authentication String from a Noise handshake hash.223+ * Derive an 8-digit Short Authentication String from a Noise handshake hash.224224  * Both pairing peers compute the SAME value because the Noise `handshakeHash`225225  * is shared after the handshake. Showing it on both devices and having the226226  * user confirm they match authenticates the channel out-of-band, detecting a227227  * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).228228  */229229 export function derivePairingSAS(handshakeHash: Uint8Array): string {230-  // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform231-  // enough for a 6-digit comparison code; the handshake hash is already a PRF232-  // output so no further mixing is needed.233-  if (handshakeHash.length < 4) return "000000";234-  const n =235-    ((handshakeHash[0] << 24) >>> 0) +236-    (handshakeHash[1] << 16) +237-    (handshakeHash[2] << 8) +238-    handshakeHash[3];239-  return String((n >>> 0) % 1_000_000).padStart(6, "0");230+  if (handshakeHash.length < 8) {231+    throw new Error("handshake hash too short for SAS");232+  }233+  // Use 64 PRF-output bits so modulo bias at the 1e8 boundary is negligible.234+  // The identical big-endian rule is pinned by Android cross-platform KATs.235+  let value = 0n;236+  for (const byte of handshakeHash.subarray(0, 8)) {237+    value = (value << 8n) | BigInt(byte);238+  }239+  return String(value % 100_000_000n).padStart(8, "0");240240 }241241 242242 /**

view file