fb40727c5334

add popup TOTP parameter and legacy trustSource provenance regression tests (PB-26-114, PB-26-116, v5.0.51)

time
2026-09-02 06:55 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
ac6674472a01
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-vault-adapter.test.ts+66 −0src/popup/views/Entry.totp-params.test.tsx+131 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.50",4+  "version": "5.0.51",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.50",5+  "version": "5.0.51",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.50",3+  "version": "5.0.51",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-vault-adapter.test.ts+66 −0
@@ -705,6 +705,72 @@705705     expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");706706   });707707 708+  // PB-26-116: rows written before `trustSource` existed carry no provenance.709+  // The manifest-apply path used to default them to "direct", which is the710+  // opposite of the fail-closed rule PB-26-006 established for the mesh routing711+  // gate — a legacy row became a dial target, manifest issuer, and revocation-712+  // repair participant on the strength of a signed manifest alone.713+  it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {714+    const { adapter } = await setupVault();715+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");716+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");717+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");718+    const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");719+720+    const issuerId = "android-legacy-issuer";721+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);722+    await upsertPairedDevice({723+      deviceId: issuerId,724+      deviceName: "Issuer Pixel",725+      deviceType: "android",726+      longTermPublicKeyHex: "1".repeat(64),727+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),728+      pairedAt: 1_700_000_000_000,729+      trustSource: "direct",730+    });731+732+    // A row predating the field: present in the registry, no recorded733+    // provenance. Seeded through storage so no writer stamps a default.734+    const legacyId = "legacy-peer";735+    const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;736+    registry.push({737+      deviceId: legacyId,738+      deviceName: "Legacy Peer",739+      deviceType: "android",740+      longTermPublicKeyHex: "3".repeat(64),741+      signingPublicKeyHex: await signingPubkeyHex(legacyId),742+      rendezvousSecretHex: "9".repeat(64),743+      relayUrl: MESH_RELAY,744+      pairedAt: 1_700_000_000_000,745+    });746+747+    const manifest = await buildSignedDeviceManifest(748+      issuerId,749+      [{750+        deviceId: legacyId,751+        deviceName: "Legacy Peer",752+        deviceType: "android",753+        longTermPublicKeyHex: "3".repeat(64),754+        signingPublicKeyHex: await signingPubkeyHex(legacyId),755+        relayUrl: MESH_RELAY,756+        pairedAt: 1_700_000_000_000,757+      }],758+      issuerKp.privateKey,759+    );760+    await adapter.applyIncomingDeviceManifest?.(761+      manifest,762+      new Uint8Array(32).fill(0x11),763+    );764+765+    const row = await findPairedDeviceById(legacyId);766+    expect(row).not.toBeNull();767+    // Not silently promoted: unknown provenance stays unknown.768+    expect(row?.trustSource).toBeUndefined();769+    expect(row?.trustSource).not.toBe("direct");770+    // And therefore not a routing authority.771+    expect(isRouteCapableMeshPeer(row!)).toBe(false);772+  });773+708774   it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {709775     // The browser can never listen, so it joins the mesh THROUGH a native peer;710776     // that peer is the one holding the peer list. A phone-issued manifest must

view file

