146d23dcd148

invitation carries vaultStatus: 'empty' for the onboarding receiver (no vault) and 'populated' for the Sync settings source (has vault). The phone reads this to decide its role — source or receiver — instead of guessing. Older invitations without the field parse as 'populated' (the historical default) so the existing sync is unaffected. (v4.3.13)

time
2026-07-28 13:06 UTC · 3 months ago
machine
koh2
agent
claude-MiniMax-M3
session
inverse pairing role in QR
parent
f65d67bbb5ba
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.ts+6 −2src/shared/pairing-invitation.test.ts+50 −6src/shared/pairing-invitation.ts+28 −5
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.12",4+  "version": "4.3.13",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.3.12",4+  "version": "4.3.13",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.3.12",3+  "version": "4.3.13",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.ts+6 −2
@@ -71,7 +71,9 @@7171     const relayUrl = relayInForce(settings);7272     local = await getLocalPairingIdentity();7373     const token = generatePairingRendezvousToken();74-    const encoded = createPairingInvitation(relayUrl, token);74+    // Source side has the vault — caller checked isUnlocked — so the phone75+    // can read the invitation and know it should receive the vault.76+    const encoded = createPairingInvitation(relayUrl, token, "populated");7577     const invitation = parsePairingInvitation(encoded);7678     if (!invitation) throw new Error("invitation_generation_failed");7779 @@ -120,7 +122,9 @@120122     const settings = await getSyncSettings();121123     const relayUrl = relayInForce(settings);122124     const token = generatePairingRendezvousToken();123-    const encoded = createPairingInvitation(relayUrl, token);125+    // Receiver side is the empty browser — onboarding hasn't created a vault126+    // yet. The phone sees "empty" in the invitation and switches to source.127+    const encoded = createPairingInvitation(relayUrl, token, "empty");124128     const invitation = parsePairingInvitation(encoded);125129     if (!invitation) throw new Error("invitation_generation_failed");126130     // Open the relay socket so the rendezvous token is reserved. Without a

view file

src/shared/pairing-invitation.test.ts+50 −6
@@ -9,21 +9,65 @@99 1010 const TOKEN = "AAECAwQFBgcICQoLDA0ODw";1111 const RELAY = "wss://relay.peachpasswords.com";12-const KAT =12+// Cross-client compact vector (pinned in the changelog). Adding a top-level key13+// would change the byte length, so this is a separate vector from the v1 one.14+const KAT_POPULATED =15+  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoicG9wdWxhdGVkIn0";16+// A pre-vaultStatus invitation as it would have circulated before the field17+// existed. Older phones parse this and fall back to "populated".18+const KAT_LEGACY =1319   "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";1420 1521 describe("relay pairing invitation", () => {1622   it("pins the cross-client compact JSON/base64url vector", () => {17-    expect(createPairingInvitation(`${RELAY}/`, TOKEN)).toBe(KAT);18-    expect(parsePairingInvitation(KAT)).toEqual({ relayUrl: RELAY, token: TOKEN });23+    expect(createPairingInvitation(`${RELAY}/`, TOKEN, "populated")).toBe(KAT_POPULATED);24+    expect(parsePairingInvitation(KAT_POPULATED)).toEqual({25+      relayUrl: RELAY,26+      token: TOKEN,27+      vaultStatus: "populated",28+    });1929   });2030 31+  it("round-trips the vaultStatus field in both directions", () => {32+    for (const status of ["empty", "populated"] as const) {33+      const encoded = createPairingInvitation(RELAY, TOKEN, status);34+      const parsed = parsePairingInvitation(encoded);35+      expect(parsed).toEqual({ relayUrl: RELAY, token: TOKEN, vaultStatus: status });36+    }37+  });38+39+  it("defaults to 'populated' for legacy invitations without the field", () => {40+    // Older invitations from before vaultStatus existed still parse cleanly —41+    // they default to "populated" so the phone does the right thing.42+    expect(parsePairingInvitation(KAT_LEGACY)).toEqual({43+      relayUrl: RELAY,44+      token: TOKEN,45+      vaultStatus: "populated",46+    });47+  });48+49+  it("defaults vaultStatus to 'populated' when omitted from createPairingInvitation", () => {50+    const encoded = createPairingInvitation(RELAY, TOKEN);51+    expect(parsePairingInvitation(encoded)).toEqual({52+      relayUrl: RELAY,53+      token: TOKEN,54+      vaultStatus: "populated",55+    });56+  });57+58+  it("ignores an unknown vault value rather than failing the parse", () => {59+    const bogus = createRaw({ relay: RELAY, token: TOKEN, vault: "full" });60+    const parsed = parsePairingInvitation(bogus);61+    expect(parsed).toEqual({ relayUrl: RELAY, token: TOKEN, vaultStatus: "populated" });62+  });63+2164   it("builds a room URL without identity or vault state", () => {22-    const parsed = parsePairingInvitation(KAT);65+    const parsed = parsePairingInvitation(KAT_POPULATED);2366     expect(parsed).not.toBeNull();2467     expect(pairingInvitationUrl(parsed!)).toBe(`${RELAY}/r/${TOKEN}`);25-    expect(KAT).not.toContain("device");26-    expect(KAT).not.toContain("vault");68+    expect(KAT_POPULATED).not.toContain("device");69+    expect(KAT_POPULATED).not.toContain("identity");70+    expect(KAT_POPULATED).not.toContain("encrypted");2771   });2872 2973   it("fails closed on malformed, insecure, or short-token payloads", () => {

