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
↓ peach-browser-28e4cc72.tar.gzatlas at this save
files changed19 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 −11manifest.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": {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",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",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 () => {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 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 /**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");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",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", () => {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 approvesrc/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 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 });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;">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+});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+}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",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;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 });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 /**