bc40f634fb77

resolve PB-26-930 through PB-26-934 at Browser 5.2.52

time
2026-09-12 03:24 UTC · 1 month ago
machine
koh2
agent
camel-stream/auto
session
resolve 2026-09-12 audit
parent
6aa6595bc1de
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/aes.test.ts+8 −0src/background/aes.ts+22 −16src/background/license.ts+24 −48src/background/license.verification.test.ts+18 −0src/background/locked-sync-spool.test.ts+4 −6src/background/locked-sync-spool.ts+20 −29src/background/sync-vault-adapter.ts+3 −6src/background/vault.ts+54 −28
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.51",4+  "version": "5.2.52",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.2.51",5+  "version": "5.2.52",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.2.51",3+  "version": "5.2.52",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/aes.test.ts+8 −0
@@ -60,4 +60,12 @@6060     expect(encrypted.tag).toHaveLength(32);6161     expect(encrypted.ciphertext.length / 2).toBe(new TextEncoder().encode(plaintext).length);6262   });63+  it("rejects malformed hex before decrypting", async () => {64+    const key = new Uint8Array(32);65+    await expect(decrypt(key, "abc", "00", "00".repeat(16))).rejects.toThrow(/nonce/i);66+    await expect(decrypt(key, "00".repeat(12), "0", "00".repeat(16))).rejects.toThrow(/ciphertext/i);67+    await expect(decrypt(key, "00".repeat(12), "gg", "00".repeat(16))).rejects.toThrow(/ciphertext/i);68+    await expect(decrypt(key, "00".repeat(12), "", "00".repeat(16))).rejects.toThrow(/ciphertext/i);69+    await expect(decrypt(key, "00".repeat(12), "00", "00".repeat(15))).rejects.toThrow(/tag/i);70+  });6371 });

view file

