cad47b22243c

vault read-migrate type=passkey + flat fields to type=login + passkey sub-object, persist on next save, update sanitize/dupe key/messages handlers

time
2026-04-28 22:05 UTC · 6 months ago
machine
koh2
parent
85dda211aed3
3 files src/background/messages.test.ts+19 −16src/background/messages.ts+9 −4src/background/vault.ts+133 −70
src/background/messages.test.ts+19 −16
@@ -195,7 +195,7 @@195195           url: "https://github.com",196196         },197197       })198-    ).resolves.toMatchObject({ ok: false, error: "password is required" });198+    ).resolves.toMatchObject({ ok: false, error: "password or passkey required" });199199   });200200 201201   it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {@@ -308,26 +308,29 @@308308     ).resolves.toMatchObject({ ok: false, error: "vault locked" });309309   });310310 311-  it("GET_ENTRY_FOR_FILL rejects passkey entries", async () => {311+  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {312312     const vault = await import("./vault");313313     await vault.createVault("correct-pass-12345");314314     const generated = await generatePasskeyKeypair(-7);315315     const entry = await vault.addEntry({316-      type: "passkey",316+      type: "login",317317       title: "WebAuthn.io",318-      rpId: "webauthn.io",319-      rpName: "WebAuthn.io",320-      credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),321-      publicKeyCose: base64urlEncode(generated.publicKeyCose),322-      privateKeyJwk: generated.privateKeyJwk,323-      userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),324-      userName: "june",325-      userDisplayName: "June",326-      algorithm: -7,327-      signCount: 0,328-      aaguid: "AAAAAAAAAAAAAAAAAAAAAA",329-      backupEligible: true,330-      backupState: false,318+      passkey: {319+        rpId: "webauthn.io",320+        rpName: "WebAuthn.io",321+        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),322+        publicKeyCose: base64urlEncode(generated.publicKeyCose),323+        privateKeyJwk: generated.privateKeyJwk,324+        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),325+        userName: "june",326+        userDisplayName: "June",327+        algorithm: -7,328+        signCount: 0,329+        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",330+        backupEligible: true,331+        backupState: false,332+        createdAt: Date.now(),333+      },331334     });332335 333336     await expect(

view file

src/background/messages.ts+9 −4
@@ -39,6 +39,7 @@3939   MessageType,4040   ProFeature,4141 } from "../shared/types";42+import { isPasskeyOnly } from "../shared/types";4243 import type { SyncStatus } from "../shared/sync-types";4344 4445 /**@@ -292,10 +293,14 @@292293         const { id } = msg.payload as { id: string };293294         const entry = vault.getEntryById(id);294295         if (!entry) return { ok: false, error: "entry not found" };295-        if (entry.type === "passkey") return { ok: false, error: "passkey not fillable" };296-        // Content scripts never need the passkey private key; strip it297-        // defensively even though login entries do not carry one.298-        const { privateKeyJwk: _stripped, ...safeEntry } = entry;296+        // Passkey-only entries (passkey credential, no password) cannot be297+        // filled into a form — sign-in goes through the WebAuthn path. A298+        // login that ALSO has a passkey is still fillable for its password.299+        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };300+        // Strip the passkey credential from the response — content scripts301+        // never need the private key, and there's no reason to expose the302+        // public credential metadata over the boundary either.303+        const { passkey: _strippedPasskey, ...safeEntry } = entry;299304         return { ok: true, data: safeEntry };300305       }301306       case "COPY": {

view file

src/background/vault.ts+133 −70
@@ -327,6 +327,14 @@327327 type DecryptedPayload = {328328   entries: VaultEntry[];329329   duressConfigured: boolean;330+  /**331+   * True when at least one entry in the on-disk payload had a legacy shape332+   * (pre-v2.11.0 passkey type/flat fields, or pre-A-2 created/updated keys)333+   * and got rewritten by normalizeEntry. The unlock path uses this to334+   * persist the new shape immediately — without it, the migration would335+   * re-run on every unlock and old shape never disappears from disk.336+   */337+  migratedFromLegacy: boolean;330338 };331339 332340 function encodePayload(entries: VaultEntry[], duress: boolean): string {@@ -334,22 +342,35 @@334342   return padPlaintext(JSON.stringify(envelope));335343 }336344 345+function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {346+  if ("created" in raw || "updated" in raw) return true;347+  const flat = raw as Partial<VaultEntry>;348+  if (flat.type === "passkey") return true;349+  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.350+  if (flat.rpId !== undefined && flat.passkey === undefined) return true;351+  return false;352+}353+337354 function decodePayload(plain: string): DecryptedPayload {338355   const parsed = JSON.parse(plain) as339356     | Array<VaultEntry | LegacyVaultEntry>340357     | VaultPayload;341358 359+  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];360+  const migratedFromLegacy = rawEntries.some(rawNeedsMigration);361+342362   if (Array.isArray(parsed)) {343-    // Pre-A-2 plaintext: bare array of entries, no duress flag.344363     return {345364       entries: parsed.map(normalizeEntry),346365       duressConfigured: false,366+      migratedFromLegacy: true, // pre-A-2 array shape always needs save347367     };348368   }349369 350370   return {351-    entries: (parsed.entries ?? []).map(normalizeEntry),371+    entries: rawEntries.map(normalizeEntry),352372     duressConfigured: parsed.duressConfigured === true,373+    migratedFromLegacy,353374   };354375 }355376 @@ -549,9 +570,10 @@549570     await loadSettings();550571     touchAutoLock();551572     if (keyB) zeroKey(keyB);552-    // If the payload came from the pre-envelope (legacy) shape, rewrite553-    // it immediately so the next save uses the new format.554-    if (hasLegacyShape(payloadA.entries)) {573+    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat574+    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so575+    // the next save uses the new format and migration doesn't re-run forever.576+    if (payloadA.migratedFromLegacy) {555577       await persistSlotA();556578     }557579     return true;@@ -732,32 +754,40 @@732754   if (!entry || typeof entry !== "object") return null;733755   const title = typeof entry.title === "string" ? entry.title.trim() : "";734756   if (!title) return null;735-  const type = entry.type ?? "login";736757   const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();737758   const now = Date.now();738759   const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;739760   const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;740761 741-  if (type === "login" && typeof entry.password !== "string") {742-    return null;743-  }762+  // Migrate legacy passkey shape on import (same logic as on-disk read).763+  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with764+  // flat fields; bring them into the new shape immediately.765+  const migrated: VaultEntry = migrateLegacyPasskey(766+    {767+      ...entry,768+      id,769+      title,770+      type: entry.type ?? "login",771+      createdAt,772+      modifiedAt,773+    },774+    entry775+  );776+777+  // Login entries from backup must carry a password (or be passkey-only,778+  // in which case the passkey sub-object replaces the password requirement).744779   if (745-    type === "passkey" &&746-    (typeof entry.rpId !== "string" ||747-      typeof entry.credentialId !== "string" ||748-      !entry.privateKeyJwk)780+    migrated.type === "login" &&781+    !migrated.passkey &&782+    typeof migrated.password !== "string"749783   ) {750784     return null;751785   }786+  if (migrated.passkey && !migrated.passkey.credentialId) {787+    return null;788+  }752789 753-  return {754-    ...entry,755-    id,756-    title,757-    type,758-    createdAt,759-    modifiedAt,760-  };790+  return migrated;761791 }762792 763793 // Soft-deletes every non-deleted entry in one call. The literal string@@ -998,25 +1028,29 @@9981028 9991029 function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {10001030   const title = entry.title.trim();1001-  const type = entry.type ?? "login";1031+  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new1032+  // model is "any entry, optionally with a passkey sub-object." Imports and1033+  // any in-flight callers from before v2.11.0 keep working without throwing.1034+  let type: VaultEntry["type"] = entry.type ?? "login";1035+  if (type === "passkey") type = "login";10021036 10031037   if (!title) throw new Error("title is required");1004-  if (type === "login" && !entry.password?.trim()) {1005-    throw new Error("password is required");1038+  // A login entry needs either a password or a passkey credential to be useful.1039+  // Passkey-only entries (passkey present, no password) are valid.1040+  if (type === "login" && !entry.password?.trim() && !entry.passkey) {1041+    throw new Error("password or passkey required");10061042   }1007-  if (type === "passkey") {1043+  if (entry.passkey) {1044+    const p = entry.passkey;10081045     if (1009-      !entry.rpId?.trim() ||1010-      !entry.rpName?.trim() ||1011-      !entry.credentialId?.trim() ||1012-      !entry.publicKeyCose?.trim() ||1013-      !entry.privateKeyJwk ||1014-      !entry.userHandle?.trim() ||1015-      !entry.userName?.trim() ||1016-      !entry.userDisplayName?.trim() ||1017-      (entry.algorithm !== -7 && entry.algorithm !== -8)1046+      !p.rpId ||1047+      !p.credentialId ||1048+      !p.publicKeyCose ||1049+      !p.userHandle ||1050+      !p.userName ||1051+      (p.algorithm !== -7 && p.algorithm !== -8)10181052     ) {1019-      throw new Error("invalid passkey entry");1053+      throw new Error("invalid passkey credential");10201054     }10211055   }10221056   if (type === "card") {@@ -1033,24 +1067,11 @@10331067     url: type === "login" ? entry.url?.trim() ?? "" : undefined,10341068     additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,10351069     totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1036-    notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1070+    notes: entry.notes?.trim() || undefined,10371071     breach: type === "login" ? entry.breach : undefined,10381072     canary: type === "login" ? entry.canary : undefined,10391073     tags: entry.tags,1040-    rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1041-    rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1042-    credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1043-    publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1044-    privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1045-    userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1046-    userName: type === "passkey" ? entry.userName?.trim() : undefined,1047-    userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1048-    algorithm: type === "passkey" ? entry.algorithm : undefined,1049-    signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1050-    aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1051-    backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1052-    backupState: type === "passkey" ? entry.backupState === true : undefined,1053-    lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1074+    passkey: entry.passkey,10541075     cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,10551076     cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,10561077     expMonth: type === "card" ? entry.expMonth?.trim() : undefined,@@ -1214,7 +1235,7 @@12141235   const modifiedAt =12151236     "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12161237 1217-  return {1238+  const base: VaultEntry = {12181239     id: entry.id,12191240     type: "type" in entry ? entry.type : "login",12201241     title: entry.title,@@ -1225,20 +1246,7 @@12251246     notes: entry.notes,12261247     breach: entry.breach,12271248     canary: "canary" in entry ? entry.canary : undefined,1228-    rpId: "rpId" in entry ? entry.rpId : undefined,1229-    rpName: "rpName" in entry ? entry.rpName : undefined,1230-    credentialId: "credentialId" in entry ? entry.credentialId : undefined,1231-    publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1232-    privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1233-    userHandle: "userHandle" in entry ? entry.userHandle : undefined,1234-    userName: "userName" in entry ? entry.userName : undefined,1235-    userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1236-    algorithm: "algorithm" in entry ? entry.algorithm : undefined,1237-    signCount: "signCount" in entry ? entry.signCount : undefined,1238-    aaguid: "aaguid" in entry ? entry.aaguid : undefined,1239-    backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1240-    backupState: "backupState" in entry ? entry.backupState : undefined,1241-    lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1249+    passkey: "passkey" in entry ? entry.passkey : undefined,12421250     createdAt,12431251     modifiedAt,12441252     deletedAt: entry.deletedAt,@@ -1252,21 +1260,76 @@12521260     network: "network" in entry ? entry.network : undefined,12531261     last4: "last4" in entry ? entry.last4 : undefined,12541262   };1263+1264+  return migrateLegacyPasskey(base, entry);12551265 }12561266 1257-function hasLegacyShape(parsed: VaultEntry[]): boolean {1258-  return parsed.some(1259-    (entry) => "created" in entry || "updated" in entry1260-  );1267+/**1268+ * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`1269+ * with flat top-level fields (rpId, credentialId, etc.). This converts them1270+ * to the new shape — `type:"login"` with a `passkey` sub-object — so all1271+ * downstream consumers can rely on the new model. The result is rewritten1272+ * to disk on next vault save (hasLegacyShape detects the un-migrated case).1273+ */1274+function migrateLegacyPasskey(1275+  base: VaultEntry,1276+  raw: VaultEntry | LegacyVaultEntry1277+): VaultEntry {1278+  if (base.passkey) return base;1279+  const flat = raw as Partial<VaultEntry>;1280+  const isPasskeyType = base.type === "passkey";1281+  const hasFlatPasskeyFields =1282+    typeof flat.rpId === "string" &&1283+    typeof flat.credentialId === "string" &&1284+    typeof flat.publicKeyCose === "string" &&1285+    typeof flat.userHandle === "string" &&1286+    typeof flat.userName === "string";1287+  if (!isPasskeyType && !hasFlatPasskeyFields) return base;1288+  if (!hasFlatPasskeyFields) return base;1289+1290+  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1291+  base.passkey = {1292+    rpId: flat.rpId!,1293+    rpName: flat.rpName ?? flat.rpId!,1294+    credentialId: flat.credentialId!,1295+    publicKeyCose: flat.publicKeyCose!,1296+    privateKeyJwk: flat.privateKeyJwk,1297+    userHandle: flat.userHandle!,1298+    userName: flat.userName!,1299+    userDisplayName: flat.userDisplayName ?? flat.userName!,1300+    algorithm,1301+    signCount: flat.signCount ?? 0,1302+    aaguid: flat.aaguid ?? "",1303+    backupEligible: flat.backupEligible === true,1304+    backupState: flat.backupState === true,1305+    lastUsedAt: flat.lastUsedAt,1306+    browser: flat.browser,1307+    transports: flat.transports,1308+    createdAt: base.createdAt,1309+  };1310+  // Promote to "login" so the unified Entry view renders. Username gets1311+  // populated from the passkey's userName so the entry is searchable in the1312+  // existing autofill flow without a separate code path.1313+  if (isPasskeyType) base.type = "login";1314+  if (!base.username) base.username = flat.userName;1315+  if (!base.url) base.url = `https://${flat.rpId}`;1316+  return base;12611317 }12621318 1319+12631320 // ---------------------------------------------------------------------------12641321 // Deduplication12651322 // ---------------------------------------------------------------------------12661323 12671324 function entryDupeKey(e: VaultEntry): string {12681325   if (e.type === "note") return `note|${e.title.toLowerCase()}`;1269-  if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1326+  // Passkey-bearing entries dedupe by the credential's RP + user. Two logins1327+  // on different sites that happen to share a username are still distinct;1328+  // two passkey credentials for the same RP+user are duplicates regardless1329+  // of any login fields they may also carry.1330+  if (e.passkey) {1331+    return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1332+  }12701333   return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;12711334 }12721335 

view file