d3b26d4046c9
feat: add ADOPT_REQUEST grant protocol (v4.0.15)
- time
- 2026-06-19 19:50 UTC · 4 months ago
- machine
- koh2
- agent
- gpt-5
- session
- vault adoption integration
- parent
- 1d6e63e6bfd3
↓ peach-browser-d3b26d40.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/chirp-adopt.test.ts+246 −0src/shared/chirp-adopt.ts+210 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "4.0.14",5+ "version": "4.0.15",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.14",4+ "version": "4.0.15",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.14",3+ "version": "4.0.15",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/chirp-adopt.test.ts+246 −0
@@ -0,0 +1,246 @@1+import { describe, expect, it, vi } from "vitest";2+import { computeVaultId } from "./sync-identity";3+import {4+ grantAdoption,5+ requestAdoption,6+ sendFrame,7+ type AdoptRequest,8+ type AdoptionReceiverDeps,9+ type AdoptionSourceDeps,10+} from "./chirp-adopt";11+import type { PairingTransport } from "./chirp-handshake";12+13+function memoryPair(): [PairingTransport, PairingTransport] {14+ const a: Uint8Array[] = [];15+ const b: Uint8Array[] = [];16+ const ar: Array<(v: Uint8Array) => void> = [];17+ const br: Array<(v: Uint8Array) => void> = [];18+ function make(19+ inbox: Uint8Array[],20+ outbox: Uint8Array[],21+ readers: Array<(v: Uint8Array) => void>,22+ outReaders: Array<(v: Uint8Array) => void>,23+ ): PairingTransport {24+ return {25+ async send(msg) {26+ const reader = outReaders.shift();27+ if (reader) reader(msg);28+ else outbox.push(msg);29+ },30+ receive() {31+ const msg = inbox.shift();32+ if (msg) return Promise.resolve(msg);33+ return new Promise((resolve) => readers.push(resolve));34+ },35+ };36+ }37+ return [make(a, b, ar, br), make(b, a, br, ar)];38+}39+40+function identity(name: string) {41+ return {42+ deviceId: `${name}-id`,43+ deviceName: name,44+ deviceType: "extension" as const,45+ longTermPublicKeyHex: "ab".repeat(32),46+ signingPublicKeyHex: "cd".repeat(32),47+ };48+}49+50+describe("vault adoption sub-protocol", () => {51+ it("T-A10: completes ADOPT_REQUEST/ADOPT_GRANT/ADOPT_IDENTITY KAT", async () => {52+ const [receiverChannel, sourceChannel] = memoryPair();53+ const rootKeyHex = "11".repeat(32);54+ const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));55+ const sourceUpsert = vi.fn();56+ const receiverUpsert = vi.fn();57+ const receiverDeps: AdoptionReceiverDeps = {58+ deviceName: "Receiver",59+ generateDeviceId: () => "receiver-ephemeral",60+ createVaultFromRootKey: vi.fn(async () => ({ vaultId })),61+ deriveLocalIdentity: vi.fn(async () => identity("receiver")),62+ upsertPairedDevice: receiverUpsert,63+ applyLicense: vi.fn(),64+ onError: vi.fn(),65+ };66+ const sourceDeps: AdoptionSourceDeps = {67+ sourceIdentity: identity("source"),68+ rootKeyHex: vi.fn(async () => rootKeyHex),69+ vaultId: vi.fn(async () => vaultId),70+ license: vi.fn(async () => "PPK-test"),71+ onConsent: vi.fn(async () => true),72+ upsertPairedDevice: sourceUpsert,73+ };74+75+ await Promise.all([76+ requestAdoption(receiverChannel, receiverDeps),77+ (async () => {78+ const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;79+ await grantAdoption(sourceChannel, request, sourceDeps);80+ })(),81+ ]);82+83+ expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);84+ expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-id" }));85+ expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-id" }));86+ expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");87+ });88+89+ 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;95+ });96+97+ it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {98+ const [receiverChannel, sourceChannel] = memoryPair();99+ const createVaultFromRootKey = vi.fn();100+ const errors: string[] = [];101+ const receiver = requestAdoption(receiverChannel, {102+ deviceName: "Receiver",103+ generateDeviceId: () => "receiver-ephemeral",104+ createVaultFromRootKey,105+ deriveLocalIdentity: vi.fn(),106+ upsertPairedDevice: vi.fn(),107+ onError: (err) => errors.push(err),108+ });109+ const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;110+ await grantAdoption(sourceChannel, request, {111+ sourceIdentity: identity("source"),112+ rootKeyHex: vi.fn(),113+ vaultId: vi.fn(),114+ onConsent: vi.fn(async () => false),115+ upsertPairedDevice: vi.fn(),116+ });117+ await Promise.race([receiver, Promise.resolve()]);118+119+ expect(createVaultFromRootKey).not.toHaveBeenCalled();120+ expect(errors).toEqual([]);121+ });122+123+ it("T-A13: invalid root_key_hex aborts gracefully", async () => {124+ const [receiverChannel, sourceChannel] = memoryPair();125+ const onError = vi.fn();126+ const createVaultFromRootKey = vi.fn();127+ const receiver = requestAdoption(receiverChannel, {128+ deviceName: "Receiver",129+ generateDeviceId: () => "receiver-ephemeral",130+ createVaultFromRootKey,131+ deriveLocalIdentity: vi.fn(),132+ upsertPairedDevice: vi.fn(),133+ onError,134+ });135+ await sourceChannel.receive();136+ await sendFrame(sourceChannel, {137+ type: "ADOPT_GRANT",138+ root_key_hex: "bad",139+ vault_id: "00".repeat(32),140+ source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },141+ } as never);142+ await receiver;143+144+ expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");145+ expect(createVaultFromRootKey).not.toHaveBeenCalled();146+ });147+148+ it("T-A14: vault_id mismatch aborts before vault creation", async () => {149+ const [receiverChannel, sourceChannel] = memoryPair();150+ const onError = vi.fn();151+ const createVaultFromRootKey = vi.fn();152+ const receiver = requestAdoption(receiverChannel, {153+ deviceName: "Receiver",154+ generateDeviceId: () => "receiver-ephemeral",155+ createVaultFromRootKey,156+ deriveLocalIdentity: vi.fn(),157+ upsertPairedDevice: vi.fn(),158+ onError,159+ });160+ await sourceChannel.receive();161+ await sendFrame(sourceChannel, {162+ type: "ADOPT_GRANT",163+ root_key_hex: "22".repeat(32),164+ vault_id: "00".repeat(32),165+ source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },166+ });167+ await receiver;168+169+ expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");170+ expect(createVaultFromRootKey).not.toHaveBeenCalled();171+ });172+173+ it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {174+ const channel = memoryPair()[0];175+ const upsert = vi.fn();176+ const promise = grantAdoption(channel, {177+ type: "ADOPT_REQUEST",178+ device_id: "receiver-ephemeral",179+ device_name: "Receiver",180+ device_type: "extension",181+ }, {182+ sourceIdentity: identity("source"),183+ rootKeyHex: vi.fn(async () => "33".repeat(32)),184+ vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),185+ onConsent: vi.fn(async () => true),186+ upsertPairedDevice: upsert,187+ });188+189+ await Promise.race([promise, Promise.resolve()]);190+ expect(upsert).not.toHaveBeenCalled();191+ });192+193+ it("T-A31: does not apply a license if vault creation fails", async () => {194+ const [receiverChannel, sourceChannel] = memoryPair();195+ const rootKeyHex = "44".repeat(32);196+ const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));197+ const applyLicense = vi.fn();198+ const receiver = requestAdoption(receiverChannel, {199+ deviceName: "Receiver",200+ generateDeviceId: () => "receiver-ephemeral",201+ createVaultFromRootKey: vi.fn(async () => {202+ throw new Error("commit_failed");203+ }),204+ deriveLocalIdentity: vi.fn(),205+ upsertPairedDevice: vi.fn(),206+ applyLicense,207+ onError: vi.fn(),208+ });209+ await sourceChannel.receive();210+ await sendFrame(sourceChannel, {211+ type: "ADOPT_GRANT",212+ root_key_hex: rootKeyHex,213+ vault_id: vaultId,214+ license: "PPK-test",215+ source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },216+ });217+218+ await expect(receiver).rejects.toThrow("commit_failed");219+ expect(applyLicense).not.toHaveBeenCalled();220+ });221+222+ it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {223+ const [receiverChannel, sourceChannel] = memoryPair();224+ const createVaultFromRootKey = vi.fn();225+ const receiver = requestAdoption(receiverChannel, {226+ deviceName: "Receiver",227+ generateDeviceId: () => "receiver-ephemeral",228+ createVaultFromRootKey,229+ deriveLocalIdentity: vi.fn(),230+ upsertPairedDevice: vi.fn(),231+ applyLicense: vi.fn(),232+ onError: vi.fn(),233+ });234+ await sourceChannel.receive();235+ await sendFrame(sourceChannel, {236+ type: "ADOPT_GRANT",237+ root_key_hex: "55".repeat(32),238+ vault_id: "99".repeat(32),239+ license: "PPK-test",240+ source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },241+ });242+243+ await receiver;244+ expect(createVaultFromRootKey).not.toHaveBeenCalled();245+ });246+});src/shared/chirp-adopt.ts+210 −0
@@ -0,0 +1,210 @@1+import { computeVaultId } from "./sync-identity";2+import type { DeviceType, PairedDevice } from "./sync-types";3+import type { LocalIdentity, PairingTransport } from "./chirp-handshake";4+5+export interface AdoptRequest {6+ type: "ADOPT_REQUEST";7+ device_id: string;8+ device_name: string;9+ device_type: DeviceType;10+}11+12+export interface AdoptGrant {13+ type: "ADOPT_GRANT";14+ root_key_hex: string;15+ vault_id: string;16+ license?: string;17+ source: {18+ device_id: string;19+ device_name: string;20+ device_type: DeviceType;21+ long_term_public_key_hex: string;22+ signing_public_key_hex?: string;23+ ws_port?: number;24+ };25+}26+27+export interface AdoptIdentity {28+ type: "ADOPT_IDENTITY";29+ device_id: string;30+ device_name: string;31+ device_type: DeviceType;32+ long_term_public_key_hex: string;33+ signing_public_key_hex?: string;34+}35+36+export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;37+38+export interface AdoptionReceiverDeps {39+ deviceName: string;40+ generateDeviceId(): string;41+ createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;42+ deriveLocalIdentity(): Promise<LocalIdentity>;43+ upsertPairedDevice(device: PairedDevice): Promise<void>;44+ applyLicense?(license: string): Promise<void>;45+ onError(error: string): void;46+}47+48+export interface AdoptionSourceDeps {49+ sourceIdentity: LocalIdentity;50+ rootKeyHex(): Promise<string>;51+ vaultId(): Promise<string>;52+ license?(): Promise<string | undefined>;53+ onConsent(request: AdoptRequest): Promise<boolean>;54+ upsertPairedDevice(device: PairedDevice): Promise<void>;55+}56+57+export function isAdoptRequest(frame: unknown): frame is AdoptRequest {58+ const value = frame as Partial<AdoptRequest> | null;59+ return value?.type === "ADOPT_REQUEST" &&60+ typeof value.device_id === "string" &&61+ value.device_id.length > 0 &&62+ typeof value.device_name === "string" &&63+ isDeviceType(value.device_type);64+}65+66+export function isAdoptGrant(frame: unknown): frame is AdoptGrant {67+ const value = frame as Partial<AdoptGrant> | null;68+ return value?.type === "ADOPT_GRANT" &&69+ validateRootKeyHex(value.root_key_hex) &&70+ typeof value.vault_id === "string" &&71+ /^[0-9a-f]{64}$/.test(value.vault_id) &&72+ typeof value.source === "object" &&73+ value.source !== null &&74+ typeof value.source.device_id === "string" &&75+ typeof value.source.device_name === "string" &&76+ isDeviceType(value.source.device_type) &&77+ typeof value.source.long_term_public_key_hex === "string" &&78+ /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);79+}80+81+export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {82+ const value = frame as Partial<AdoptIdentity> | null;83+ return value?.type === "ADOPT_IDENTITY" &&84+ typeof value.device_id === "string" &&85+ value.device_id.length > 0 &&86+ typeof value.device_name === "string" &&87+ isDeviceType(value.device_type) &&88+ typeof value.long_term_public_key_hex === "string" &&89+ /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);90+}91+92+export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {93+ return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);94+}95+96+export async function requestAdoption(97+ channel: PairingTransport,98+ deps: AdoptionReceiverDeps,99+): Promise<void> {100+ await sendFrame(channel, {101+ type: "ADOPT_REQUEST",102+ device_id: deps.generateDeviceId(),103+ device_name: deps.deviceName,104+ device_type: "extension",105+ });106+107+ const frame = await receiveFrame(channel);108+ if (!isAdoptGrant(frame)) {109+ deps.onError("ADOPT_UNEXPECTED_FRAME");110+ return;111+ }112+113+ const rootKeyBytes = hexToBytes(frame.root_key_hex);114+ try {115+ const computedVaultId = await computeVaultId(rootKeyBytes);116+ if (computedVaultId !== frame.vault_id) {117+ deps.onError("ADOPT_VAULT_ID_MISMATCH");118+ return;119+ }120+ } finally {121+ rootKeyBytes.fill(0);122+ }123+124+ const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);125+ if (vaultId !== frame.vault_id) {126+ deps.onError("ADOPT_VAULT_ID_MISMATCH");127+ return;128+ }129+130+ if (frame.license) await deps.applyLicense?.(frame.license);131+132+ const identity = await deps.deriveLocalIdentity();133+ await sendFrame(channel, {134+ type: "ADOPT_IDENTITY",135+ device_id: identity.deviceId,136+ device_name: deps.deviceName,137+ device_type: identity.deviceType,138+ long_term_public_key_hex: identity.longTermPublicKeyHex,139+ signing_public_key_hex: identity.signingPublicKeyHex,140+ });141+142+ await deps.upsertPairedDevice({143+ deviceId: frame.source.device_id,144+ deviceName: frame.source.device_name,145+ deviceType: frame.source.device_type,146+ longTermPublicKeyHex: frame.source.long_term_public_key_hex,147+ signingPublicKeyHex: frame.source.signing_public_key_hex,148+ trustSource: "direct",149+ wsPort: frame.source.ws_port,150+ pairedAt: Date.now(),151+ });152+}153+154+export async function grantAdoption(155+ channel: PairingTransport,156+ request: AdoptRequest,157+ deps: AdoptionSourceDeps,158+): Promise<void> {159+ const approved = await deps.onConsent(request);160+ if (!approved) return;161+162+ const rootKeyHex = await deps.rootKeyHex();163+ if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");164+165+ await sendFrame(channel, {166+ type: "ADOPT_GRANT",167+ root_key_hex: rootKeyHex,168+ vault_id: await deps.vaultId(),169+ license: await deps.license?.(),170+ source: {171+ device_id: deps.sourceIdentity.deviceId,172+ device_name: deps.sourceIdentity.deviceName,173+ device_type: deps.sourceIdentity.deviceType,174+ long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,175+ signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,176+ ws_port: deps.sourceIdentity.wsPort,177+ },178+ });179+180+ const identity = await receiveFrame(channel);181+ if (!isAdoptIdentity(identity)) return;182+183+ await deps.upsertPairedDevice({184+ deviceId: identity.device_id,185+ deviceName: identity.device_name,186+ deviceType: identity.device_type,187+ longTermPublicKeyHex: identity.long_term_public_key_hex,188+ signingPublicKeyHex: identity.signing_public_key_hex,189+ trustSource: "direct",190+ pairedAt: Date.now(),191+ });192+}193+194+export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {195+ await channel.send(new TextEncoder().encode(JSON.stringify(frame)));196+}197+198+export async function receiveFrame(channel: PairingTransport): Promise<unknown> {199+ return JSON.parse(new TextDecoder().decode(await channel.receive()));200+}201+202+function isDeviceType(value: unknown): value is DeviceType {203+ return value === "extension" || value === "android" || value === "ios" || value === "desktop";204+}205+206+function hexToBytes(hex: string): Uint8Array {207+ const out = new Uint8Array(hex.length / 2);208+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);209+ return out;210+}