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
↓ peach-browser-146d23dc.tar.gzatlas at this save
files changed6 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 −5manifest.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": {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",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",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 asrc/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", () => {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 {