src/popup/views/Entry.totp-params.test.tsx+131 −0
@@ -0,0 +1,131 @@1+// @vitest-environment jsdom2+//3+// PB-26-114: the popup generated TOTP codes from the RFC defaults (SHA-1 / 64+// digits / 30 s) regardless of what the entry actually stored. The background5+// fill path already honoured the entry's parameters under an explicit comment,6+// and the import layer deliberately preserves non-default parameters from7+// otpauth:// URIs — so an entry imported from Bitwarden or Proton with8+// algorithm=SHA256, digits=8, or a 60 s period displayed and copied the WRONG9+// code from the popup while autofill generated the right one.10+//11+// Both popup call sites (the entry view's live code, and the copy_totp keyboard12+// action) must derive their code from the same stored parameters.13+14+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";15+16+const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));17+vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));18+19+import { generateTOTP } from "../../crypto/totp";20+import type { VaultEntry } from "../../shared/types";21+import { EntryView } from "./Entry";22+import { flushView, renderView } from "./view-test-utils";23+24+let mounted: Array<{ dispose: () => void }> = [];25+26+/** The view groups the digits for readability ("824 492"), so compare squashed. */27+function squashed(view: { container: HTMLElement }): string {28+  return (view.container.textContent ?? "").replace(/\s+/g, "");29+}30+31+// A seed that is stable across the default and non-default parameter sets, so32+// the two calls actually differ rather than coincidentally agreeing.33+const SEED = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";34+35+function totpEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {36+  return {37+    id: "entry-1",38+    type: "login",39+    title: "Vault",40+    username: "june",41+    password: "pw",42+    url: "https://example.com",43+    totp: SEED,44+    totpAlgorithm: "SHA256",45+    totpDigits: 8,46+    totpPeriod: 60,47+    createdAt: 1,48+    modifiedAt: 1,49+    ...overrides,50+  } as VaultEntry;51+}52+53+beforeEach(() => {54+  mounted = [];55+  mockSendMessage.mockReset();56+  mockSendMessage.mockImplementation(() => Promise.resolve({ ok: true, data: null }));57+  (globalThis as unknown as { chrome: unknown }).chrome = {58+    runtime: {59+      onMessage: { addListener: () => undefined, removeListener: () => undefined },60+      getURL: (path: string) => `chrome-extension://test/${path}`,61+    },62+    storage: {63+      local: { get: () => Promise.resolve({}), set: () => Promise.resolve() },64+      session: { get: () => Promise.resolve({}), set: () => Promise.resolve() },65+    },66+  };67+});68+69+afterEach(() => {70+  for (const view of mounted) view.dispose();71+  mounted = [];72+  vi.restoreAllMocks();73+});74+75+describe("PB-26-114: popup TOTP honours the entry's stored parameters", () => {76+  it("the entry view shows the code for SHA-256 / 8 digits / 60 s", async () => {77+    const view = renderView(() => (78+      <EntryView79+        entry={totpEntry()}80+        currentTabUrl=""81+        onDelete={() => undefined}82+        onFieldSaved={() => undefined}83+      />84+    ));85+    mounted.push(view);86+    await flushView();87+88+    const expected = generateTOTP(SEED, "SHA256", 8, 60);89+    const withDefaults = generateTOTP(SEED);90+    // Guard the premise: the two calls must not agree, or the test proves91+    // nothing about which one the UI used.92+    expect(expected.code).not.toBe(withDefaults.code);93+    expect(expected.code).toHaveLength(8);94+95+    expect(squashed(view)).toContain(expected.code);96+    expect(squashed(view)).not.toContain(withDefaults.code);97+  });98+99+  it("an entry with RFC-default parameters still shows the default code", async () => {100+    const view = renderView(() => (101+      <EntryView102+        entry={totpEntry({ totpAlgorithm: undefined, totpDigits: undefined, totpPeriod: undefined })}103+        currentTabUrl=""104+        onDelete={() => undefined}105+        onFieldSaved={() => undefined}106+      />107+    ));108+    mounted.push(view);109+    await flushView();110+111+    const expected = generateTOTP(SEED);112+    expect(squashed(view)).toContain(expected.code);113+  });114+115+  it("a 6-digit SHA-512 entry is generated with its own parameters", async () => {116+    const view = renderView(() => (117+      <EntryView118+        entry={totpEntry({ totpAlgorithm: "SHA512", totpDigits: 6, totpPeriod: 30 })}119+        currentTabUrl=""120+        onDelete={() => undefined}121+        onFieldSaved={() => undefined}122+      />123+    ));124+    mounted.push(view);125+    await flushView();126+127+    const expected = generateTOTP(SEED, "SHA512", 6, 30);128+    expect(expected.code).not.toBe(generateTOTP(SEED).code);129+    expect(squashed(view)).toContain(expected.code);130+  });131+});

view file