src/background/aes.ts+22 −16
@@ -24,11 +24,19 @@2424     .join("");2525 }2626 27-function unhex(value: string): Uint8Array {28-  const out = new Uint8Array(value.length / 2);29-  for (let i = 0; i < value.length; i += 2) {30-    out[i / 2] = parseInt(value.slice(i, i + 2), 16);27+const MAX_CIPHERTEXT_BYTES = 8 * 1024 * 1024;28+29+function unhex(value: string, field: string, options: { empty?: boolean; exactBytes?: number } = {}): Uint8Array {30+  if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) {31+    throw new Error(`invalid ${field}`);3132   }33+  if (options.exactBytes !== undefined && value.length !== options.exactBytes * 2) {34+    throw new Error(`invalid ${field}`);35+  }36+  if (!options.empty && value.length === 0) throw new Error(`invalid ${field}`);37+  if (value.length / 2 > MAX_CIPHERTEXT_BYTES) throw new Error(`${field} too large`);38+  const out = new Uint8Array(value.length / 2);39+  for (let i = 0; i < value.length; i += 2) out[i / 2] = Number.parseInt(value.slice(i, i + 2), 16);3240   return out;3341 }3442 @@ -99,28 +107,26 @@99107   tag: string,100108   aad: Uint8Array,101109 ): Promise<Uint8Array> {110+  const nonceBytes = unhex(nonce, "nonce", { exactBytes: 12 });111+  const cipherBytes = unhex(ciphertext, "ciphertext");112+  const tagBytes = unhex(tag, "tag", { exactBytes: 16 });113+  const combined = new Uint8Array(cipherBytes.length + tagBytes.length);114+  combined.set(cipherBytes);115+  combined.set(tagBytes, cipherBytes.length);116+102117   const key = await crypto.subtle.importKey(103118     "raw",104119     keyBytes.buffer as ArrayBuffer,105120     { name: "AES-GCM" },106121     false,107-    ["decrypt"]122+    ["decrypt"],108123   );109124 110-  const cipherBytes = unhex(ciphertext);111-  const tagBytes = unhex(tag);112-  const combined = new Uint8Array(cipherBytes.length + tagBytes.length);113-  combined.set(cipherBytes);114-  combined.set(tagBytes, cipherBytes.length);115-116125   const raw = await crypto.subtle.decrypt(117126     {118127       name: "AES-GCM",119-      iv: unhex(nonce).buffer as ArrayBuffer,120-      additionalData: aad.buffer.slice(121-        aad.byteOffset,122-        aad.byteOffset + aad.byteLength,123-      ) as ArrayBuffer,128+      iv: nonceBytes.buffer as ArrayBuffer,129+      additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,124130       tagLength: 128,125131     },126132     key,

view file

src/background/license.ts+24 −48
@@ -52,76 +52,52 @@5252   if (!current.includes(identifier)) current.push(identifier);5353   await chrome.storage.local.set({ [LICENSE_REVOCATIONS_KEY]: current });5454 }55+// Revocations are profile-local. No issuer update channel is implemented; a56+// user must activate a newly issued license on each installation.5557 56-async function importPublicKey(): Promise<CryptoKey> {57-  const keyData = hexToBytes(PUBLIC_KEY_HEX);58-  return (crypto.subtle as any).importKey(58+async function importPublicKey(publicKeyHex = PUBLIC_KEY_HEX): Promise<CryptoKey> {59+  const keyData = hexToBytes(publicKeyHex);60+  return crypto.subtle.importKey(5961     "raw",60-    keyData,62+    keyData.buffer as ArrayBuffer,6163     { name: "Ed25519" },6264     true,63-    ["verify"]65+    ["verify"],6466   );6567 }6668 6769 function hexToBytes(hex: string): Uint8Array {68-  const bytes = new Uint8Array(hex.length / 2);69-  for (let i = 0; i < hex.length; i += 2) {70-    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);71-  }70+  if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("invalid public key");71+  const bytes = new Uint8Array(32);72+  for (let i = 0; i < bytes.length; i += 1) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);7273   return bytes;7374 }7475 75-export async function validateLicense(keyString: string): Promise<LicenseInfo> {76-  if (!keyString || typeof keyString !== "string") {77-    return { valid: false, plan: "free" };78-  }79-76+export async function validateLicense(keyString: string, publicKeyHex = PUBLIC_KEY_HEX): Promise<LicenseInfo> {77+  if (!keyString || typeof keyString !== "string") return { valid: false, plan: "free" };8078   const trimmed = keyString.trim();81-  if (!trimmed.startsWith(LICENSE_PREFIX)) {82-    return { valid: false, plan: "free" };83-  }79+  if (!trimmed.startsWith(LICENSE_PREFIX)) return { valid: false, plan: "free" };8480 8581   try {86-    const withoutPrefix = trimmed.slice(LICENSE_PREFIX.length);87-    const groups = withoutPrefix.split("-");88-    if (groups.length !== 5) {89-      return { valid: false, plan: "free" };90-    }82+    const groups = trimmed.slice(LICENSE_PREFIX.length).split("-");83+    if (groups.length !== 5) return { valid: false, plan: "free" };9184     const decoded = base58Decode(fromBase58Groups(groups));92-    if (decoded.length !== 76) {93-      return { valid: false, plan: "free" };94-    }95-85+    if (decoded.length !== 76) return { valid: false, plan: "free" };9686     const payload = decoded.slice(0, LICENSE_PAYLOAD_LENGTH);97-    const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH, 76);98-87+    const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH);9988     if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {10089       return { valid: false, plan: "free" };10190     }102-10391     const issuedTimestamp = new DataView(payload.buffer, payload.byteOffset + 3, 4).getUint32(0, true);104-    const issuedDate = new Date(issuedTimestamp * 86400 * 1000);10592     const identifier = new DataView(payload.buffer, payload.byteOffset + 7, 4).getUint32(0, true);106-107-    const publicKey = await importPublicKey();108-    const isValid = await (crypto.subtle as any).verify(109-      "Ed25519",110-      publicKey,111-      signature,112-      payload113-    );114-115-    if (!isValid) {116-      return { valid: false, plan: "free" };117-    }118-93+    const publicKey = await importPublicKey(publicKeyHex);94+    const isValid = await crypto.subtle.verify("Ed25519", publicKey, signature, payload);95+    if (!isValid) return { valid: false, plan: "free" };11996     const revocations = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;120-    const revoked = Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) &&121-      revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier);122-    if (revoked) return { valid: false, plan: "free", identifier };123-124-    return { valid: true, plan: "pro", issued: issuedDate, identifier };97+    if (Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) && revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier)) {98+      return { valid: false, plan: "free", identifier };99+    }100+    return { valid: true, plan: "pro", issued: new Date(issuedTimestamp * 86400 * 1000), identifier };125101   } catch {126102     return { valid: false, plan: "free" };127103   }

view file

src/background/license.verification.test.ts+18 −0
@@ -247,4 +247,22 @@247247     const result = await validateLicense("PPK-AAAA-BBBB-CCCC-DDDD");248248     expect(result.valid).toBe(false);249249   });250+  it("uses the production verifier with a public-key vector and honors local revocation", async () => {251+    const local: Record<string, unknown> = {};252+    vi.stubGlobal("chrome", {253+      storage: {254+        local: {255+          get: vi.fn(async (key: string) => ({ [key]: local[key] })),256+          set: vi.fn(async (next: Record<string, unknown>) => Object.assign(local, next)),257+        },258+      },259+    });260+    const license = await buildTestLicense(testPrivKey);261+    const valid = await validateLicense(license, testPubKeyHex);262+    expect(valid).toMatchObject({ valid: true, plan: "pro", identifier: 0 });263+    const { revokeLicenseIdentifier } = await import("./license");264+    await revokeLicenseIdentifier(0);265+    await expect(validateLicense(license, testPubKeyHex)).resolves.toMatchObject({ valid: false, plan: "free", identifier: 0 });266+    vi.unstubAllGlobals();267+  });250268 });

