123456789101112import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 LegacyVaultEntry,29 ManualSummonRecord,30 PasskeyData,31 SavedIdentity,32 StoredVault,33 VaultEntry,34 VaultHistory,35 VaultSnapshot,36} from "../shared/types";37import { decrypt, encrypt, zeroKey } from "./aes";38import { clearAutoLock, touchAutoLock } from "./alarms";39import { setRecoveryConfirmPending } from "./recovery-pending";4041type EntryDraft = Omit<42 VaultEntry,43 "id" | "createdAt" | "modifiedAt" | "deletedAt"44>;454647484950const SLOT_A = "peach_vault_a";51const SLOT_B = "peach_vault_b";525354const LEGACY_MAIN = "peach_vault";55const LEGACY_DECOY = "peach_decoy_vault";56const LEGACY_DURESS_FLAG = "peach_duress";57585960const PAD_BLOCK = 1024;6162636465const PASSWORD_HISTORY_MAX = 50;6667686970717273747576const DURESS_SLOT_SIZE = 262_144;7778let derivedKey: Uint8Array | null = null;7980818283let rootKey: Uint8Array | null = null;84let entries: VaultEntry[] = [];85let domainIndex: Map<string, VaultEntry[]> = new Map();86let vaultMode: "real" | "decoy" = "real";87888990let duressConfigured = false;9192let migrationRan = false;93949596979899100101102103let lastUnlockFailureReason: string | null = null;104105export function consumeLastUnlockFailureReason(): string | null {106 const r = lastUnlockFailureReason;107 lastUnlockFailureReason = null;108 return r;109}110111112113114115export type TextSize = "small" | "medium" | "large";116117export interface VaultSettings {118 accentColor?: AccentColorName;119 theme?: "dark" | "light";120 autoLockMinutes?: number;121 clipboardClearSeconds?: number;122 lockOnClose?: boolean;123 breachMonitoring?: boolean;124 passkeyProviderEnabled?: boolean;125 trashRetentionDays?: number;126 manualSummonDomains?: Record<string, ManualSummonRecord>;127 declinedCardFingerprints?: string[];128 129130131132133 textSize?: TextSize;134 135136137138139 reduceMotion?: boolean;140 141142143144145 biometricsEnabled?: boolean;146 147148149150151152153 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;154}155156let settings: VaultSettings = {};157158export function getSettings(): VaultSettings {159 return settings;160}161162export async function ensureSettingsLoaded(): Promise<VaultSettings> {163 await loadSettings();164 return settings;165}166167export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {168 await loadSettings();169 170 171 172 173 const safe = Object.fromEntries(174 Object.entries(patch).filter(175 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),176 ),177 ) as Partial<VaultSettings>;178 settings = { ...settings, ...safe };179 await persistSettings();180}181182async function persistSettings(): Promise<void> {183 try {184 await chrome.storage.local.set({ peach_settings: settings });185 } catch (err) {186 throw mapStorageError(err);187 }188}189190function mapStorageError(err: unknown): Error {191 const message = err instanceof Error ? err.message : String(err);192 if (/QUOTA_BYTES|quota/i.test(message)) {193 return new Error("STORAGE_QUOTA_EXCEEDED");194 }195 return err instanceof Error ? err : new Error(message);196}197198async function loadSettings(): Promise<void> {199 const result = await chrome.storage.local.get("peach_settings");200 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};201}202203204205function fireSyncTrigger(reason: "vault_change"): void {206 void Promise.resolve()207 .then(() => triggerSyncIfReachable({ reason }))208 .catch(() => {209 210 });211}212213214215216217export async function checkBreach(password: string): Promise<number> {218 219 220 const encoder = new TextEncoder();221 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));222 const hashHex = Array.from(new Uint8Array(hashBuffer))223 .map((b) => b.toString(16).padStart(2, "0"))224 .join("")225 .toUpperCase();226227 const prefix = hashHex.slice(0, 5);228 const suffix = hashHex.slice(5);229230 try {231 232 233 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {234 headers: { "Add-Padding": "true" },235 });236 if (!res.ok) return 0;237 const text = await res.text();238 const match = text.split("\r\n").find((l) => l.startsWith(suffix));239 return match ? parseInt(match.split(":")[1], 10) : 0;240 } catch {241 return 0; 242 }243}244245export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {246 const fingerprint = await cardFingerprint(last4, expMonth, expYear);247 await loadSettings();248 settings = {249 ...settings,250 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],251 };252 await persistSettings();253}254255export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {256 await loadSettings();257 const fingerprint = await cardFingerprint(last4, expMonth, expYear);258 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);259}260261async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {262 const encoder = new TextEncoder();263 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));264 return Array.from(new Uint8Array(buf).slice(0, 8))265 .map((b) => b.toString(16).padStart(2, "0"))266 .join("");267}268269function hex(buf: Uint8Array): string {270 return Array.from(buf)271 .map((b) => b.toString(16).padStart(2, "0"))272 .join("");273}274275function unhex(s: string): Uint8Array {276 const out = new Uint8Array(s.length / 2);277 for (let i = 0; i < s.length; i += 2) {278 out[i / 2] = parseInt(s.slice(i, i + 2), 16);279 }280 return out;281}282283284285286287288289290291292293294295296297298299300301302async function generateAndWrapRootKey(): Promise<{303 rawRootKey: string; 304 recoverySalt: string;305 wrappedRootKey: string;306 wrappedNonce: string;307 wrappedTag: string;308}> {309 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));310311 312 const words = await rootKeyToWords(rootKeyBytes);313 const wordMaterial = await wordsToRootKeyMaterial(words);314 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));315 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);316317 318 319 const nonce = crypto.getRandomValues(new Uint8Array(12));320 const encoded = new TextEncoder().encode(hex(rootKeyBytes));321 const raw = await crypto.subtle.encrypt(322 {323 name: "AES-GCM",324 iv: nonce,325 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,326 tagLength: 128,327 },328 recoveryKey,329 encoded330 );331 332 const rawU8 = new Uint8Array(raw);333 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));334 const tag = hex(rawU8.slice(rawU8.length - 16));335336 return {337 rawRootKey: hex(rootKeyBytes),338 recoverySalt: hex(recoverySalt),339 wrappedRootKey: ciphertext,340 wrappedNonce: hex(nonce),341 wrappedTag: tag,342 };343}344345async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{346 recoverySalt: string;347 wrappedRootKey: string;348 wrappedNonce: string;349 wrappedTag: string;350}> {351 if (rootKeyBytes.length !== 32) {352 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");353 }354355 const words = await rootKeyToWords(rootKeyBytes);356 const wordMaterial = await wordsToRootKeyMaterial(words);357 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));358 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);359 const nonce = crypto.getRandomValues(new Uint8Array(12));360 const encoded = new TextEncoder().encode(hex(rootKeyBytes));361 const raw = await crypto.subtle.encrypt(362 {363 name: "AES-GCM",364 iv: nonce,365 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,366 tagLength: 128,367 },368 recoveryKey,369 encoded,370 );371 const rawU8 = new Uint8Array(raw);372 return {373 recoverySalt: hex(recoverySalt),374 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),375 wrappedNonce: hex(nonce),376 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),377 };378}379380381382383384385export async function unwrapRootKeyForRecovery(386 wrappedRootKey: string,387 wrappedNonce: string,388 wrappedTag: string,389 recoverySaltHex: string,390 words: string[]391): Promise<void> {392 const wordMaterial = await wordsToRootKeyMaterial(words);393 const recoverySalt = unhex(recoverySaltHex);394 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);395396 397 398 399 400 401 const nonceRaw = unhex(wrappedNonce);402 const ciphertextRaw = unhex(wrappedRootKey);403 const tagRaw = unhex(wrappedTag);404 405 406 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;407 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);408 combined.set(ciphertextRaw, 0);409 combined.set(tagRaw, ciphertextRaw.length);410 const raw = await crypto.subtle.decrypt(411 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },412 recoveryKey,413 combined414 );415 const decryptedHex = new TextDecoder().decode(raw);416 rootKey = unhex(decryptedHex);417}418419420421422423424425426427428export async function rewrapRootKeyForPassword(429 newDerivedKey: Uint8Array,430 _newSalt: Uint8Array431): Promise<void> {432 if (!rootKey) throw new Error("root key not set");433 const slotA = await loadSlot(SLOT_A);434 if (!slotA) throw new Error("missing slot A");435 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);436 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });437}438439440441442443444async function reencryptVaultForNewKey(445 newKey: Uint8Array,446 slotA: StoredVault447): Promise<void> {448 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);449 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");450 await writeSlot(SLOT_A, {451 ...slotA,452 nonce,453 ciphertext,454 tag,455 });456}457458459460461462463export async function changePasswordWithNewKey(464 newSalt: Uint8Array,465 newKey: Uint8Array466): Promise<void> {467 const slotA = await loadSlot(SLOT_A);468 if (!slotA) throw new Error("missing slot A");469 470 471 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });472 if (derivedKey) zeroKey(derivedKey);473 derivedKey = newKey;474 475 476 477 void saveSessionKey(newKey, "real");478 479 480 481 482 if (rootKey) {483 const slotAfter = await loadSlot(SLOT_A);484 if (slotAfter) {485 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);486 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });487 }488 }489 if (!duressConfigured) {490 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));491 }492 await stampLocalVaultMutation();493}494495496497498499500export { deriveKey, generateSalt };501502export async function loadSlotA(): Promise<StoredVault | null> {503 return loadSlot(SLOT_A);504}505506507508509510511512513514515516517518async function deriveRootKeyFromDerivedKey(519 derivedKeyBytes: Uint8Array,520 slotA: StoredVault,521): Promise<Uint8Array | null> {522 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {523 return null;524 }525 try {526 const rootHex = await decrypt(527 derivedKeyBytes,528 slotA.pw_root_nonce,529 slotA.pw_root_ciphertext,530 slotA.pw_root_tag,531 "pw_root_v1",532 );533 return unhex(rootHex);534 } catch {535 return null;536 }537}538539540541async function wrapRootKeyForDerivedKey(542 derivedKeyBytes: Uint8Array,543 rootKeyBytes: Uint8Array,544): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {545 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");546 return {547 pw_root_ciphertext: ciphertext,548 pw_root_nonce: nonce,549 pw_root_tag: tag,550 };551}552553554555556557558559560export async function migrateToRecovery(): Promise<void> {561 if (!derivedKey || vaultMode !== "real") {562 throw new Error("vault must be unlocked in real mode");563 }564 const slotA = await loadSlot(SLOT_A);565 if (!slotA) throw new Error("missing slot A");566 const hasRecoveryEscrow =567 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;568 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;569 if (hasRecoveryEscrow && hasPwRoot) return; 570571 572 573 574 575 576 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =577 await generateAndWrapRootKey();578 rootKey = unhex(rawRootKey);579580 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);581 const nextSlotA: StoredVault = {582 ...slotA,583 recovery_salt: recoverySalt,584 recovery_wrapped_root_key: wrappedRootKey,585 recovery_nonce: wrappedNonce,586 recovery_tag: wrappedTag,587 ...pwRoot,588 };589 await writeSlot(SLOT_A, nextSlotA);590 await stampLocalVaultMutation();591}592593594595596597598export async function getRecoveryWords(): Promise<string[]> {599 if (!rootKey) {600 throw new Error("recovery not set up — vault may need migration");601 }602 return rootKeyToWords(rootKey);603}604605async function loadSlot(key: string): Promise<StoredVault | null> {606 const result = await chrome.storage.local.get(key);607 return (result as Record<string, StoredVault | undefined>)[key] ?? null;608}609610async function writeSlot(key: string, vault: StoredVault): Promise<void> {611 try {612 await chrome.storage.local.set({ [key]: vault });613 } catch (err) {614 throw mapStorageError(err);615 }616}617618619620621622623624625626627export async function rollbackAdoptedVault(): Promise<void> {628 lock();629 try {630 await chrome.storage.local.remove([631 SLOT_A,632 SLOT_B,633 "peach_last_modified_at",634 ]);635 } catch (err) {636 throw mapStorageError(err);637 }638}639640async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {641 const raw = await chrome.storage.local.get("peach_last_modified_at");642 const previous = Number(643 (raw as Record<string, unknown>).peach_last_modified_at,644 );645 const next = Math.max(646 Date.now(),647 Number.isFinite(previous) ? previous + 1 : 0,648 );649 try {650 await chrome.storage.local.set({651 [SLOT_A]: slotA,652 [SLOT_B]: slotB,653 peach_last_modified_at: String(next),654 });655 } catch (err) {656 throw mapStorageError(err);657 }658}659660661662663664665666667let lastEntryTimestamp = 0;668function monotonicEntryTimestamp(currentModifiedAt?: number): number {669 const base = Math.max(670 Date.now(),671 (currentModifiedAt ?? 0) + 1,672 lastEntryTimestamp + 1,673 );674 lastEntryTimestamp = base;675 return base;676}677678async function stampLocalVaultMutation(): Promise<void> {679 const raw = await chrome.storage.local.get("peach_last_modified_at");680 const previous = Number(681 (raw as Record<string, unknown>).peach_last_modified_at,682 );683 const next = Math.max(684 Date.now(),685 Number.isFinite(previous) ? previous + 1 : 0,686 );687 await chrome.storage.local.set({688 peach_last_modified_at: String(next),689 });690}691692693694695696697async function ensureMigrated(): Promise<void> {698 if (migrationRan) return;699 migrationRan = true;700701 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];702 const raw = await chrome.storage.local.get(keys);703 const slotAExists = raw[SLOT_A] !== undefined;704 const slotBExists = raw[SLOT_B] !== undefined;705 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;706 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;707708 709 if (!slotAExists && !legacyMain) return;710711 712 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;713714 let didMigrate = false;715 if (legacyMain && !slotAExists) {716 await writeSlot(SLOT_A, legacyMain);717 didMigrate = true;718 }719720 721 if (!slotBExists) {722 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;723 if (legacyDecoy) {724 await writeSlot(SLOT_B, legacyDecoy);725 didMigrate = true;726 } else if (target) {727 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));728 didMigrate = true;729 }730 }731732 const toRemove: string[] = [];733 if (legacyMain) toRemove.push(LEGACY_MAIN);734 if (legacyDecoy) toRemove.push(LEGACY_DECOY);735 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);736 if (toRemove.length > 0) {737 await chrome.storage.local.remove(toRemove);738 didMigrate = true;739 }740 if (didMigrate) {741 await stampLocalVaultMutation();742 }743}744745746747748749750751752753754755function padPlaintext(json: string, minBytes = 0): string {756 const encoded = new TextEncoder().encode(json);757 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);758 759 const target = Math.max(bucket, minBytes);760 const padLen = target - encoded.length;761 return json + " ".repeat(padLen);762}763764765766767768769770771772773774const CSPRNG_CHUNK = 65_536;775776function fillRandom(buf: Uint8Array): Uint8Array {777 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {778 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);779 const chunk = new Uint8Array(end - offset);780 crypto.getRandomValues(chunk);781 buf.set(chunk, offset);782 }783 return buf;784}785786function generateNoiseBlob(ciphertextBytes: number): StoredVault {787 const salt = crypto.getRandomValues(new Uint8Array(16));788 const nonce = crypto.getRandomValues(new Uint8Array(12));789 const cipher = fillRandom(new Uint8Array(ciphertextBytes));790 const tag = crypto.getRandomValues(new Uint8Array(16));791 return {792 version: 1,793 salt: hex(salt),794 nonce: hex(nonce),795 ciphertext: hex(cipher),796 tag: hex(tag),797 };798}799800801802803804type VaultPayload = {805 entries: VaultEntry[];806 duressConfigured?: boolean;807};808809type DecryptedPayload = {810 entries: VaultEntry[];811 duressConfigured: boolean;812 813814815816817818819 migratedFromLegacy: boolean;820};821822function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {823 const envelope: VaultPayload = { entries, duressConfigured: duress };824 return padPlaintext(JSON.stringify(envelope), minBytes);825}826827function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {828 if ("created" in raw || "updated" in raw) return true;829 const flat = raw as Partial<VaultEntry>;830 if (flat.type === "passkey") return true;831 832 if (flat.rpId !== undefined && flat.passkey === undefined) return true;833 return false;834}835836function decodePayload(plain: string): DecryptedPayload {837 const parsed = JSON.parse(plain) as838 | Array<VaultEntry | LegacyVaultEntry>839 | VaultPayload;840841 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];842 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);843844 const normalized = rawEntries.map(normalizeEntry);845 const { merged } = mergePasskeyOnlyIntoLogins(normalized);846847 if (Array.isArray(parsed)) {848 return {849 entries: normalized,850 duressConfigured: false,851 migratedFromLegacy: true, 852 };853 }854855 return {856 entries: normalized,857 duressConfigured: parsed.duressConfigured === true,858 migratedFromLegacy: flatMigrationNeeded || merged,859 };860}861862863864865866867868869870871872873874875876877878879880881882883884885function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {886 let merged = false;887 const now = Date.now();888889 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);890 const byRpId = new Map<string, VaultEntry[]>();891 for (const e of candidates) {892 const rpId = e.passkey!.rpId;893 const list = byRpId.get(rpId);894 if (list) list.push(e);895 else byRpId.set(rpId, [e]);896 }897898 for (const [rpId, group] of byRpId) {899 if (group.length === 0) continue;900 group.sort((a, b) => {901 const aT =902 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;903 const bT =904 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;905 return bT - aT;906 });907 const winner = group[0]!;908909 910 911 912 const loginTarget = entries.find((e) => {913 if (e.deletedAt || e.passkey || e.type !== "login") return false;914 if (e.url && extractDomain(e.url) === rpId) return true;915 for (const extra of e.additionalUrls ?? []) {916 if (extractDomain(extra) === rpId) return true;917 }918 return false;919 });920921 if (loginTarget) {922 loginTarget.passkey = winner.passkey;923 924 925 926 if (!loginTarget.totp && winner.totp) {927 loginTarget.totp = winner.totp;928 loginTarget.totpAlgorithm = winner.totpAlgorithm;929 loginTarget.totpDigits = winner.totpDigits;930 loginTarget.totpPeriod = winner.totpPeriod;931 }932 loginTarget.modifiedAt = now;933 for (const dup of group) {934 if (!dup.deletedAt) {935 dup.deletedAt = now;936 dup.modifiedAt = now;937 }938 }939 merged = true;940 } else if (group.length > 1) {941 for (let i = 1; i < group.length; i += 1) {942 const dup = group[i]!;943 if (!dup.deletedAt) {944 dup.deletedAt = now;945 dup.modifiedAt = now;946 }947 }948 merged = true;949 }950 }951952 return { merged };953}954955956957958959960961function hasValidVaultSchema(v: unknown): v is StoredVault {962 if (typeof v !== "object" || v === null) return false;963 const r = v as Record<string, unknown>;964 return (965 r.version === 1 &&966 typeof r.salt === "string" &&967 typeof r.nonce === "string" &&968 typeof r.ciphertext === "string" &&969 typeof r.tag === "string"970 );971}972973export type TryDecryptResult =974 | { status: "ok"; payload: DecryptedPayload }975 | { status: "wrong_key" }976 | { status: "corrupted"; reason: string };977978979980981982983export async function tryDecryptSlot(984 vault: StoredVault,985 key: Uint8Array,986 aadTag?: string,987): Promise<DecryptedPayload | null> {988 const res = await tryDecryptSlotResult(vault, key, aadTag);989 return res.status === "ok" ? res.payload : null;990}991992export async function tryDecryptSlotResult(993 vault: StoredVault,994 key: Uint8Array,995 aadTag?: string,996): Promise<TryDecryptResult> {997 if (!hasValidVaultSchema(vault)) {998 return { status: "corrupted", reason: "invalid_slot_schema" };999 }1000 let plain: string;1001 try {1002 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1003 } catch {1004 return { status: "wrong_key" };1005 }1006 try {1007 const parsed = JSON.parse(plain) as unknown;1008 if (typeof parsed !== "object" || parsed === null) {1009 return { status: "corrupted", reason: "invalid_payload_shape" };1010 }1011 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1012 return { status: "corrupted", reason: "invalid_payload_shape" };1013 }1014 return { status: "ok", payload: decodePayload(plain) };1015 } catch {1016 return { status: "corrupted", reason: "invalid_payload_json" };1017 }1018}101910201021102210231024function buildDomainIndexSync(all: VaultEntry[]): void {1025 domainIndex = new Map();10261027 for (const entry of all) {1028 if (entry.deletedAt) continue;1029 if (entry.type === "note" || entry.type === "card") continue;10301031 1032 1033 1034 1035 const domains = new Set<string>();1036 const primary = extractDomain(entry.url ?? "");1037 if (primary) domains.add(primary);1038 for (const extra of entry.additionalUrls ?? []) {1039 const d = extractDomain(extra);1040 if (d) domains.add(d);1041 }1042 if (domains.size === 0) continue;10431044 for (const domain of domains) {1045 const bucket = domainIndex.get(domain) ?? [];1046 bucket.push(entry);1047 domainIndex.set(domain, bucket);1048 }1049 }1050}10511052export async function createVault(password: string): Promise<void> {1053 await ensureMigrated();10541055 const salt = generateSalt();1056 derivedKey = await deriveKey(password, salt);1057 entries = [];1058 vaultMode = "real";1059 duressConfigured = false;1060 buildDomainIndexSync(entries);10611062 1063 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1064 await generateAndWrapRootKey();1065 rootKey = unhex(rawRootKey);10661067 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1068 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10691070 1071 1072 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10731074 const slotA: StoredVault = {1075 version: 1,1076 salt: hex(salt),1077 nonce,1078 ciphertext,1079 tag,1080 recovery_salt: recoverySalt,1081 recovery_wrapped_root_key: wrappedRootKey,1082 recovery_nonce: wrappedNonce,1083 recovery_tag: wrappedTag,1084 ...pwRoot,1085 };1086 await writeSlot(SLOT_A, slotA);10871088 1089 1090 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1091 await stampLocalVaultMutation();10921093 touchAutoLock();1094}1095109610971098109911001101export async function createVaultFromRootKey(1102 password: string,1103 rootKeyHex: string,1104): Promise<{ vaultId: string }> {1105 if (typeof password !== "string" || password.length === 0) {1106 throw new Error("ADOPT_INVALID_PASSWORD");1107 }1108 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1109 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1110 }11111112 const rootKeyBytes = unhex(rootKeyHex);1113 if (rootKeyBytes.length !== 32) {1114 rootKeyBytes.fill(0);1115 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1116 }11171118 let nextDerivedKey: Uint8Array | null = null;1119 let sessionRootKey: Uint8Array | null = null;1120 try {1121 await ensureMigrated();1122 if ((await loadSlot(SLOT_A)) !== null) {1123 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1124 }11251126 const nextEntries: VaultEntry[] = [];1127 const nextDuressConfigured = false;1128 const vaultId = await computeVaultId(rootKeyBytes);1129 const salt = generateSalt();1130 nextDerivedKey = await deriveKey(password, salt);1131 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11321133 sessionRootKey = new Uint8Array(rootKeyBytes);1134 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1135 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1136 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1137 const slotA: StoredVault = {1138 version: 1,1139 salt: hex(salt),1140 nonce,1141 ciphertext,1142 tag,1143 recovery_salt: recovery.recoverySalt,1144 recovery_wrapped_root_key: recovery.wrappedRootKey,1145 recovery_nonce: recovery.wrappedNonce,1146 recovery_tag: recovery.wrappedTag,1147 ...pwRoot,1148 };1149 const slotB = generateNoiseBlob(ciphertext.length / 2);1150 await commitCreatedVaultSlots(slotA, slotB);11511152 const previousDerivedKey = derivedKey;1153 const previousRootKey = rootKey;1154 derivedKey = nextDerivedKey;1155 rootKey = sessionRootKey;1156 nextDerivedKey = null;1157 sessionRootKey = null;1158 entries = nextEntries;1159 vaultMode = "real";1160 duressConfigured = nextDuressConfigured;1161 buildDomainIndexSync(entries);1162 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1163 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1164 touchAutoLock();11651166 return { vaultId };1167 } finally {1168 rootKeyBytes.fill(0);1169 if (nextDerivedKey) zeroKey(nextDerivedKey);1170 if (sessionRootKey) sessionRootKey.fill(0);1171 }1172}11731174export async function hasVault(): Promise<boolean> {1175 await ensureMigrated();1176 return (await loadSlot(SLOT_A)) !== null;1177}1178117911801181118211831184export async function isDuressEnabled(): Promise<boolean> {1185 return vaultMode === "real" && duressConfigured;1186}11871188export async function setupDuress(duressPassword: string): Promise<void> {1189 if (!derivedKey || vaultMode !== "real") {1190 throw new Error("vault must be unlocked in real mode");1191 }1192 await ensureMigrated();11931194 1195 1196 1197 const slotA = await loadSlot(SLOT_A);1198 if (!slotA) throw new Error("missing slot A");11991200 const decoySalt = generateSalt();1201 const decoyKey = await deriveKey(duressPassword, decoySalt);1202 try {1203 1204 1205 1206 1207 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1208 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1209 await writeSlot(SLOT_B, {1210 version: 1,1211 salt: hex(decoySalt),1212 nonce,1213 ciphertext,1214 tag,1215 });1216 } finally {1217 zeroKey(decoyKey);1218 }12191220 1221 1222 duressConfigured = true;1223 await persistSlotA();1224}12251226export async function disableDuress(): Promise<void> {1227 if (!derivedKey || vaultMode !== "real") {1228 throw new Error("vault must be unlocked in real mode");1229 }1230 await ensureMigrated();12311232 1233 1234 1235 const slotA = await loadSlot(SLOT_A);1236 if (slotA) {1237 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1238 }12391240 duressConfigured = false;1241 await persistSlotA();1242}12431244124512461247124812491250export async function verifyMainPassword(password: string): Promise<boolean> {1251 await ensureMigrated();1252 const slotA = await loadSlot(SLOT_A);1253 if (!slotA) return false;1254 const key = await deriveKey(password, unhex(slotA.salt));1255 try {1256 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1257 } finally {1258 zeroKey(key);1259 }1260}12611262export async function unlock(password: string): Promise<boolean> {1263 1264 1265 1266 if (await attemptUnlockWithDerive(password, deriveKey)) return true;12671268 const nfc = password.normalize("NFC");1269 if (password === nfc) return false; 12701271 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1272 1273 1274 1275 if (vaultMode === "real") {1276 try {1277 const newSalt = generateSalt();1278 const newKey = await deriveKey(password, newSalt);1279 await changePasswordWithNewKey(newSalt, newKey);1280 } catch (healErr) {1281 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1282 }1283 }1284 return true;1285 }1286 return false;1287}12881289async function attemptUnlockWithDerive(1290 password: string,1291 derive: typeof deriveKey,1292): Promise<boolean> {1293 await ensureMigrated();12941295 1296 1297 1298 1299 1300 const [slotA, slotB] = await Promise.all([1301 loadSlot(SLOT_A),1302 loadSlot(SLOT_B),1303 ]);13041305 1306 const [keyA, keyB] = await Promise.all([1307 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1308 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1309 ]);13101311 1312 1313 1314 const [resultA, resultB] = await Promise.all([1315 keyA && slotA1316 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1317 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1318 keyB && slotB1319 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1320 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1321 ]);1322 const payloadA = resultA.status === "ok" ? resultA.payload : null;1323 const payloadB = resultB.status === "ok" ? resultB.payload : null;13241325 if (payloadA && keyA) {1326 derivedKey = keyA;1327 void saveSessionKey(keyA, "real");1328 vaultMode = "real";1329 entries = payloadA.entries;1330 duressConfigured = payloadA.duressConfigured;1331 buildDomainIndexSync(entries);1332 await loadSettings();1333 touchAutoLock();1334 if (keyB) zeroKey(keyB);13351336 1337 if (slotA) {1338 const needsRecoverySetup =1339 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1340 if (!needsRecoverySetup) {1341 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1342 }1343 1344 1345 1346 1347 if (needsRecoverySetup || !rootKey) {1348 try {1349 await migrateToRecovery();1350 if (needsRecoverySetup) {1351 1352 1353 1354 await chrome.storage.local.set({ peach_recovery_pending: '1' });1355 }1356 } catch (migrationErr) {1357 console.warn('[Peach] recovery migration failed:', migrationErr);1358 }1359 }1360 }13611362 1363 1364 1365 if (payloadA.migratedFromLegacy) {1366 await persistSlotA();1367 }1368 return true;1369 }13701371 if (payloadB && keyB) {1372 derivedKey = keyB;1373 void saveSessionKey(keyB, "decoy");1374 vaultMode = "decoy";1375 entries = payloadB.entries;1376 duressConfigured = false;1377 buildDomainIndexSync(entries);1378 await loadSettings();1379 touchAutoLock();1380 if (keyA) zeroKey(keyA);1381 return true;1382 }13831384 if (keyA) zeroKey(keyA);1385 if (keyB) zeroKey(keyB);1386 1387 1388 if (resultA.status === "corrupted") {1389 lastUnlockFailureReason = resultA.reason;1390 } else if (resultB.status === "corrupted") {1391 lastUnlockFailureReason = resultB.reason;1392 }1393 return false;1394}13951396139713981399140014011402const SESSION_KEY_STORAGE = "peach_session_dk";14031404async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1405 if (!chrome.storage.session) return;1406 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1407}14081409async function clearSessionKey(): Promise<void> {1410 if (!chrome.storage.session) return;1411 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1412}1413141414151416141714181419142014211422export async function restoreFromSession(): Promise<boolean> {1423 if (derivedKey !== null) return true;1424 if (!chrome.storage.session) return false;1425 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1426 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1427 if (!entry?.keyHex) return false;14281429 const key = unhex(entry.keyHex);1430 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1431 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1432 const slot = await loadSlot(slotKey);1433 if (!slot) { await clearSessionKey(); return false; }14341435 const result = await tryDecryptSlotResult(slot, key, aadTag);1436 if (result.status !== "ok") { await clearSessionKey(); return false; }14371438 derivedKey = key;1439 vaultMode = entry.mode;1440 entries = result.payload.entries;1441 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1442 buildDomainIndexSync(entries);1443 await loadSettings();1444 touchAutoLock();14451446 1447 1448 1449 1450 1451 1452 1453 1454 1455 if (entry.mode === "real") {1456 try {1457 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1458 } catch {1459 1460 }1461 }14621463 return true;1464}14651466export function lock(): void {1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 if (derivedKey) {1479 zeroKey(derivedKey);1480 derivedKey = null;1481 }1482 if (rootKey) {1483 rootKey.fill(0);1484 rootKey = null;1485 }14861487 entries = [];1488 domainIndex = new Map();1489 vaultMode = "real";1490 duressConfigured = false;1491 clearAutoLock();1492}14931494export function isUnlocked(): boolean {1495 return derivedKey !== null;1496}14971498149915001501export async function unlockWithKey(1502 masterKeyHex: string,1503): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1504 try {1505 const masterKeyBytes = unhex(masterKeyHex);1506 const stored = await loadSlot(SLOT_A);1507 if (!stored) return { ok: false, error: "invalid_key" };1508 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1509 if (!payload) return { ok: false, error: "invalid_key" };15101511 derivedKey = masterKeyBytes;1512 vaultMode = "real";1513 entries = payload.entries;1514 duressConfigured = payload.duressConfigured;1515 buildDomainIndexSync(entries);1516 await loadSettings();1517 touchAutoLock();15181519 1520 1521 1522 1523 1524 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1525 try {1526 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1527 } catch { }1528 }1529 return { ok: true, entries };1530 } catch {1531 return { ok: false, error: "invalid_key" };1532 }1533}153415351536153715381539154015411542export async function exportDuressSlotForAdoption(): Promise<string | null> {1543 if (!(await isDuressEnabled())) return null;1544 await ensureMigrated();1545 const slotB = await loadSlot(SLOT_B);1546 if (!slotB) return null;1547 return JSON.stringify(slotB);1548}15491550155115521553155415551556export async function installAdoptedDuressSlot(json: string): Promise<void> {1557 let parsed: StoredVault;1558 try {1559 const raw = JSON.parse(json) as unknown;1560 if (!hasValidVaultSchema(raw)) {1561 throw new Error("invalid_duress_slot_shape");1562 }1563 parsed = raw;1564 } catch (err) {1565 if (err instanceof SyntaxError) {1566 throw new Error("invalid_duress_slot_shape");1567 }1568 throw err;1569 }1570 await writeSlot(SLOT_B, parsed);1571}15721573157415751576157715781579158015811582158315841585158615871588158915901591export async function unlockFromCodex(params: {1592 slotA: string; 1593 slotB: string | null; 1594 timestamp: number;1595 masterPassphrase: string;1596}): Promise<1597 | { ok: true; entryCount: number }1598 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1599> {1600 let parsedSlotA: StoredVault;1601 let parsedSlotB: StoredVault | null = null;1602 try {1603 const rawA = JSON.parse(params.slotA) as unknown;1604 if (!hasValidVaultSchema(rawA)) {1605 return { ok: false, reason: "invalid_slot_format" };1606 }1607 parsedSlotA = rawA;1608 if (params.slotB !== null) {1609 const rawB = JSON.parse(params.slotB) as unknown;1610 if (!hasValidVaultSchema(rawB)) {1611 return { ok: false, reason: "invalid_slot_format" };1612 }1613 parsedSlotB = rawB;1614 }1615 } catch {1616 return { ok: false, reason: "invalid_slot_format" };1617 }16181619 1620 1621 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1622 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1623 if (!trialPayload) {1624 zeroKey(trialKey);1625 return { ok: false, reason: "wrong_passphrase" };1626 }1627 1628 1629 zeroKey(trialKey);16301631 1632 1633 await takeVaultSnapshot("restore");16341635 await writeSlot(SLOT_A, parsedSlotA);1636 if (parsedSlotB) {1637 await writeSlot(SLOT_B, parsedSlotB);1638 } else {1639 1640 1641 1642 await writeSlot(1643 SLOT_B,1644 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1645 );1646 }1647 await chrome.storage.local.set({1648 peach_last_modified_at: String(params.timestamp),1649 });16501651 1652 1653 const ok = await unlock(params.masterPassphrase);1654 if (!ok) {1655 1656 1657 1658 return { ok: false, reason: "wrong_passphrase" };1659 }1660 return { ok: true, entryCount: getEntries().length };1661}1662166316641665export function getDerivedKeyHex(): string {1666 if (!derivedKey) throw new Error("vault_not_unlocked");1667 return hex(derivedKey);1668}16691670167116721673167416751676167716781679168016811682168316841685export function getRootKeyHex(): string | null {1686 return rootKey ? hex(rootKey) : null;1687}16881689export function getEntries(): VaultEntry[] {1690 if (!derivedKey) throw new Error("vault_locked");1691 return entries.filter((entry) => !entry.deletedAt);1692}16931694const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;16951696export function getIdentities(): SavedIdentity[] {1697 if (!derivedKey) throw new Error("vault_locked");1698 const active = entries.filter((e) => !e.deletedAt && e.type === "login");16991700 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1701 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();17021703 for (const entry of active) {1704 const domain = extractDomain(entry.url ?? "") ?? "";1705 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;17061707 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1708 const existing = map.get(v);1709 if (!existing || modifiedAt > existing.lastUsed) {1710 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1711 } else {1712 map.set(v, { ...existing, count: existing.count + 1 });1713 }1714 }17151716 const uVal = entry.username?.trim();1717 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);17181719 1720 1721 const eVal = entry.email?.trim();1722 if (eVal) tally(emailMap, eVal);1723 }17241725 const toIdentity = (type: "email" | "username") =>1726 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1727 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1728 });17291730 return [1731 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1732 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1733 ];1734}17351736export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1737 if (!derivedKey) throw new Error("vault_locked");17381739 const now = Date.now();1740 const added: VaultEntry[] = [];1741 for (const draft of drafts) {1742 try {1743 const sanitized = sanitizeEntryDraft(draft);1744 const nextEntry: VaultEntry = {1745 ...sanitized,1746 id: crypto.randomUUID(),1747 createdAt: now,1748 modifiedAt: monotonicEntryTimestamp(),1749 };1750 entries.push(nextEntry);1751 added.push(nextEntry);1752 } catch {1753 1754 }1755 }17561757 if (added.length > 0) {1758 await persistEntries();1759 }17601761 return added;1762}176317641765176617671768176917701771export async function restoreEntries(1772 incoming: VaultEntry[],1773 mode: "replace" | "merge"1774): Promise<{ added: number; updated: number; skipped: number }> {1775 if (!derivedKey) throw new Error("vault_locked");17761777 1778 1779 1780 if (mode === "replace" && incoming.length === 0) {1781 throw new Error("refuse to replace with empty entry set");1782 }17831784 let added = 0;1785 let updated = 0;1786 let skipped = 0;17871788 if (mode === "replace") {1789 entries = [];1790 for (const entry of incoming) {1791 const normalized = sanitizeRestoredEntry(entry);1792 if (!normalized) {1793 skipped += 1;1794 continue;1795 }1796 entries.push(normalized);1797 added += 1;1798 }1799 } else {1800 const byId = new Map<string, VaultEntry>();1801 for (const e of entries) byId.set(e.id, e);18021803 for (const entry of incoming) {1804 const normalized = sanitizeRestoredEntry(entry);1805 if (!normalized) {1806 skipped += 1;1807 continue;1808 }1809 const existing = byId.get(normalized.id);1810 if (!existing) {1811 entries.push(normalized);1812 added += 1;1813 } else if (normalized.modifiedAt > existing.modifiedAt) {1814 const idx = entries.findIndex((e) => e.id === existing.id);1815 if (idx !== -1) {1816 entries[idx] = normalized;1817 updated += 1;1818 }1819 } else {1820 skipped += 1;1821 }1822 }1823 }18241825 await persistEntries("import");1826 return { added, updated, skipped };1827}182818291830183118321833183418351836function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1837 if (!entry || typeof entry !== "object") return null;1838 const title = typeof entry.title === "string" ? entry.title.trim() : "";1839 if (!title) return null;1840 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1841 const now = Date.now();1842 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1843 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;18441845 1846 1847 1848 1849 const normalized = normalizeEntry({1850 ...entry,1851 id,1852 title,1853 type: entry.type ?? "login",1854 createdAt,1855 modifiedAt,1856 });18571858 if (normalized.passkey) {1859 normalized.passkey = sanitizePasskey(normalized.passkey);1860 }1861 if (normalized.canary) {1862 normalized.canary = sanitizeCanary(normalized.canary);1863 }1864 1865 normalized.tags = sanitizeTags(normalized.tags);18661867 1868 1869 if (1870 normalized.type === "login" &&1871 !normalized.passkey &&1872 typeof normalized.password !== "string"1873 ) {1874 return null;1875 }1876 if (entry.passkey && !normalized.passkey) {1877 1878 1879 return null;1880 }18811882 return normalized;1883}1884188518861887188818891890function sanitizePasskey(pk: unknown): PasskeyData | undefined {1891 if (!pk || typeof pk !== "object") return undefined;1892 const r = pk as Record<string, unknown>;1893 if (1894 typeof r.rpId !== "string" || !r.rpId ||1895 typeof r.credentialId !== "string" || !r.credentialId ||1896 typeof r.publicKeyCose !== "string" ||1897 typeof r.userHandle !== "string" ||1898 typeof r.userName !== "string"1899 ) {1900 return undefined;1901 }1902 return {1903 rpId: r.rpId,1904 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1905 credentialId: r.credentialId,1906 publicKeyCose: r.publicKeyCose,1907 privateKeyJwk:1908 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1909 ? (r.privateKeyJwk as JsonWebKey)1910 : undefined,1911 userHandle: r.userHandle,1912 userName: r.userName,1913 userDisplayName:1914 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1915 algorithm: r.algorithm === -8 ? -8 : -7,1916 signCount: typeof r.signCount === "number" ? r.signCount : 0,1917 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1918 backupEligible: r.backupEligible === true,1919 backupState: r.backupState === true,1920 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1921 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1922 transports: Array.isArray(r.transports)1923 ? r.transports.filter((t): t is string => typeof t === "string")1924 : undefined,1925 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1926 };1927}19281929193019311932193319341935193619371938function sanitizeCanary(c: unknown): Canary | undefined {1939 if (!c || typeof c !== "object") return undefined;1940 const r = c as Record<string, unknown>;1941 if (typeof r.webhookUrl !== "string") return undefined;1942 let url: URL;1943 try {1944 url = new URL(r.webhookUrl);1945 } catch {1946 return undefined;1947 }1948 if (url.protocol !== "https:") return undefined;1949 return {1950 webhookUrl: url.toString(),1951 label: typeof r.label === "string" ? r.label : undefined,1952 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1953 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1954 };1955}19561957195819591960196119621963function sanitizeTags(tags: unknown): string[] | undefined {1964 if (!Array.isArray(tags)) return undefined;1965 const out: string[] = [];1966 for (const t of tags) {1967 if (typeof t !== "string") continue;1968 const trimmed = t.trim();1969 if (!trimmed) continue;1970 out.push(trimmed.slice(0, 256));1971 if (out.length >= 255) break;1972 }1973 return out.length > 0 ? out : undefined;1974}197519761977197819791980export async function clearVaultEntries(1981 confirmation: "REPLACE_ALL"1982): Promise<void> {1983 if (confirmation !== "REPLACE_ALL") {1984 throw new Error("clearVaultEntries requires explicit confirmation");1985 }1986 if (!derivedKey) throw new Error("vault_locked");1987 for (const entry of entries) {1988 if (!entry.deletedAt) {1989 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1990 entry.deletedAt = stamp;1991 entry.modifiedAt = stamp;1992 }1993 }1994 await persistEntries();1995}19961997export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1998 if (!derivedKey) throw new Error("vault_locked");19992000 const sanitized = sanitizeEntryDraft(entry);2001 const now = Date.now();2002 const stamp = monotonicEntryTimestamp();2003 const nextEntry: VaultEntry = {2004 ...sanitized,2005 id: crypto.randomUUID(),2006 createdAt: now,2007 modifiedAt: stamp,2008 2009 2010 passwordChangedAt: sanitized.password ? stamp : undefined,2011 };20122013 2014 2015 2016 const rollback = entries;2017 entries = [...entries, nextEntry];2018 try {2019 await persistEntries();2020 } catch (err) {2021 entries = rollback;2022 throw err;2023 }20242025 2026 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2027 checkBreach(nextEntry.password).then(async (count) => {2028 if (count > 0) {2029 const idx = entries.findIndex((e) => e.id === nextEntry.id);2030 if (idx !== -1) {2031 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2032 await persistEntries();2033 }2034 }2035 });2036 }20372038 fireSyncTrigger("vault_change");20392040 return nextEntry;2041}20422043204420452046204720482049export async function consolidatePasskeyEntries(): Promise<void> {2050 if (!derivedKey) return;2051 const { merged } = mergePasskeyOnlyIntoLogins(entries);2052 if (merged) {2053 await persistEntries();2054 }2055}20562057export async function updateEntry(2058 id: string,2059 patch: Partial<VaultEntry>2060): Promise<void> {2061 if (!derivedKey) throw new Error("vault_locked");20622063 const idx = entries.findIndex((entry) => entry.id === id);2064 if (idx === -1 || entries[idx].deletedAt) {2065 throw new Error("entry_not_found");2066 }20672068 const current = entries[idx];2069 const sanitizedPatch = sanitizeEntryPatch(patch);2070 2071 2072 2073 const passwordChanged =2074 "password" in patch &&2075 typeof sanitizedPatch.password === "string" &&2076 sanitizedPatch.password !== current.password;2077 const stamp = monotonicEntryTimestamp(current.modifiedAt);2078 2079 2080 2081 2082 2083 2084 2085 const priorPassword = current.password;2086 const shouldCapture =2087 passwordChanged &&2088 typeof priorPassword === "string" &&2089 priorPassword.length > 0 &&2090 current.passwordHistory?.[0]?.password !== priorPassword;2091 const nextHistory = shouldCapture2092 ? [2093 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2094 ...(current.passwordHistory ?? []),2095 ].slice(0, PASSWORD_HISTORY_MAX)2096 : current.passwordHistory;2097 entries[idx] = {2098 ...current,2099 ...sanitizedPatch,2100 modifiedAt: stamp,2101 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2102 };2103210421052106 2107 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2108 entries[idx] = current;2109 throw new Error("card number is required for card entries");2110 }21112112 2113 2114 2115 2116 try {2117 await persistEntries();2118 } catch (err) {2119 entries[idx] = current;2120 throw err;2121 }21222123 2124 2125 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2126 entries[idx] = { ...entries[idx], breach: undefined };2127 await persistEntries();2128 const password = entries[idx].password;2129 if (password) {2130 checkBreach(password).then(async (count) => {2131 const i = entries.findIndex((e) => e.id === id);2132 if (i !== -1 && count > 0) {2133 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2134 await persistEntries();2135 }2136 });2137 }2138 }21392140 fireSyncTrigger("vault_change");2141}21422143export async function deleteEntry(id: string): Promise<void> {2144 if (!derivedKey) throw new Error("vault_locked");21452146 const entry = entries.find((item) => item.id === id);2147 if (!entry || entry.deletedAt) {2148 throw new Error("entry_not_found");2149 }21502151 2152 2153 2154 const previousModifiedAt = entry.modifiedAt;2155 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2156 entry.deletedAt = stamp;2157 entry.modifiedAt = stamp;2158 2159 2160 try {2161 await persistEntries();2162 } catch (err) {2163 delete entry.deletedAt;2164 entry.modifiedAt = previousModifiedAt;2165 throw err;2166 }2167 fireSyncTrigger("vault_change");2168}21692170export function getDeletedEntries(): VaultEntry[] {2171 if (!derivedKey) return [];2172 return entries.filter((e) => !!e.deletedAt);2173}2174217521762177217821792180export async function getVaultVersion(): Promise<number> {2181 try {2182 const raw = await chrome.storage.local.get("peach_last_modified_at");2183 const v = (raw as Record<string, unknown>).peach_last_modified_at;2184 if (typeof v === "number") return v;2185 if (typeof v === "string" && v !== "") {2186 const n = Number.parseInt(v, 10);2187 return Number.isFinite(n) ? n : 0;2188 }2189 return 0;2190 } catch {2191 return 0;2192 }2193}219421952196219721982199220022012202220322042205export async function addEntryDirect(entry: VaultEntry): Promise<void> {2206 if (!derivedKey) throw new Error("vault_locked");2207 entries = [...entries, entry];2208 await persistEntries("sync_received");2209}22102211export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2212 if (!derivedKey) throw new Error("vault_locked");2213 const idx = entries.findIndex((e) => e.id === patch.id);2214 if (idx === -1) throw new Error("entry_not_found");2215 entries[idx] = { ...entries[idx], ...patch };2216 await persistEntries("sync_received");2217}22182219export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2220 if (!derivedKey) throw new Error("vault_locked");2221 const idx = entries.findIndex((e) => e.id === id);2222 if (idx === -1) throw new Error("entry_not_found");2223 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2224 await persistEntries("sync_received");2225}2226222722282229223022312232223322342235223622372238export async function applyMergedRemoteEntries(2239 remoteEntries: VaultEntry[],2240 remoteTombstones: Record<string, number>,2241 _context: { deviceName?: string } = {},2242): Promise<{ received: number; applied: number; conflicts: number }> {2243 2244 2245 2246 2247 2248 2249 if (!derivedKey) throw new Error("vault_locked");22502251 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2252 let applied = 0;2253 let conflicts = 0;22542255 const byId = new Map<string, VaultEntry>();2256 for (const e of entries) byId.set(e.id, e);22572258 const mergeNow = Date.now();22592260 for (const remote of remoteEntries) {2261 const sanitized = sanitizeRestoredEntry(remote);2262 if (!sanitized) continue;2263 2264 2265 2266 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2267 if (typeof sanitized.deletedAt === "number") {2268 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2269 }2270 const tombstoneTs = remoteTombstones[sanitized.id];2271 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2272 continue;2273 }2274 const local = byId.get(sanitized.id);2275 if (!local) {2276 entries.push(sanitized);2277 byId.set(sanitized.id, sanitized);2278 applied += 1;2279 } else if (sanitized.modifiedAt > local.modifiedAt) {2280 const idx = entries.findIndex((e) => e.id === local.id);2281 if (idx !== -1) {2282 entries[idx] = sanitized;2283 byId.set(sanitized.id, sanitized);2284 applied += 1;2285 if (!local.deletedAt) conflicts += 1;2286 }2287 }2288 }22892290 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2291 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2292 2293 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2294 const local = byId.get(id);2295 if (!local) continue;2296 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2297 if (deletedAt < local.modifiedAt) continue;2298 const idx = entries.findIndex((e) => e.id === id);2299 if (idx !== -1) {2300 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2301 applied += 1;2302 }2303 }23042305 if (applied > 0) {2306 await persistEntries("sync_received");2307 buildDomainIndexSync(entries);2308 }2309 return { received, applied, conflicts };2310}23112312export async function restoreEntry(id: string): Promise<void> {2313 if (!derivedKey) throw new Error("vault_locked");2314 const entry = entries.find((e) => e.id === id && e.deletedAt);2315 if (!entry) throw new Error("deleted entry not found");2316 delete entry.deletedAt;2317 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2318 await persistEntries();2319 buildDomainIndexSync(entries);2320}23212322export async function purgeEntry(id: string): Promise<void> {2323 if (!derivedKey) throw new Error("vault_locked");2324 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2325 if (idx === -1) throw new Error("deleted entry not found");2326 entries.splice(idx, 1);2327 await persistEntries();2328}232923302331export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2332 if (!derivedKey || retentionMs === 0) return 0;2333 const cutoff = Date.now() - retentionMs;2334 const before = entries.length;2335 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2336 const purged = before - entries.length;2337 if (purged > 0) await persistEntries();2338 return purged;2339}23402341export function queryDomain(domain: string): VaultEntry[] {2342 return domainIndex.get(domain) ?? [];2343}23442345export function getEntryById(id: string): VaultEntry | undefined {2346 return entries.find((entry) => entry.id === id && !entry.deletedAt);2347}23482349async function persistSlotA(2350 reason: VaultSnapshot["reason"] = "local_save",2351): Promise<StoredVault> {2352 if (!derivedKey) throw new Error("vault_locked");23532354 const slotA = await loadSlot(SLOT_A);2355 if (!slotA) throw new Error("missing slot A");23562357 2358 2359 await takeVaultSnapshot(reason);23602361 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2362 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");23632364 2365 const nextSlotA: StoredVault = {2366 ...slotA,2367 nonce,2368 ciphertext,2369 tag,2370 2371 };2372 await writeSlot(SLOT_A, nextSlotA);23732374 2375 2376 2377 2378 2379 2380 try {2381 2382 2383 2384 if (!duressConfigured) {2385 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2386 }2387 2388 2389 2390 2391 2392 if (reason !== "sync_received") {2393 await stampLocalVaultMutation();2394 }2395 } catch (err) {2396 2397 2398 await writeSlot(SLOT_A, slotA).catch(() => {});2399 throw err;2400 }24012402 return nextSlotA;2403}24042405async function persistSlotB(): Promise<void> {2406 if (!derivedKey) throw new Error("vault_locked");24072408 const slotB = await loadSlot(SLOT_B);2409 if (!slotB) throw new Error("missing slot B");24102411 2412 2413 2414 2415 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2416 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");24172418 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2419 2420 2421 2422 try {2423 await stampLocalVaultMutation();2424 } catch (err) {2425 await writeSlot(SLOT_B, slotB).catch(() => {});2426 throw err;2427 }2428}24292430async function persistEntries(2431 reason: VaultSnapshot["reason"] = "local_save",2432): Promise<void> {2433 if (!derivedKey) throw new Error("vault_locked");24342435 if (vaultMode === "decoy") {2436 await persistSlotB();2437 } else {2438 await persistSlotA(reason);2439 }2440 buildDomainIndexSync(entries);2441}24422443function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2444 if (!Array.isArray(values)) return undefined;2445 const seen = new Set<string>();2446 const cleaned: string[] = [];2447 for (const value of values) {2448 const trimmed = typeof value === "string" ? value.trim() : "";2449 if (!trimmed) continue;2450 const key = trimmed.toLowerCase();2451 if (seen.has(key)) continue;2452 seen.add(key);2453 cleaned.push(trimmed);2454 }2455 return cleaned.length > 0 ? cleaned : undefined;2456}24572458function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2459 const title = entry.title.trim();2460 2461 2462 2463 let type: VaultEntry["type"] = entry.type ?? "login";2464 if (type === "passkey") type = "login";24652466 if (!title) throw new Error("title is required");24672468 if (entry.passkey) {2469 const p = entry.passkey;2470 if (2471 !p.rpId ||2472 !p.credentialId ||2473 !p.publicKeyCose ||2474 !p.userHandle ||2475 !p.userName ||2476 (p.algorithm !== -7 && p.algorithm !== -8)2477 ) {2478 throw new Error("invalid passkey credential");2479 }2480 }2481 if (type === "card") {2482 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2483 throw new Error("cardholder name and card number are required");2484 }2485 }24862487 return {2488 type,2489 title,2490 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2491 email: type === "login" ? entry.email?.trim() || undefined : undefined,2492 password: type === "login" ? entry.password : undefined,2493 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2494 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2495 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2496 2497 2498 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2499 totpDigits: type === "login" ? entry.totpDigits : undefined,2500 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2501 notes: entry.notes?.trim() || undefined,2502 breach: type === "login" ? entry.breach : undefined,2503 2504 2505 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2506 tags: sanitizeTags(entry.tags),2507 passkey: entry.passkey,2508 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2509 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2510 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2511 expYear: type === "card" ? entry.expYear?.trim() : undefined,2512 cvc: type === "card" ? entry.cvc?.trim() : undefined,2513 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2514 network: type === "card" ? entry.network : undefined,2515 last4: type === "card" ? entry.last4?.trim() : undefined,2516 };2517}25182519function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2520 const nextPatch: Partial<VaultEntry> = {};25212522 if ("type" in patch) {2523 nextPatch.type = patch.type;2524 }25252526 if ("title" in patch) {2527 const title = patch.title?.trim() ?? "";2528 if (!title) throw new Error("title is required");2529 nextPatch.title = title;2530 }25312532 if ("url" in patch) {2533 nextPatch.url = patch.url?.trim() ?? "";2534 }25352536 if ("additionalUrls" in patch) {2537 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2538 const seen = new Set<string>();2539 const cleaned: string[] = [];2540 for (const value of incoming) {2541 const trimmed = typeof value === "string" ? value.trim() : "";2542 if (!trimmed) continue;2543 const key = trimmed.toLowerCase();2544 if (seen.has(key)) continue;2545 seen.add(key);2546 cleaned.push(trimmed);2547 }2548 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2549 }25502551 if ("username" in patch) {2552 nextPatch.username = patch.username?.trim() ?? "";2553 }25542555 if ("email" in patch) {2556 nextPatch.email = patch.email?.trim() || undefined;2557 }25582559 if ("password" in patch) {2560 const password = patch.password ?? "";2561 2562 nextPatch.password = password || undefined;2563 }25642565 if ("totp" in patch) {2566 nextPatch.totp = patch.totp?.trim() || undefined;2567 }25682569 if ("notes" in patch) {2570 nextPatch.notes = patch.notes?.trim() || undefined;2571 }25722573 if ("breach" in patch) {2574 nextPatch.breach = patch.breach;2575 }25762577 if ("canary" in patch) {2578 2579 2580 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2581 }25822583 if ("passkey" in patch) {2584 2585 2586 2587 if (patch.passkey === undefined || patch.passkey === null) {2588 nextPatch.passkey = undefined;2589 } else {2590 const sanitized = sanitizePasskey(patch.passkey);2591 if (!sanitized) throw new Error("invalid passkey credential");2592 nextPatch.passkey = sanitized;2593 }2594 }25952596 if ("tags" in patch) {2597 2598 nextPatch.tags = sanitizeTags(patch.tags);2599 }26002601 if ("cardholderName" in patch) {2602 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2603 }2604 if ("cardNumber" in patch) {2605 const n = patch.cardNumber ?? "";2606 nextPatch.cardNumber = n || undefined;2607 }2608 if ("expMonth" in patch) {2609 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2610 }2611 if ("expYear" in patch) {2612 nextPatch.expYear = patch.expYear?.trim() || undefined;2613 }2614 if ("cvc" in patch) {2615 nextPatch.cvc = patch.cvc?.trim() || undefined;2616 }2617 if ("cardNickname" in patch) {2618 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2619 }2620 if ("network" in patch) {2621 nextPatch.network = patch.network;2622 }2623 if ("last4" in patch) {2624 nextPatch.last4 = patch.last4?.trim() || undefined;2625 }26262627 return nextPatch;2628}26292630function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2631 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2632 const modifiedAt =2633 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;26342635 const base: VaultEntry = {2636 id: entry.id,2637 type: "type" in entry ? entry.type : "login",2638 title: entry.title,2639 username: entry.username,2640 email: "email" in entry ? entry.email : undefined,2641 password: entry.password,2642 url: entry.url,2643 additionalUrls:2644 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2645 ? entry.additionalUrls2646 : undefined,2647 totp: entry.totp,2648 2649 totpAlgorithm: entry.totpAlgorithm,2650 totpDigits: entry.totpDigits,2651 totpPeriod: entry.totpPeriod,2652 notes: entry.notes,2653 breach: entry.breach,2654 2655 2656 2657 passwordChangedAt:2658 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2659 2660 2661 2662 passwordHistory:2663 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2664 ? entry.passwordHistory2665 : undefined,2666 canary: "canary" in entry ? entry.canary : undefined,2667 passkey: "passkey" in entry ? entry.passkey : undefined,2668 createdAt,2669 modifiedAt,2670 deletedAt: entry.deletedAt,2671 tags: "tags" in entry ? entry.tags : undefined,2672 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2673 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2674 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2675 expYear: "expYear" in entry ? entry.expYear : undefined,2676 cvc: "cvc" in entry ? entry.cvc : undefined,2677 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2678 network: "network" in entry ? entry.network : undefined,2679 last4: "last4" in entry ? entry.last4 : undefined,2680 };26812682 return migrateLegacyPasskey(base, entry);2683}268426852686268726882689269026912692function migrateLegacyPasskey(2693 base: VaultEntry,2694 raw: VaultEntry | LegacyVaultEntry2695): VaultEntry {2696 if (base.passkey) return base;2697 const flat = raw as Partial<VaultEntry>;2698 const isPasskeyType = base.type === "passkey";2699 const hasFlatPasskeyFields =2700 typeof flat.rpId === "string" &&2701 typeof flat.credentialId === "string" &&2702 typeof flat.publicKeyCose === "string" &&2703 typeof flat.userHandle === "string" &&2704 typeof flat.userName === "string";2705 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2706 if (!hasFlatPasskeyFields) return base;27072708 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2709 base.passkey = {2710 rpId: flat.rpId!,2711 rpName: flat.rpName ?? flat.rpId!,2712 credentialId: flat.credentialId!,2713 publicKeyCose: flat.publicKeyCose!,2714 privateKeyJwk: flat.privateKeyJwk,2715 userHandle: flat.userHandle!,2716 userName: flat.userName!,2717 userDisplayName: flat.userDisplayName ?? flat.userName!,2718 algorithm,2719 signCount: flat.signCount ?? 0,2720 aaguid: flat.aaguid ?? "",2721 backupEligible: flat.backupEligible === true,2722 backupState: flat.backupState === true,2723 lastUsedAt: flat.lastUsedAt,2724 browser: flat.browser,2725 transports: flat.transports,2726 createdAt: base.createdAt,2727 };2728 2729 2730 2731 if (isPasskeyType) base.type = "login";2732 if (!base.username) base.username = flat.userName;2733 if (!base.url) base.url = `https://${flat.rpId}`;2734 return base;2735}2736273727382739274027412742function entryDupeKey(e: VaultEntry): string {2743 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2744 2745 2746 2747 2748 if (e.passkey) {2749 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2750 }2751 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2752}275327542755export function countDuplicates(): number {2756 const best = new Map<string, VaultEntry>();2757 let dupeCount = 0;2758 for (const e of entries.filter((e) => !e.deletedAt)) {2759 const key = entryDupeKey(e);2760 const existing = best.get(key);2761 if (!existing) {2762 best.set(key, e);2763 } else {2764 dupeCount++;2765 2766 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2767 best.set(key, e);2768 }2769 }2770 }2771 return dupeCount;2772}277327742775277627772778export async function deduplicateEntries(): Promise<number> {2779 if (!derivedKey) throw new Error("vault_locked");2780 2781 2782 if (vaultMode === "decoy") {2783 throw new Error("deduplicate disabled in decoy mode");2784 }27852786 const best = new Map<string, VaultEntry>();2787 for (const e of entries.filter((en) => !en.deletedAt)) {2788 const key = entryDupeKey(e);2789 const existing = best.get(key);2790 if (!existing) {2791 best.set(key, e);2792 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2793 best.set(key, e);2794 }2795 }27962797 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2798 let removed = 0;2799 entries = entries.map((e) => {2800 if (!e.deletedAt && !keepIds.has(e.id)) {2801 removed++;2802 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2803 }2804 return e;2805 });28062807 if (removed > 0) await persistEntries();2808 return removed;2809}281028112812281328142815281628172818const HISTORY_KEY = "peach_vault_history";2819const HISTORY_MAX = 5;282028212822282328242825function bucketEntryCount(n: number): number {2826 if (n <= 0) return 0;2827 if (n <= 10) return 10;2828 if (n <= 25) return 25;2829 if (n <= 50) return 50;2830 if (n <= 100) return 100;2831 if (n <= 250) return 250;2832 if (n <= 500) return 500;2833 return Math.ceil(n / 500) * 500;2834}28352836283728382839284028412842export async function takeVaultSnapshot(2843 reason: VaultSnapshot["reason"],2844): Promise<void> {2845 try {2846 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2847 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2848 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];28492850 2851 2852 2853 if (!slotA) return;28542855 const vault_a = JSON.stringify(slotA);2856 const vault_b = JSON.stringify(slotB ?? slotA);28572858 2859 2860 2861 2862 2863 2864 let entryCount: number | null = null;2865 if (derivedKey && vaultMode === "real") {2866 try {2867 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2868 } catch {2869 entryCount = null;2870 }2871 }28722873 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2874 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2875 ? existingRaw2876 : { snapshots: [] };28772878 2879 2880 2881 const latest = history.snapshots[0];2882 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2883 return;2884 }28852886 const snapshot: VaultSnapshot = {2887 id: crypto.randomUUID(),2888 savedAt: Date.now(),2889 reason,2890 vault_a,2891 vault_b,2892 entryCount,2893 };28942895 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2896 await chrome.storage.local.set({ [HISTORY_KEY]: history });2897 } catch (err) {2898 2899 2900 2901 2902 console.warn('[Peach] takeVaultSnapshot failed:', err);2903 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2904 try {2905 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2906 } catch {2907 2908 }2909 }2910 }2911}291229132914export async function clearVaultHistory(): Promise<void> {2915 try {2916 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2917 } catch {2918 2919 }2920}292129222923export async function getVaultHistory(): Promise<VaultSnapshot[]> {2924 const raw = await chrome.storage.local.get(HISTORY_KEY);2925 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2926 if (!history || !Array.isArray(history.snapshots)) return [];2927 return history.snapshots;2928}29292930293129322933293429352936export async function restoreVaultSnapshot(2937 snapshotId: string,2938): Promise<{ ok: true } | { ok: false; error: string }> {2939 const history = await getVaultHistory();2940 const snapshot = history.find((s) => s.id === snapshotId);2941 if (!snapshot) return { ok: false, error: "snapshot_not_found" };29422943 2944 2945 2946 await takeVaultSnapshot("restore");29472948 let restoredA: StoredVault;2949 let restoredB: StoredVault;2950 try {2951 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2952 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2953 } catch {2954 return { ok: false, error: "snapshot_corrupt" };2955 }29562957 await writeSlot(SLOT_A, restoredA);2958 await writeSlot(SLOT_B, restoredB);2959 await stampLocalVaultMutation();29602961 2962 2963 2964 lock();29652966 return { ok: true };2967}296829692970297129722973297429752976297729782979298029812982export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2983 if (!derivedKey || vaultMode !== "real") {2984 return { ok: false, error: "vault_locked" };2985 }2986 const slotA = await loadSlot(SLOT_A);2987 if (!slotA) return { ok: false, error: "missing_slot_a" };29882989 2990 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2991 await generateAndWrapRootKey();29922993 2994 const newRootKey = unhex(rawRootKey);2995 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);29962997 2998 2999 3000 3001 await takeVaultSnapshot("local_save");3002 const nextSlotA: StoredVault = {3003 ...slotA,3004 recovery_salt: recoverySalt,3005 recovery_wrapped_root_key: wrappedRootKey,3006 recovery_nonce: wrappedNonce,3007 recovery_tag: wrappedTag,3008 ...pwRoot,3009 };3010 await writeSlot(SLOT_A, nextSlotA);30113012 3013 if (rootKey) zeroKey(rootKey);3014 rootKey = newRootKey;30153016 3017 await clearPairedDevices();30183019 3020 await setRecoveryConfirmPending(); 30213022 await stampLocalVaultMutation();3023 return { ok: true };3024}30253026302730283029303030313032export async function changePassword(3033 currentPassword: string,3034 newPassword: string3035): Promise<boolean> {3036 if (!derivedKey || vaultMode !== "real") return false;30373038 3039 const valid = await verifyMainPassword(currentPassword);3040 if (!valid) return false;30413042 const slotA = await loadSlot(SLOT_A);3043 if (!slotA) return false;3044 if (!rootKey) return false;30453046 3047 const newSalt = generateSalt();3048 const newKey = await deriveKey(newPassword, newSalt);30493050 3051 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3052 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");30533054 3055 3056 3057 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);30583059 const finalSlotA: StoredVault = {3060 ...slotA,3061 salt: hex(newSalt),3062 nonce,3063 ciphertext,3064 tag,3065 ...pwRoot,3066 };30673068 3069 3070 3071 await takeVaultSnapshot("local_save");30723073 3074 3075 3076 3077 await writeSlot(SLOT_A, finalSlotA);30783079 3080 3081 3082 zeroKey(derivedKey);3083 derivedKey = newKey;30843085 3086 3087 3088 3089 void saveSessionKey(newKey, "real");30903091 3092 3093 3094 if (!duressConfigured) {3095 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3096 }3097 await stampLocalVaultMutation();30983099 return true;3100}