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
7 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 −0
src/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 {

view file

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 the

view file

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

view file

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 {

view file

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 () => {

view file

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(

view file

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;

view file