view file

src/background/locked-sync-spool.test.ts+4 −6
@@ -135,10 +135,7 @@135135     expect(local.peach_locked_sync_spool_v1).toBeUndefined();136136   });137137 138-  // chrome.storage.session is memory-backed but outlives the MV3 service139-  // worker, which is what makes it the right home for the spool: locked sync140-  // keeps working across worker death without the key ever reaching disk.141-  it("the spool survives a service-worker restart but not a new browser session", async () => {138+  it("fails closed after a worker restart because the private key is not persisted", async () => {142139     const vault = await import("./vault");143140     await vault.createVault("correct horse battery staple");144141     const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");@@ -147,9 +144,10 @@147144 148145     vi.resetModules();149146     const { getLockedSyncIdentity } = await import("./locked-sync-spool");150-    expect(await getLockedSyncIdentity()).not.toBeNull();147+    expect(await getLockedSyncIdentity()).toBeNull();148+    expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("PrivateKey");149+    expect(JSON.stringify(session.peach_locked_sync_spool_v1)).not.toContain("longTermPrivateKey");151150 152-    // Browser restart: the session area is cleared for us.153151     for (const key of Object.keys(session)) delete session[key];154152     vi.resetModules();155153     const afterRestart = await import("./locked-sync-spool");

view file

