61a600251c9b
fix: gate vault adoption on both confirmations (v4.0.46)
- time
- 2026-07-26 04:47 UTC · 3 months ago
- machine
- koh2
- parent
- 91e964e65906
↓ peach-browser-61a60025.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+6 −6src/shared/vault-adopt.test.ts+39 −7src/shared/vault-adopt.ts+31 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.45",4+ "version": "4.0.46",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.0.45",4+ "version": "4.0.46",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.0.45",3+ "version": "4.0.46",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/App.tsx+6 −6
@@ -1206,14 +1206,12 @@12061206 const { channel, sas, socket, host } = dial.dialled;12071207 12081208 try {1209- // Same out-of-band gate as device pairing, and for the same reason: the1210- // token proves nothing, the user comparing this code does. Ask BEFORE1211- // requesting the vault's root key.1212- if (!(await args.confirmSAS(sas))) throw new Error("sas_rejected");1213-12141209 const source = await requestAdoption(channel, {12151210 deviceName: defaultAdoptionDeviceName(),12161211 generateDeviceId: () => crypto.randomUUID(),1212+ // requestAdoption writes ADOPT_CONFIRM and refuses to read a root-key1213+ // grant until this local SAS decision is true.1214+ confirmSas: () => args.confirmSAS(sas),12171215 createVaultFromRootKey: args.createVault,12181216 deriveLocalIdentity: async () => {12191217 const res = await sendMessage({ type: "GET_LOCAL_IDENTITY" });@@ -1244,7 +1242,9 @@12441242 if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();12451243 },12461244 onError(error) {1247- throw new Error(error);1245+ throw new Error(1246+ error === "ADOPT_SAS_REJECTED" ? "sas_rejected" : error,1247+ );12481248 },12491249 });12501250 if (!source) throw new Error("adoption_rejected");src/shared/vault-adopt.test.ts+39 −7
@@ -57,6 +57,7 @@5757 const receiverDeps: AdoptionReceiverDeps = {5858 deviceName: "Receiver",5959 generateDeviceId: () => "receiver-ephemeral",60+ confirmSas: vi.fn(async () => true),6061 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),6162 deriveLocalIdentity: vi.fn(async () => identity("receiver")),6263 upsertPairedDevice: receiverUpsert,@@ -87,11 +88,33 @@8788 });8889 8990 it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {90- const onConsent = vi.fn();91- const channel = memoryPair()[0];92- await expect(Promise.reject(new Error("sas_rejected"))).rejects.toThrow("sas_rejected");93- expect(onConsent).not.toHaveBeenCalled();94- void channel;91+ const [receiverChannel, sourceChannel] = memoryPair();92+ const rootKeyHex = vi.fn(async () => "11".repeat(32));93+ const createVaultFromRootKey = vi.fn();94+ const receiver = requestAdoption(receiverChannel, {95+ deviceName: "Receiver",96+ generateDeviceId: () => "receiver-ephemeral",97+ confirmSas: vi.fn(async () => false),98+ createVaultFromRootKey,99+ deriveLocalIdentity: vi.fn(),100+ upsertPairedDevice: vi.fn(),101+ onError: vi.fn(),102+ });103+ const request = JSON.parse(104+ new TextDecoder().decode(await sourceChannel.receive()),105+ ) as AdoptRequest;106+ const source = grantAdoption(sourceChannel, request, {107+ sourceIdentity: identity("source"),108+ rootKeyHex,109+ vaultId: vi.fn(),110+ onConsent: vi.fn(async () => true),111+ upsertPairedDevice: vi.fn(),112+ });113+114+ await expect(receiver).resolves.toBeNull();115+ await expect(source).resolves.toBeNull();116+ expect(rootKeyHex).not.toHaveBeenCalled();117+ expect(createVaultFromRootKey).not.toHaveBeenCalled();95118 });96119 97120 it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {@@ -101,6 +124,7 @@101124 const receiver = requestAdoption(receiverChannel, {102125 deviceName: "Receiver",103126 generateDeviceId: () => "receiver-ephemeral",127+ confirmSas: vi.fn(async () => true),104128 createVaultFromRootKey,105129 deriveLocalIdentity: vi.fn(),106130 upsertPairedDevice: vi.fn(),@@ -127,6 +151,7 @@127151 const receiver = requestAdoption(receiverChannel, {128152 deviceName: "Receiver",129153 generateDeviceId: () => "receiver-ephemeral",154+ confirmSas: vi.fn(async () => true),130155 createVaultFromRootKey,131156 deriveLocalIdentity: vi.fn(),132157 upsertPairedDevice: vi.fn(),@@ -152,6 +177,7 @@152177 const receiver = requestAdoption(receiverChannel, {153178 deviceName: "Receiver",154179 generateDeviceId: () => "receiver-ephemeral",180+ confirmSas: vi.fn(async () => true),155181 createVaultFromRootKey,156182 deriveLocalIdentity: vi.fn(),157183 upsertPairedDevice: vi.fn(),@@ -171,9 +197,9 @@171197 });172198 173199 it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {174- const channel = memoryPair()[0];200+ const [receiverChannel, sourceChannel] = memoryPair();175201 const upsert = vi.fn();176- const promise = grantAdoption(channel, {202+ const promise = grantAdoption(sourceChannel, {177203 type: "ADOPT_REQUEST",178204 device_id: "receiver-ephemeral",179205 device_name: "Receiver",@@ -185,6 +211,10 @@185211 onConsent: vi.fn(async () => true),186212 upsertPairedDevice: upsert,187213 });214+ await sendFrame(receiverChannel, {215+ type: "ADOPT_CONFIRM",216+ confirmed: true,217+ });188218 189219 await Promise.race([promise, Promise.resolve()]);190220 expect(upsert).not.toHaveBeenCalled();@@ -198,6 +228,7 @@198228 const receiver = requestAdoption(receiverChannel, {199229 deviceName: "Receiver",200230 generateDeviceId: () => "receiver-ephemeral",231+ confirmSas: vi.fn(async () => true),201232 createVaultFromRootKey: vi.fn(async () => {202233 throw new Error("commit_failed");203234 }),@@ -225,6 +256,7 @@225256 const receiver = requestAdoption(receiverChannel, {226257 deviceName: "Receiver",227258 generateDeviceId: () => "receiver-ephemeral",259+ confirmSas: vi.fn(async () => true),228260 createVaultFromRootKey,229261 deriveLocalIdentity: vi.fn(),230262 upsertPairedDevice: vi.fn(),src/shared/vault-adopt.ts+31 −1
@@ -24,6 +24,11 @@2424 };2525 }2626 27+export interface AdoptConfirm {28+ type: "ADOPT_CONFIRM";29+ confirmed: boolean;30+}31+2732 export interface AdoptIdentity {2833 type: "ADOPT_IDENTITY";2934 device_id: string;@@ -33,11 +38,17 @@3338 signing_public_key_hex?: string;3439 }3540 36-export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;41+export type AdoptionFrame =42+ | AdoptRequest43+ | AdoptConfirm44+ | AdoptGrant45+ | AdoptIdentity;3746 3847 export interface AdoptionReceiverDeps {3948 deviceName: string;4049 generateDeviceId(): string;50+ /** Root-key transfer cannot begin until this device confirms the SAS. */51+ confirmSas(): Promise<boolean>;4152 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;4253 deriveLocalIdentity(): Promise<LocalIdentity>;4354 upsertPairedDevice(device: PairedDevice): Promise<void>;@@ -78,6 +89,12 @@7889 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);7990 }8091 92+export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {93+ const value = frame as Partial<AdoptConfirm> | null;94+ return value?.type === "ADOPT_CONFIRM" &&95+ typeof value.confirmed === "boolean";96+}97+8198 export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {8299 const value = frame as Partial<AdoptIdentity> | null;83100 return value?.type === "ADOPT_IDENTITY" &&@@ -104,6 +121,16 @@104121 device_type: "extension",105122 });106123 124+ const confirmed = await deps.confirmSas();125+ await sendFrame(channel, {126+ type: "ADOPT_CONFIRM",127+ confirmed,128+ });129+ if (!confirmed) {130+ deps.onError("ADOPT_SAS_REJECTED");131+ return null;132+ }133+107134 const frame = await receiveFrame(channel);108135 if (!isAdoptGrant(frame)) {109136 deps.onError("ADOPT_UNEXPECTED_FRAME");@@ -160,6 +187,9 @@160187 ): Promise<PairedDevice | null> {161188 const approved = await deps.onConsent(request);162189 if (!approved) return null;190+191+ const confirmation = await receiveFrame(channel);192+ if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;163193 164194 const rootKeyHex = await deps.rootKeyHex();165195 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");