da4b30abc2c9
fix: PCH-01-019 thread otpauth algorithm/digits/period through TOTP generation + storage — new optional totpAlgorithm/Digits/Period on entry (backward-compatible bare-seed default SHA1/6/30), import + sanitizeEntryDraft whitelist + GET_ENTRY_FOR_FILL (v3.8.1)
- time
- 2026-06-10 20:15 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- b2e695e079b3
↓ peach-browser-da4b30ab.tar.gzatlas at this save
files changed7 files
src/background/messages.ts+12 −2src/background/vault.ts+9 −0src/crypto/totp.test.ts+57 −0src/crypto/totp.ts+21 −5src/shared/import.test.ts+21 −0src/shared/import.ts+19 −7src/shared/types.ts+12 −0src/background/messages.ts+12 −2
@@ -734,12 +734,22 @@734734 // Handing every past password to a content script during passive735735 // autofill on an arbitrary page would widen the blast radius of a736736 // compromised page far beyond the one credential being filled.737- const { passkey: _strippedPasskey, totp: seed, passwordHistory: _ph, ...safeEntry } = entry;737+ const {738+ passkey: _strippedPasskey,739+ totp: seed,740+ totpAlgorithm,741+ totpDigits,742+ totpPeriod,743+ passwordHistory: _ph,744+ ...safeEntry745+ } = entry;738746 let totpCode: string | undefined;739747 let totpRemaining: number | undefined;740748 if (seed) {741749 try {742- const generated = generateTOTP(seed);750+ // PCH-01-019: honour stored algorithm/digits/period (defaults apply751+ // when these are undefined, i.e. legacy bare-seed entries).752+ const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);743753 totpCode = generated.code;744754 totpRemaining = generated.remaining;745755 } catch {src/background/vault.ts+9 −0
@@ -2283,6 +2283,11 @@22832283 url: type === "login" ? entry.url?.trim() ?? "" : undefined,22842284 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,22852285 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2286+ // PCH-01-019: preserve non-default TOTP params through batch-add/import so2287+ // they are not silently stripped (they fall back to SHA-1/6/30 when absent).2288+ totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2289+ totpDigits: type === "login" ? entry.totpDigits : undefined,2290+ totpPeriod: type === "login" ? entry.totpPeriod : undefined,22862291 notes: entry.notes?.trim() || undefined,22872292 breach: type === "login" ? entry.breach : undefined,22882293 // PCH-01-016: the batch-add path was bypassing the MV-6 canary/tags@@ -2421,6 +2426,10 @@24212426 ? entry.additionalUrls24222427 : undefined,24232428 totp: entry.totp,2429+ // PCH-01-019: carry TOTP params through the normalize/restore path too.2430+ totpAlgorithm: entry.totpAlgorithm,2431+ totpDigits: entry.totpDigits,2432+ totpPeriod: entry.totpPeriod,24242433 notes: entry.notes,24252434 breach: entry.breach,24262435 // Preserve the password-age clock across lock/unlock. Without this thesrc/crypto/totp.test.ts+57 −0
@@ -0,0 +1,57 @@1+import { describe, expect, it } from "vitest";2+import { TOTP } from "otpauth";3+4+import { generateTOTP, parseOtpauthURI } from "./totp";5+6+// PCH-01-019: algorithm/digits/period must be threaded through, with RFC7+// defaults (SHA-1 / 6 / 30) when absent so legacy bare-seed entries are stable.8+9+const SECRET = "JBSWY3DPEHPK3PXP";10+11+describe("generateTOTP (PCH-01-019)", () => {12+ it("honours SHA256 / 8-digit / 60s params", () => {13+ const { code, remaining } = generateTOTP(SECRET, "SHA256", 8, 60);14+ const expected = new TOTP({ secret: SECRET, algorithm: "SHA256", digits: 8, period: 60 }).generate();15+ expect(code).toBe(expected);16+ expect(code.length).toBe(8);17+ expect(remaining).toBeGreaterThan(0);18+ expect(remaining).toBeLessThanOrEqual(60);19+ });20+21+ it("legacy bare seed (no params) generates the SHA-1 / 6 / 30 default", () => {22+ const { code, remaining } = generateTOTP(SECRET);23+ const expected = new TOTP({ secret: SECRET, algorithm: "SHA1", digits: 6, period: 30 }).generate();24+ expect(code).toBe(expected);25+ expect(code.length).toBe(6);26+ expect(remaining).toBeLessThanOrEqual(30);27+ });28+29+ it("undefined optional args fall through to defaults", () => {30+ const a = generateTOTP(SECRET, undefined, undefined, undefined);31+ const b = generateTOTP(SECRET);32+ expect(a.code).toBe(b.code);33+ });34+});35+36+describe("parseOtpauthURI (PCH-01-019)", () => {37+ it("extracts algorithm, digits, and period", () => {38+ const parsed = parseOtpauthURI(39+ `otpauth://totp/Example:alice?secret=${SECRET}&algorithm=SHA256&digits=8&period=60&issuer=Example`,40+ );41+ expect(parsed).toMatchObject({42+ secret: SECRET,43+ algorithm: "SHA256",44+ digits: 8,45+ period: 60,46+ });47+ });48+49+ it("defaults a bare otpauth URI to SHA1 / 6 / 30", () => {50+ const parsed = parseOtpauthURI(`otpauth://totp/Example:alice?secret=${SECRET}&issuer=Example`);51+ expect(parsed).toMatchObject({ secret: SECRET, algorithm: "SHA1", digits: 6, period: 30 });52+ });53+54+ it("returns null for non-otpauth input", () => {55+ expect(parseOtpauthURI("not-a-uri")).toBeNull();56+ });57+});src/crypto/totp.ts+21 −5
@@ -2,21 +2,37 @@22 33 export type TOTPAlgorithm = "SHA1" | "SHA256" | "SHA512";44 5+export interface TOTPParams {6+ secret: string;7+ algorithm?: TOTPAlgorithm;8+ digits?: number;9+ period?: number;10+}11+512 export function generateTOTP(613 base32Secret: string,7- algorithm: TOTPAlgorithm = "SHA1"14+ algorithm: TOTPAlgorithm = "SHA1",15+ digits: number = 6,16+ period: number = 30817 ): { code: string; remaining: number } {9- const totp = new TOTP({ secret: base32Secret, digits: 6, period: 30, algorithm });18+ // PCH-01-019: honour the entry's stored algorithm/digits/period; absent19+ // params (undefined) fall through to the RFC defaults (SHA-1 / 6 / 30 s).20+ const totp = new TOTP({ secret: base32Secret, digits, period, algorithm });1021 const code = totp.generate();11- const remaining = 30 - (Math.floor(Date.now() / 1000) % 30);22+ const remaining = period - (Math.floor(Date.now() / 1000) % period);1223 return { code, remaining };1324 }1425 15-export function parseOtpauthURI(uri: string): { secret: string; algorithm?: TOTPAlgorithm } | null {26+export function parseOtpauthURI(uri: string): TOTPParams | null {1627 try {1728 const parsed = URI.parse(uri);1829 if (parsed instanceof TOTP) {19- return { secret: parsed.secret.base32, algorithm: parsed.algorithm as TOTPAlgorithm };30+ return {31+ secret: parsed.secret.base32,32+ algorithm: parsed.algorithm as TOTPAlgorithm,33+ digits: parsed.digits,34+ period: parsed.period,35+ };2036 }2137 return null;2238 } catch {src/shared/import.test.ts+21 −0
@@ -72,6 +72,27 @@7272 notes: "daily driver",7373 totp: "JBSWY3DPEHPK3PXP",7474 });75+ // PCH-01-019: a default-param URI carries no sibling fields (stays legacy).76+ expect(result.entries[0].totpAlgorithm).toBeUndefined();77+ expect(result.entries[0].totpDigits).toBeUndefined();78+ expect(result.entries[0].totpPeriod).toBeUndefined();79+ });80+81+ it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => {82+ const result = await parseImportSource(83+ [84+ "name,url,email,password,note,totp",85+ '"Acct","https://e.test","[email protected]","pw","","otpauth://totp/Acct?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Acct"',86+ ].join("\n")87+ );88+89+ expect(result.entries).toHaveLength(1);90+ expect(result.entries[0]).toMatchObject({91+ totp: "JBSWY3DPEHPK3PXP",92+ totpAlgorithm: "SHA256",93+ totpDigits: 8,94+ totpPeriod: 60,95+ });7596 });7697 7798 it("parses Proton Pass JSON exports", async () => {src/shared/import.ts+19 −7
@@ -255,7 +255,7 @@255255 "comment",256256 "extra",257257 ]);258- const totp = normalizeTotp(258+ const totpParams = normalizeTotp(259259 pickFirstField(row, [260260 "totp",261261 "totpUri",@@ -276,7 +276,7 @@276276 password,277277 url,278278 notes: notes || undefined,279- totp,279+ ...(totpParams ?? {}),280280 tags: grouping ? [grouping] : undefined,281281 });282282 return entries;@@ -304,7 +304,7 @@304304 username: firstNonEmptyString(item.login?.username, ""),305305 password,306306 notes: stringOrUndefined(item.notes),307- totp: normalizeTotp(stringOrUndefined(item.login?.totp)),307+ ...(normalizeTotp(stringOrUndefined(item.login?.totp)) ?? {}),308308 });309309 return entries;310310 }, []),@@ -364,9 +364,9 @@364364 ""365365 ),366366 notes: stringOrUndefined(metadata.note ?? metadata.notes),367- totp: normalizeTotp(367+ ...(normalizeTotp(368368 firstNonEmptyString(content.totpUri, content.itemTotpUri)369- ),369+ ) ?? {}),370370 });371371 }372372 }@@ -567,10 +567,22 @@567567 return "";568568 }569569 570-function normalizeTotp(value?: string): string | undefined {570+function normalizeTotp(571+ value?: string572+): { totp: string; totpAlgorithm?: "SHA1" | "SHA256" | "SHA512"; totpDigits?: number; totpPeriod?: number } | undefined {571573 if (!value) return undefined;572574 const parsed = value.startsWith("otpauth://") ? parseOtpauthURI(value) : null;573- return parsed?.secret ?? value.trim() ?? undefined;575+ const totp = parsed?.secret ?? value.trim();576+ if (!totp) return undefined;577+ if (!parsed) return { totp };578+ // PCH-01-019: persist only non-default params so legacy bare-seed entries579+ // stay byte-identical and keep generating the same codes.580+ return {581+ totp,582+ totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,583+ totpDigits: parsed.digits && parsed.digits !== 6 ? parsed.digits : undefined,584+ totpPeriod: parsed.period && parsed.period !== 30 ? parsed.period : undefined,585+ };574586 }575587 576588 function firstNonEmptyString(src/shared/types.ts+12 −0
@@ -88,6 +88,15 @@8888 */8989 additionalUrls?: string[];9090 totp?: string; // base32 seed, stored encrypted — only for login entries91+ /**92+ * Optional TOTP parameters parsed from an otpauth:// URI. Absent fields fall93+ * back to RFC defaults (SHA-1 / 6 digits / 30-second period), so legacy entries94+ * carrying only a bare `totp` seed keep generating identical codes95+ * (PCH-01-019). Login entries only.96+ */97+ totpAlgorithm?: "SHA1" | "SHA256" | "SHA512";98+ totpDigits?: number;99+ totpPeriod?: number;91100 notes?: string;92101 breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries93102 /**@@ -176,6 +185,9 @@176185 password: string;177186 url: string;178187 totp?: string;188+ totpAlgorithm?: "SHA1" | "SHA256" | "SHA512";189+ totpDigits?: number;190+ totpPeriod?: number;179191 notes?: string;180192 breach?: number;181193 created: number;