src/background/locked-sync-spool.ts+20 −29
@@ -8,19 +8,8 @@88 const STORAGE_KEY = "peach_locked_sync_spool_v1";99 1010 /**11- * The spool lives in `chrome.storage.session`, never `chrome.storage.local`.12- *13- * PB-26-104: locked sync needs the device's long-term Noise static while the14- * vault is locked, which is exactly when no material exists to wrap it with —15- * anything available while locked is by definition at rest. Session storage is16- * memory-backed: the key never reaches disk, surviving the MV3 service-worker17- * restarts that a plain in-memory cache would not, and dying with the browser18- * session. That is the same accepted profile as the master-password-derived19- * session key (M-1).20- *21- * Session storage is quota-bound, so a vault too large to spool simply loses22- * locked sync for that browser session: the write failure is caught by23- * vault.refreshLockedSyncState and lock() still completes.11+ * Locked sync metadata lives in session storage, but the long-term private key12+ * stays only in this worker's heap. A worker restart fails closed until unlock.2413  */2514 2615 interface CachedEntry {@@ -38,13 +27,19 @@3827   deviceType: DeviceType;3928   localVersion: number;4029   longTermPublicKeyHex: string;41-  longTermPrivateKeyHex: string;4230   entries: Record<string, CachedEntry>;4331   tombstones: Record<string, number>;4432   inboxEntries: Record<string, StagedEntry>;4533   inboxTombstones: Record<string, number>;4634 }4735 36+let lockedPrivateKey: Uint8Array | null = null;37+38+export function setLockedSyncPrivateKey(value: Uint8Array): void {39+  lockedPrivateKey?.fill(0);40+  lockedPrivateKey = new Uint8Array(value);41+}42+4843 let spoolMutation: Promise<void> = Promise.resolve();4944 5045 export async function replaceLockedSyncSpool(@@ -62,23 +57,16 @@6257 6358 export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {6459   const snapshot = await loadSpool();65-  if (!snapshot) return null;60+  if (!snapshot || !lockedPrivateKey) return null;6661   return {6762     deviceId: snapshot.deviceId,6863     deviceName: snapshot.deviceName,6964     deviceType: snapshot.deviceType,7065     longTermPublicKeyHex: snapshot.longTermPublicKeyHex,71-    longTermPrivateKey: hexToBytes(snapshot.longTermPrivateKeyHex),66+    longTermPrivateKey: new Uint8Array(lockedPrivateKey),7267   };7368 }7469 75-function hexToBytes(value: string): Uint8Array {76-  const out = new Uint8Array(value.length / 2);77-  for (let i = 0; i < out.length; i += 1) {78-    out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);79-  }80-  return out;81-}8270 8371 export async function drainLockedSyncInbox(8472   live: SyncVaultAdapter,@@ -217,15 +205,18 @@217205   if (!value || typeof value !== "object") return false;218206   const candidate = value as Partial<LockedSyncSpool>;219207   return candidate.version === 1 &&220-    typeof candidate.longTermPrivateKeyHex === "string" &&221-    /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&222208     typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&223-    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&224-    typeof candidate.deviceName === "string" &&209+    typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 && candidate.deviceId.length <= 128 &&210+    typeof candidate.deviceName === "string" && candidate.deviceName.length <= 128 &&211+    (candidate.deviceType === "extension" || candidate.deviceType === "android" || candidate.deviceType === "ios") &&225212     typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&226-    typeof candidate.localVersion === "number" &&213+    typeof candidate.localVersion === "number" && Number.isSafeInteger(candidate.localVersion) &&227214     isRecord(candidate.entries) && isRecord(candidate.tombstones) &&228-    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);215+    isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones) &&216+    Object.keys(candidate.entries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&217+    Object.keys(candidate.tombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT &&218+    Object.keys(candidate.inboxEntries).length <= SYNC_LIMITS.MAX_ENTRY_COUNT &&219+    Object.keys(candidate.inboxTombstones).length <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT;229220 }230221 231222 function isRecord(value: unknown): value is Record<string, unknown> {

view file

src/background/sync-vault-adapter.ts+3 −6
@@ -75,7 +75,7 @@7575   type PairedDevice,7676   type SignedDeviceManifest,7777 } from "../shared/sync-types";78-import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";78+import { replaceLockedSyncSpool, drainLockedSyncInbox, setLockedSyncPrivateKey } from "./locked-sync-spool";7979 8080 // Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").8181 // The info binding prevents a ciphertext from one entry slot being replayed into another.@@ -249,11 +249,8 @@249249       };250250     }251251     const tombstones = getTombstoneManifest();252-    // PB-26-104: the private key rides along because locked sync needs it, but253-    // the spool is written to chrome.storage.session — never to disk. See254-    // locked-sync-spool.ts for why session storage is the only home that255-    // satisfies both constraints.256-    const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;252+    setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes);253+    const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;257254     await replaceLockedSyncSpool({258255       ...storedIdentity,259256       localVersion: await getVaultVersion(),

view file