view file

src/shared/pairing-invitation.ts+28 −5
@@ -6,23 +6,37 @@66 77 export const PAIRING_INVITATION_PREFIX = "peach-pair-v1:";88 9+export type VaultStatus = "empty" | "populated";10+911 export interface PairingInvitation {1012   relayUrl: string;1113   token: string;14+  /**15+   * Whether the device that issued this invitation has a vault. The phone reads16+   * this to decide its role on connect:17+   *   "empty"     → issuer has no vault; the phone is the source and shares its vault18+   *   "populated" → issuer has the vault; the phone is the receiver and receives it19+   *20+   * Older invitations from before this field existed parse as "populated" (the21+   * historical default), so a phone that doesn't know about `vaultStatus` still22+   * does the right thing: it receives the vault from the source.23+   */24+  vaultStatus: VaultStatus;1225 }1326 1427 /**15- * QR/manual first-pair payload. It contains reachability only: a credential-free16- * relay URL and a fresh 128-bit bearer token. No identity or vault material is17- * present before Noise and the two-person SAS gate.28+ * QR/manual first-pair payload. Reachability plus a credential-free relay URL,29+ * a fresh 128-bit bearer token, and the issuer's vault state — no identity or30+ * vault material is present before Noise and the two-person SAS gate.1831  */1932 export function createPairingInvitation(2033   relayUrl: string,2134   token = generatePairingRendezvousToken(),35+  vaultStatus: VaultStatus = "populated",2236 ): string {2337   const normalized = normalizeRelayUrl(relayUrl);2438   assertToken(token);25-  const json = JSON.stringify({ relay: normalized, token });39+  const json = JSON.stringify({ relay: normalized, token, vault: vaultStatus });2640   return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;2741 }2842 @@ -33,12 +47,17 @@3347     const json = new TextDecoder("utf-8", { fatal: true }).decode(3448       base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),3549     );36-    const parsed = JSON.parse(json) as { relay?: unknown; token?: unknown };50+    const parsed = JSON.parse(json) as {51+      relay?: unknown;52+      token?: unknown;53+      vault?: unknown;54+    };3755     if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;3856     assertToken(parsed.token);3957     return {4058       relayUrl: normalizeRelayUrl(parsed.relay),4159       token: parsed.token,60+      vaultStatus: parseVaultStatus(parsed.vault),4261     };4362   } catch {4463     return null;@@ -47,6 +66,10 @@4766 4867 export function pairingInvitationUrl(invitation: PairingInvitation): string {4968   return buildRendezvousUrl(invitation.relayUrl, invitation.token);69+}70+71+function parseVaultStatus(value: unknown): VaultStatus {72+  return value === "empty" ? "empty" : "populated";5073 }5174 5275 function assertToken(token: string): void {

view file