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
↓ peach-browser-fb40727c.tar.gzatlas at this save
files changed5 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 −0manifest.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": {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",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",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 mustsrc/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+});