src/background/vault.ts+54 −28
@@ -1260,7 +1260,44 @@12601260     typeof r.tag === "string"12611261   );12621262 }1263+const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12631264 1265+function hasValidHistorySlot(value: unknown): value is StoredVault {1266+  if (!hasValidVaultSchema(value)) return false;1267+  return /^[0-9a-f]{32}$/i.test(value.salt) &&1268+    /^[0-9a-f]{24}$/i.test(value.nonce) &&1269+    /^[0-9a-f]+$/i.test(value.ciphertext) &&1270+    value.ciphertext.length % 2 === 0 &&1271+    value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1272+    /^[0-9a-f]{32}$/i.test(value.tag);1273+}1274+1275+function parseHistorySlot(value: string): StoredVault | null {1276+  try {1277+    const parsed: unknown = JSON.parse(value);1278+    return hasValidHistorySlot(parsed) ? parsed : null;1279+  } catch {1280+    return null;1281+  }1282+}1283+1284+function isHistoryRecord(value: unknown): value is VaultSnapshot {1285+  if (typeof value !== "object" || value === null) return false;1286+  const record = value as Record<string, unknown>;1287+  return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1288+    typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1289+    typeof record.reason === "string" &&1290+    typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1291+    typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1292+    (record.entryCount === null || typeof record.entryCount === "number");1293+}1294+1295+function sanitizeHistory(value: unknown): VaultSnapshot[] {1296+  if (typeof value !== "object" || value === null) return [];1297+  if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1298+  return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1299+}1300+12641301 export type TryDecryptResult =12651302   | { status: "ok"; payload: DecryptedPayload }12661303   | { status: "wrong_key" }@@ -3411,56 +3448,45 @@34113448 34123449 /** Read all snapshots, newest first, for the Settings vault history view. */34133450 export async function getVaultHistory(): Promise<VaultSnapshot[]> {3414-  // The invariant lives here as well as at the message handlers, so it does not3415-  // depend on every caller remembering it — mirroring how takeVaultSnapshot3416-  // refuses to record while in decoy mode (PB-26-915).34173451   if (vaultMode === "decoy") return [];34183452   const raw = await chrome.storage.local.get(HISTORY_KEY);3419-  const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3420-  if (!history || !Array.isArray(history.snapshots)) return [];3421-  return history.snapshots;3453+  const value = (raw as Record<string, unknown>)[HISTORY_KEY];3454+  return sanitizeHistory(value);34223455 }34233456 34243457 /**34253458  * Replace the on-disk vault with the chosen snapshot's blobs and lock the3426- * vault. The current state is itself snapshotted with reason 'restore' first3427- * so the restore is reversible. After this returns, the user must unlock3428- * with the password that matches the restored blob.3459+ * vault. The current state is itself snapshotted before replacement.34293460  */34303461 export async function restoreVaultSnapshot(34313462   snapshotId: string,34323463 ): Promise<{ ok: true } | { ok: false; error: string }> {3433-  // A decoy session holds no real-mode authority: refusing here means a coerced3434-  // user cannot be made to roll the real vault back to an earlier snapshot3435-  // (PB-26-915).34363464   if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };34373465   const history = await getVaultHistory();34383466   const snapshot = history.find((s) => s.id === snapshotId);34393467   if (!snapshot) return { ok: false, error: "snapshot_not_found" };34403468 3441-  // Snapshot the about-to-be-replaced current state so the restore is3442-  // reversible. takeVaultSnapshot is no-op when the most recent entry is3443-  // byte-identical, so a restore-of-a-restore stays clean.3444-  await takeVaultSnapshot("restore");3469+  const restoredA = parseHistorySlot(snapshot.vault_a);3470+  const restoredB = parseHistorySlot(snapshot.vault_b);3471+  if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34453472 3446-  let restoredA: StoredVault;3447-  let restoredB: StoredVault;3473+  await takeVaultSnapshot("restore");3474+  const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3475+  const previousA = (current as Record<string, unknown>)[SLOT_A];3476+  const previousB = (current as Record<string, unknown>)[SLOT_B];34483477   try {3449-    restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3450-    restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3478+    await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3479+    await stampLocalVaultMutation();34513480   } catch {3481+    try {3482+      await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3483+    } catch {3484+      // Preserve the restore failure if storage cannot complete the rollback.3485+    }34523486     return { ok: false, error: "snapshot_corrupt" };34533487   }34543488 3455-  await writeSlot(SLOT_A, restoredA);3456-  await writeSlot(SLOT_B, restoredB);3457-  await stampLocalVaultMutation();3458-3459-  // Lock so the user must prove they can decrypt the restored blob. If they3460-  // restored a pre-password-change snapshot they discover it immediately at3461-  // the lock screen rather than silently holding an inaccessible vault.34623489   await lock();3463-34643490   return { ok: true };34653491 }34663492 

view file