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
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/chirp-adopt.test.ts+246 −0src/shared/chirp-adopt.ts+210 −0
manifest.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": {

view file

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",

view file

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",

view file

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+});

view file

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+}

view file