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 loginTarget.modifiedAt = now;924 for (const dup of group) {925 if (!dup.deletedAt) {926 dup.deletedAt = now;927 dup.modifiedAt = now;928 }929 }930 merged = true;931 } else if (group.length > 1) {932 for (let i = 1; i < group.length; i += 1) {933 const dup = group[i]!;934 if (!dup.deletedAt) {935 dup.deletedAt = now;936 dup.modifiedAt = now;937 }938 }939 merged = true;940 }941 }942943 return { merged };944}945946947948949950951952function hasValidVaultSchema(v: unknown): v is StoredVault {953 if (typeof v !== "object" || v === null) return false;954 const r = v as Record<string, unknown>;955 return (956 r.version === 1 &&957 typeof r.salt === "string" &&958 typeof r.nonce === "string" &&959 typeof r.ciphertext === "string" &&960 typeof r.tag === "string"961 );962}963964export type TryDecryptResult =965 | { status: "ok"; payload: DecryptedPayload }966 | { status: "wrong_key" }967 | { status: "corrupted"; reason: string };968969970971972973974export async function tryDecryptSlot(975 vault: StoredVault,976 key: Uint8Array,977 aadTag?: string,978): Promise<DecryptedPayload | null> {979 const res = await tryDecryptSlotResult(vault, key, aadTag);980 return res.status === "ok" ? res.payload : null;981}982983export async function tryDecryptSlotResult(984 vault: StoredVault,985 key: Uint8Array,986 aadTag?: string,987): Promise<TryDecryptResult> {988 if (!hasValidVaultSchema(vault)) {989 return { status: "corrupted", reason: "invalid_slot_schema" };990 }991 let plain: string;992 try {993 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);994 } catch {995 return { status: "wrong_key" };996 }997 try {998 const parsed = JSON.parse(plain) as unknown;999 if (typeof parsed !== "object" || parsed === null) {1000 return { status: "corrupted", reason: "invalid_payload_shape" };1001 }1002 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1003 return { status: "corrupted", reason: "invalid_payload_shape" };1004 }1005 return { status: "ok", payload: decodePayload(plain) };1006 } catch {1007 return { status: "corrupted", reason: "invalid_payload_json" };1008 }1009}101010111012101310141015function buildDomainIndexSync(all: VaultEntry[]): void {1016 domainIndex = new Map();10171018 for (const entry of all) {1019 if (entry.deletedAt) continue;1020 if (entry.type === "note" || entry.type === "card") continue;10211022 1023 1024 1025 1026 const domains = new Set<string>();1027 const primary = extractDomain(entry.url ?? "");1028 if (primary) domains.add(primary);1029 for (const extra of entry.additionalUrls ?? []) {1030 const d = extractDomain(extra);1031 if (d) domains.add(d);1032 }1033 if (domains.size === 0) continue;10341035 for (const domain of domains) {1036 const bucket = domainIndex.get(domain) ?? [];1037 bucket.push(entry);1038 domainIndex.set(domain, bucket);1039 }1040 }1041}10421043export async function createVault(password: string): Promise<void> {1044 await ensureMigrated();10451046 const salt = generateSalt();1047 derivedKey = await deriveKey(password, salt);1048 entries = [];1049 vaultMode = "real";1050 duressConfigured = false;1051 buildDomainIndexSync(entries);10521053 1054 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1055 await generateAndWrapRootKey();1056 rootKey = unhex(rawRootKey);10571058 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1059 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10601061 1062 1063 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10641065 const slotA: StoredVault = {1066 version: 1,1067 salt: hex(salt),1068 nonce,1069 ciphertext,1070 tag,1071 recovery_salt: recoverySalt,1072 recovery_wrapped_root_key: wrappedRootKey,1073 recovery_nonce: wrappedNonce,1074 recovery_tag: wrappedTag,1075 ...pwRoot,1076 };1077 await writeSlot(SLOT_A, slotA);10781079 1080 1081 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1082 await stampLocalVaultMutation();10831084 touchAutoLock();1085}1086108710881089109010911092export async function createVaultFromRootKey(1093 password: string,1094 rootKeyHex: string,1095): Promise<{ vaultId: string }> {1096 if (typeof password !== "string" || password.length === 0) {1097 throw new Error("ADOPT_INVALID_PASSWORD");1098 }1099 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1100 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1101 }11021103 const rootKeyBytes = unhex(rootKeyHex);1104 if (rootKeyBytes.length !== 32) {1105 rootKeyBytes.fill(0);1106 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1107 }11081109 let nextDerivedKey: Uint8Array | null = null;1110 let sessionRootKey: Uint8Array | null = null;1111 try {1112 await ensureMigrated();1113 if ((await loadSlot(SLOT_A)) !== null) {1114 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1115 }11161117 const nextEntries: VaultEntry[] = [];1118 const nextDuressConfigured = false;1119 const vaultId = await computeVaultId(rootKeyBytes);1120 const salt = generateSalt();1121 nextDerivedKey = await deriveKey(password, salt);1122 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11231124 sessionRootKey = new Uint8Array(rootKeyBytes);1125 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1126 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1127 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1128 const slotA: StoredVault = {1129 version: 1,1130 salt: hex(salt),1131 nonce,1132 ciphertext,1133 tag,1134 recovery_salt: recovery.recoverySalt,1135 recovery_wrapped_root_key: recovery.wrappedRootKey,1136 recovery_nonce: recovery.wrappedNonce,1137 recovery_tag: recovery.wrappedTag,1138 ...pwRoot,1139 };1140 const slotB = generateNoiseBlob(ciphertext.length / 2);1141 await commitCreatedVaultSlots(slotA, slotB);11421143 const previousDerivedKey = derivedKey;1144 const previousRootKey = rootKey;1145 derivedKey = nextDerivedKey;1146 rootKey = sessionRootKey;1147 nextDerivedKey = null;1148 sessionRootKey = null;1149 entries = nextEntries;1150 vaultMode = "real";1151 duressConfigured = nextDuressConfigured;1152 buildDomainIndexSync(entries);1153 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1154 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1155 touchAutoLock();11561157 return { vaultId };1158 } finally {1159 rootKeyBytes.fill(0);1160 if (nextDerivedKey) zeroKey(nextDerivedKey);1161 if (sessionRootKey) sessionRootKey.fill(0);1162 }1163}11641165export async function hasVault(): Promise<boolean> {1166 await ensureMigrated();1167 return (await loadSlot(SLOT_A)) !== null;1168}1169117011711172117311741175export async function isDuressEnabled(): Promise<boolean> {1176 return vaultMode === "real" && duressConfigured;1177}11781179export async function setupDuress(duressPassword: string): Promise<void> {1180 if (!derivedKey || vaultMode !== "real") {1181 throw new Error("vault must be unlocked in real mode");1182 }1183 await ensureMigrated();11841185 1186 1187 1188 const slotA = await loadSlot(SLOT_A);1189 if (!slotA) throw new Error("missing slot A");11901191 const decoySalt = generateSalt();1192 const decoyKey = await deriveKey(duressPassword, decoySalt);1193 try {1194 1195 1196 1197 1198 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1199 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1200 await writeSlot(SLOT_B, {1201 version: 1,1202 salt: hex(decoySalt),1203 nonce,1204 ciphertext,1205 tag,1206 });1207 } finally {1208 zeroKey(decoyKey);1209 }12101211 1212 1213 duressConfigured = true;1214 await persistSlotA();1215}12161217export async function disableDuress(): Promise<void> {1218 if (!derivedKey || vaultMode !== "real") {1219 throw new Error("vault must be unlocked in real mode");1220 }1221 await ensureMigrated();12221223 1224 1225 1226 const slotA = await loadSlot(SLOT_A);1227 if (slotA) {1228 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1229 }12301231 duressConfigured = false;1232 await persistSlotA();1233}12341235123612371238123912401241export async function verifyMainPassword(password: string): Promise<boolean> {1242 await ensureMigrated();1243 const slotA = await loadSlot(SLOT_A);1244 if (!slotA) return false;1245 const key = await deriveKey(password, unhex(slotA.salt));1246 try {1247 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1248 } finally {1249 zeroKey(key);1250 }1251}12521253export async function unlock(password: string): Promise<boolean> {1254 1255 1256 1257 if (await attemptUnlockWithDerive(password, deriveKey)) return true;12581259 const nfc = password.normalize("NFC");1260 if (password === nfc) return false; 12611262 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1263 1264 1265 1266 if (vaultMode === "real") {1267 try {1268 const newSalt = generateSalt();1269 const newKey = await deriveKey(password, newSalt);1270 await changePasswordWithNewKey(newSalt, newKey);1271 } catch (healErr) {1272 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1273 }1274 }1275 return true;1276 }1277 return false;1278}12791280async function attemptUnlockWithDerive(1281 password: string,1282 derive: typeof deriveKey,1283): Promise<boolean> {1284 await ensureMigrated();12851286 1287 1288 1289 1290 1291 const [slotA, slotB] = await Promise.all([1292 loadSlot(SLOT_A),1293 loadSlot(SLOT_B),1294 ]);12951296 1297 const [keyA, keyB] = await Promise.all([1298 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1299 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1300 ]);13011302 1303 1304 1305 const [resultA, resultB] = await Promise.all([1306 keyA && slotA1307 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1308 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1309 keyB && slotB1310 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1311 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1312 ]);1313 const payloadA = resultA.status === "ok" ? resultA.payload : null;1314 const payloadB = resultB.status === "ok" ? resultB.payload : null;13151316 if (payloadA && keyA) {1317 derivedKey = keyA;1318 void saveSessionKey(keyA, "real");1319 vaultMode = "real";1320 entries = payloadA.entries;1321 duressConfigured = payloadA.duressConfigured;1322 buildDomainIndexSync(entries);1323 await loadSettings();1324 touchAutoLock();1325 if (keyB) zeroKey(keyB);13261327 1328 if (slotA) {1329 const needsRecoverySetup =1330 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1331 if (!needsRecoverySetup) {1332 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1333 }1334 1335 1336 1337 1338 if (needsRecoverySetup || !rootKey) {1339 try {1340 await migrateToRecovery();1341 if (needsRecoverySetup) {1342 1343 1344 1345 await chrome.storage.local.set({ peach_recovery_pending: '1' });1346 }1347 } catch (migrationErr) {1348 console.warn('[Peach] recovery migration failed:', migrationErr);1349 }1350 }1351 }13521353 1354 1355 1356 if (payloadA.migratedFromLegacy) {1357 await persistSlotA();1358 }1359 return true;1360 }13611362 if (payloadB && keyB) {1363 derivedKey = keyB;1364 void saveSessionKey(keyB, "decoy");1365 vaultMode = "decoy";1366 entries = payloadB.entries;1367 duressConfigured = false;1368 buildDomainIndexSync(entries);1369 await loadSettings();1370 touchAutoLock();1371 if (keyA) zeroKey(keyA);1372 return true;1373 }13741375 if (keyA) zeroKey(keyA);1376 if (keyB) zeroKey(keyB);1377 1378 1379 if (resultA.status === "corrupted") {1380 lastUnlockFailureReason = resultA.reason;1381 } else if (resultB.status === "corrupted") {1382 lastUnlockFailureReason = resultB.reason;1383 }1384 return false;1385}13861387138813891390139113921393const SESSION_KEY_STORAGE = "peach_session_dk";13941395async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1396 if (!chrome.storage.session) return;1397 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1398}13991400async function clearSessionKey(): Promise<void> {1401 if (!chrome.storage.session) return;1402 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1403}14041405140614071408140914101411export async function restoreFromSession(): Promise<boolean> {1412 if (derivedKey !== null) return true;1413 if (!chrome.storage.session) return false;1414 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1415 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1416 if (!entry?.keyHex) return false;14171418 const key = unhex(entry.keyHex);1419 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1420 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1421 const slot = await loadSlot(slotKey);1422 if (!slot) { await clearSessionKey(); return false; }14231424 const result = await tryDecryptSlotResult(slot, key, aadTag);1425 if (result.status !== "ok") { await clearSessionKey(); return false; }14261427 derivedKey = key;1428 vaultMode = entry.mode;1429 entries = result.payload.entries;1430 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1431 buildDomainIndexSync(entries);1432 await loadSettings();1433 touchAutoLock();14341435 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1436 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1437 }14381439 return true;1440}14411442export function lock(): void {1443 void clearSessionKey();1444 if (derivedKey) {1445 zeroKey(derivedKey);1446 derivedKey = null;1447 }1448 if (rootKey) {1449 rootKey.fill(0);1450 rootKey = null;1451 }14521453 entries = [];1454 domainIndex = new Map();1455 vaultMode = "real";1456 duressConfigured = false;1457 clearAutoLock();1458}14591460export function isUnlocked(): boolean {1461 return derivedKey !== null;1462}14631464146514661467export async function unlockWithKey(1468 masterKeyHex: string,1469): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1470 try {1471 const masterKeyBytes = unhex(masterKeyHex);1472 const stored = await loadSlot(SLOT_A);1473 if (!stored) return { ok: false, error: "invalid_key" };1474 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1475 if (!payload) return { ok: false, error: "invalid_key" };14761477 derivedKey = masterKeyBytes;1478 vaultMode = "real";1479 entries = payload.entries;1480 duressConfigured = payload.duressConfigured;1481 buildDomainIndexSync(entries);1482 await loadSettings();1483 touchAutoLock();14841485 1486 1487 1488 1489 1490 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1491 try {1492 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1493 } catch { }1494 }1495 return { ok: true, entries };1496 } catch {1497 return { ok: false, error: "invalid_key" };1498 }1499}150015011502150315041505150615071508export async function exportDuressSlotForAdoption(): Promise<string | null> {1509 if (!(await isDuressEnabled())) return null;1510 await ensureMigrated();1511 const slotB = await loadSlot(SLOT_B);1512 if (!slotB) return null;1513 return JSON.stringify(slotB);1514}15151516151715181519152015211522export async function installAdoptedDuressSlot(json: string): Promise<void> {1523 let parsed: StoredVault;1524 try {1525 const raw = JSON.parse(json) as unknown;1526 if (!hasValidVaultSchema(raw)) {1527 throw new Error("invalid_duress_slot_shape");1528 }1529 parsed = raw;1530 } catch (err) {1531 if (err instanceof SyntaxError) {1532 throw new Error("invalid_duress_slot_shape");1533 }1534 throw err;1535 }1536 await writeSlot(SLOT_B, parsed);1537}15381539154015411542154315441545154615471548154915501551155215531554155515561557export async function unlockFromCodex(params: {1558 slotA: string; 1559 slotB: string | null; 1560 timestamp: number;1561 masterPassphrase: string;1562}): Promise<1563 | { ok: true; entryCount: number }1564 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1565> {1566 let parsedSlotA: StoredVault;1567 let parsedSlotB: StoredVault | null = null;1568 try {1569 const rawA = JSON.parse(params.slotA) as unknown;1570 if (!hasValidVaultSchema(rawA)) {1571 return { ok: false, reason: "invalid_slot_format" };1572 }1573 parsedSlotA = rawA;1574 if (params.slotB !== null) {1575 const rawB = JSON.parse(params.slotB) as unknown;1576 if (!hasValidVaultSchema(rawB)) {1577 return { ok: false, reason: "invalid_slot_format" };1578 }1579 parsedSlotB = rawB;1580 }1581 } catch {1582 return { ok: false, reason: "invalid_slot_format" };1583 }15841585 1586 1587 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1588 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1589 if (!trialPayload) {1590 zeroKey(trialKey);1591 return { ok: false, reason: "wrong_passphrase" };1592 }1593 1594 1595 zeroKey(trialKey);15961597 1598 1599 await takeVaultSnapshot("restore");16001601 await writeSlot(SLOT_A, parsedSlotA);1602 if (parsedSlotB) {1603 await writeSlot(SLOT_B, parsedSlotB);1604 } else {1605 1606 1607 1608 await writeSlot(1609 SLOT_B,1610 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1611 );1612 }1613 await chrome.storage.local.set({1614 peach_last_modified_at: String(params.timestamp),1615 });16161617 1618 1619 const ok = await unlock(params.masterPassphrase);1620 if (!ok) {1621 1622 1623 1624 return { ok: false, reason: "wrong_passphrase" };1625 }1626 return { ok: true, entryCount: getEntries().length };1627}1628162916301631export function getDerivedKeyHex(): string {1632 if (!derivedKey) throw new Error("vault_not_unlocked");1633 return hex(derivedKey);1634}16351636163716381639164016411642164316441645164616471648164916501651export function getRootKeyHex(): string | null {1652 return rootKey ? hex(rootKey) : null;1653}16541655export function getEntries(): VaultEntry[] {1656 if (!derivedKey) throw new Error("vault_locked");1657 return entries.filter((entry) => !entry.deletedAt);1658}16591660const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;16611662export function getIdentities(): SavedIdentity[] {1663 if (!derivedKey) throw new Error("vault_locked");1664 const active = entries.filter((e) => !e.deletedAt && e.type === "login");16651666 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1667 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();16681669 for (const entry of active) {1670 const domain = extractDomain(entry.url ?? "") ?? "";1671 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;16721673 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1674 const existing = map.get(v);1675 if (!existing || modifiedAt > existing.lastUsed) {1676 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1677 } else {1678 map.set(v, { ...existing, count: existing.count + 1 });1679 }1680 }16811682 const uVal = entry.username?.trim();1683 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);16841685 1686 1687 const eVal = entry.email?.trim();1688 if (eVal) tally(emailMap, eVal);1689 }16901691 const toIdentity = (type: "email" | "username") =>1692 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1693 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1694 });16951696 return [1697 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1698 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1699 ];1700}17011702export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1703 if (!derivedKey) throw new Error("vault_locked");17041705 const now = Date.now();1706 const added: VaultEntry[] = [];1707 for (const draft of drafts) {1708 try {1709 const sanitized = sanitizeEntryDraft(draft);1710 const nextEntry: VaultEntry = {1711 ...sanitized,1712 id: crypto.randomUUID(),1713 createdAt: now,1714 modifiedAt: monotonicEntryTimestamp(),1715 };1716 entries.push(nextEntry);1717 added.push(nextEntry);1718 } catch {1719 1720 }1721 }17221723 if (added.length > 0) {1724 await persistEntries();1725 }17261727 return added;1728}172917301731173217331734173517361737export async function restoreEntries(1738 incoming: VaultEntry[],1739 mode: "replace" | "merge"1740): Promise<{ added: number; updated: number; skipped: number }> {1741 if (!derivedKey) throw new Error("vault_locked");17421743 1744 1745 1746 if (mode === "replace" && incoming.length === 0) {1747 throw new Error("refuse to replace with empty entry set");1748 }17491750 let added = 0;1751 let updated = 0;1752 let skipped = 0;17531754 if (mode === "replace") {1755 entries = [];1756 for (const entry of incoming) {1757 const normalized = sanitizeRestoredEntry(entry);1758 if (!normalized) {1759 skipped += 1;1760 continue;1761 }1762 entries.push(normalized);1763 added += 1;1764 }1765 } else {1766 const byId = new Map<string, VaultEntry>();1767 for (const e of entries) byId.set(e.id, e);17681769 for (const entry of incoming) {1770 const normalized = sanitizeRestoredEntry(entry);1771 if (!normalized) {1772 skipped += 1;1773 continue;1774 }1775 const existing = byId.get(normalized.id);1776 if (!existing) {1777 entries.push(normalized);1778 added += 1;1779 } else if (normalized.modifiedAt > existing.modifiedAt) {1780 const idx = entries.findIndex((e) => e.id === existing.id);1781 if (idx !== -1) {1782 entries[idx] = normalized;1783 updated += 1;1784 }1785 } else {1786 skipped += 1;1787 }1788 }1789 }17901791 await persistEntries("import");1792 return { added, updated, skipped };1793}179417951796179717981799180018011802function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1803 if (!entry || typeof entry !== "object") return null;1804 const title = typeof entry.title === "string" ? entry.title.trim() : "";1805 if (!title) return null;1806 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1807 const now = Date.now();1808 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1809 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;18101811 1812 1813 1814 1815 const normalized = normalizeEntry({1816 ...entry,1817 id,1818 title,1819 type: entry.type ?? "login",1820 createdAt,1821 modifiedAt,1822 });18231824 if (normalized.passkey) {1825 normalized.passkey = sanitizePasskey(normalized.passkey);1826 }1827 if (normalized.canary) {1828 normalized.canary = sanitizeCanary(normalized.canary);1829 }1830 1831 normalized.tags = sanitizeTags(normalized.tags);18321833 1834 1835 if (1836 normalized.type === "login" &&1837 !normalized.passkey &&1838 typeof normalized.password !== "string"1839 ) {1840 return null;1841 }1842 if (entry.passkey && !normalized.passkey) {1843 1844 1845 return null;1846 }18471848 return normalized;1849}1850185118521853185418551856function sanitizePasskey(pk: unknown): PasskeyData | undefined {1857 if (!pk || typeof pk !== "object") return undefined;1858 const r = pk as Record<string, unknown>;1859 if (1860 typeof r.rpId !== "string" || !r.rpId ||1861 typeof r.credentialId !== "string" || !r.credentialId ||1862 typeof r.publicKeyCose !== "string" ||1863 typeof r.userHandle !== "string" ||1864 typeof r.userName !== "string"1865 ) {1866 return undefined;1867 }1868 return {1869 rpId: r.rpId,1870 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1871 credentialId: r.credentialId,1872 publicKeyCose: r.publicKeyCose,1873 privateKeyJwk:1874 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1875 ? (r.privateKeyJwk as JsonWebKey)1876 : undefined,1877 userHandle: r.userHandle,1878 userName: r.userName,1879 userDisplayName:1880 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1881 algorithm: r.algorithm === -8 ? -8 : -7,1882 signCount: typeof r.signCount === "number" ? r.signCount : 0,1883 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1884 backupEligible: r.backupEligible === true,1885 backupState: r.backupState === true,1886 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1887 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1888 transports: Array.isArray(r.transports)1889 ? r.transports.filter((t): t is string => typeof t === "string")1890 : undefined,1891 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1892 };1893}18941895189618971898189919001901190219031904function sanitizeCanary(c: unknown): Canary | undefined {1905 if (!c || typeof c !== "object") return undefined;1906 const r = c as Record<string, unknown>;1907 if (typeof r.webhookUrl !== "string") return undefined;1908 let url: URL;1909 try {1910 url = new URL(r.webhookUrl);1911 } catch {1912 return undefined;1913 }1914 if (url.protocol !== "https:") return undefined;1915 return {1916 webhookUrl: url.toString(),1917 label: typeof r.label === "string" ? r.label : undefined,1918 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1919 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1920 };1921}19221923192419251926192719281929function sanitizeTags(tags: unknown): string[] | undefined {1930 if (!Array.isArray(tags)) return undefined;1931 const out: string[] = [];1932 for (const t of tags) {1933 if (typeof t !== "string") continue;1934 const trimmed = t.trim();1935 if (!trimmed) continue;1936 out.push(trimmed.slice(0, 256));1937 if (out.length >= 255) break;1938 }1939 return out.length > 0 ? out : undefined;1940}194119421943194419451946export async function clearVaultEntries(1947 confirmation: "REPLACE_ALL"1948): Promise<void> {1949 if (confirmation !== "REPLACE_ALL") {1950 throw new Error("clearVaultEntries requires explicit confirmation");1951 }1952 if (!derivedKey) throw new Error("vault_locked");1953 for (const entry of entries) {1954 if (!entry.deletedAt) {1955 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1956 entry.deletedAt = stamp;1957 entry.modifiedAt = stamp;1958 }1959 }1960 await persistEntries();1961}19621963export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1964 if (!derivedKey) throw new Error("vault_locked");19651966 const sanitized = sanitizeEntryDraft(entry);1967 const now = Date.now();1968 const stamp = monotonicEntryTimestamp();1969 const nextEntry: VaultEntry = {1970 ...sanitized,1971 id: crypto.randomUUID(),1972 createdAt: now,1973 modifiedAt: stamp,1974 1975 1976 passwordChangedAt: sanitized.password ? stamp : undefined,1977 };19781979 1980 1981 1982 const rollback = entries;1983 entries = [...entries, nextEntry];1984 try {1985 await persistEntries();1986 } catch (err) {1987 entries = rollback;1988 throw err;1989 }19901991 1992 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1993 checkBreach(nextEntry.password).then(async (count) => {1994 if (count > 0) {1995 const idx = entries.findIndex((e) => e.id === nextEntry.id);1996 if (idx !== -1) {1997 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1998 await persistEntries();1999 }2000 }2001 });2002 }20032004 fireSyncTrigger("vault_change");20052006 return nextEntry;2007}20082009201020112012201320142015export async function consolidatePasskeyEntries(): Promise<void> {2016 if (!derivedKey) return;2017 const { merged } = mergePasskeyOnlyIntoLogins(entries);2018 if (merged) {2019 await persistEntries();2020 }2021}20222023export async function updateEntry(2024 id: string,2025 patch: Partial<VaultEntry>2026): Promise<void> {2027 if (!derivedKey) throw new Error("vault_locked");20282029 const idx = entries.findIndex((entry) => entry.id === id);2030 if (idx === -1 || entries[idx].deletedAt) {2031 throw new Error("entry_not_found");2032 }20332034 const current = entries[idx];2035 const sanitizedPatch = sanitizeEntryPatch(patch);2036 2037 2038 2039 const passwordChanged =2040 "password" in patch &&2041 typeof sanitizedPatch.password === "string" &&2042 sanitizedPatch.password !== current.password;2043 const stamp = monotonicEntryTimestamp(current.modifiedAt);2044 2045 2046 2047 2048 2049 2050 2051 const priorPassword = current.password;2052 const shouldCapture =2053 passwordChanged &&2054 typeof priorPassword === "string" &&2055 priorPassword.length > 0 &&2056 current.passwordHistory?.[0]?.password !== priorPassword;2057 const nextHistory = shouldCapture2058 ? [2059 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2060 ...(current.passwordHistory ?? []),2061 ].slice(0, PASSWORD_HISTORY_MAX)2062 : current.passwordHistory;2063 entries[idx] = {2064 ...current,2065 ...sanitizedPatch,2066 modifiedAt: stamp,2067 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2068 };2069207020712072 2073 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2074 entries[idx] = current;2075 throw new Error("card number is required for card entries");2076 }20772078 2079 2080 2081 2082 try {2083 await persistEntries();2084 } catch (err) {2085 entries[idx] = current;2086 throw err;2087 }20882089 2090 2091 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2092 entries[idx] = { ...entries[idx], breach: undefined };2093 await persistEntries();2094 const password = entries[idx].password;2095 if (password) {2096 checkBreach(password).then(async (count) => {2097 const i = entries.findIndex((e) => e.id === id);2098 if (i !== -1 && count > 0) {2099 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2100 await persistEntries();2101 }2102 });2103 }2104 }21052106 fireSyncTrigger("vault_change");2107}21082109export async function deleteEntry(id: string): Promise<void> {2110 if (!derivedKey) throw new Error("vault_locked");21112112 const entry = entries.find((item) => item.id === id);2113 if (!entry || entry.deletedAt) {2114 throw new Error("entry_not_found");2115 }21162117 2118 2119 2120 const previousModifiedAt = entry.modifiedAt;2121 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2122 entry.deletedAt = stamp;2123 entry.modifiedAt = stamp;2124 2125 2126 try {2127 await persistEntries();2128 } catch (err) {2129 delete entry.deletedAt;2130 entry.modifiedAt = previousModifiedAt;2131 throw err;2132 }2133 fireSyncTrigger("vault_change");2134}21352136export function getDeletedEntries(): VaultEntry[] {2137 if (!derivedKey) return [];2138 return entries.filter((e) => !!e.deletedAt);2139}2140214121422143214421452146export async function getVaultVersion(): Promise<number> {2147 try {2148 const raw = await chrome.storage.local.get("peach_last_modified_at");2149 const v = (raw as Record<string, unknown>).peach_last_modified_at;2150 if (typeof v === "number") return v;2151 if (typeof v === "string" && v !== "") {2152 const n = Number.parseInt(v, 10);2153 return Number.isFinite(n) ? n : 0;2154 }2155 return 0;2156 } catch {2157 return 0;2158 }2159}216021612162216321642165216621672168216921702171export async function addEntryDirect(entry: VaultEntry): Promise<void> {2172 if (!derivedKey) throw new Error("vault_locked");2173 entries = [...entries, entry];2174 await persistEntries("sync_received");2175}21762177export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2178 if (!derivedKey) throw new Error("vault_locked");2179 const idx = entries.findIndex((e) => e.id === patch.id);2180 if (idx === -1) throw new Error("entry_not_found");2181 entries[idx] = { ...entries[idx], ...patch };2182 await persistEntries("sync_received");2183}21842185export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2186 if (!derivedKey) throw new Error("vault_locked");2187 const idx = entries.findIndex((e) => e.id === id);2188 if (idx === -1) throw new Error("entry_not_found");2189 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2190 await persistEntries("sync_received");2191}2192219321942195219621972198219922002201220222032204export async function applyMergedRemoteEntries(2205 remoteEntries: VaultEntry[],2206 remoteTombstones: Record<string, number>,2207 _context: { deviceName?: string } = {},2208): Promise<{ received: number; applied: number; conflicts: number }> {2209 2210 2211 2212 2213 2214 2215 if (!derivedKey) throw new Error("vault_locked");22162217 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2218 let applied = 0;2219 let conflicts = 0;22202221 const byId = new Map<string, VaultEntry>();2222 for (const e of entries) byId.set(e.id, e);22232224 const mergeNow = Date.now();22252226 for (const remote of remoteEntries) {2227 const sanitized = sanitizeRestoredEntry(remote);2228 if (!sanitized) continue;2229 2230 2231 2232 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2233 if (typeof sanitized.deletedAt === "number") {2234 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2235 }2236 const tombstoneTs = remoteTombstones[sanitized.id];2237 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2238 continue;2239 }2240 const local = byId.get(sanitized.id);2241 if (!local) {2242 entries.push(sanitized);2243 byId.set(sanitized.id, sanitized);2244 applied += 1;2245 } else if (sanitized.modifiedAt > local.modifiedAt) {2246 const idx = entries.findIndex((e) => e.id === local.id);2247 if (idx !== -1) {2248 entries[idx] = sanitized;2249 byId.set(sanitized.id, sanitized);2250 applied += 1;2251 if (!local.deletedAt) conflicts += 1;2252 }2253 }2254 }22552256 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2257 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2258 2259 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2260 const local = byId.get(id);2261 if (!local) continue;2262 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2263 if (deletedAt < local.modifiedAt) continue;2264 const idx = entries.findIndex((e) => e.id === id);2265 if (idx !== -1) {2266 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2267 applied += 1;2268 }2269 }22702271 if (applied > 0) {2272 await persistEntries("sync_received");2273 buildDomainIndexSync(entries);2274 }2275 return { received, applied, conflicts };2276}22772278export async function restoreEntry(id: string): Promise<void> {2279 if (!derivedKey) throw new Error("vault_locked");2280 const entry = entries.find((e) => e.id === id && e.deletedAt);2281 if (!entry) throw new Error("deleted entry not found");2282 delete entry.deletedAt;2283 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2284 await persistEntries();2285 buildDomainIndexSync(entries);2286}22872288export async function purgeEntry(id: string): Promise<void> {2289 if (!derivedKey) throw new Error("vault_locked");2290 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2291 if (idx === -1) throw new Error("deleted entry not found");2292 entries.splice(idx, 1);2293 await persistEntries();2294}229522962297export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2298 if (!derivedKey || retentionMs === 0) return 0;2299 const cutoff = Date.now() - retentionMs;2300 const before = entries.length;2301 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2302 const purged = before - entries.length;2303 if (purged > 0) await persistEntries();2304 return purged;2305}23062307export function queryDomain(domain: string): VaultEntry[] {2308 return domainIndex.get(domain) ?? [];2309}23102311export function getEntryById(id: string): VaultEntry | undefined {2312 return entries.find((entry) => entry.id === id && !entry.deletedAt);2313}23142315async function persistSlotA(2316 reason: VaultSnapshot["reason"] = "local_save",2317): Promise<StoredVault> {2318 if (!derivedKey) throw new Error("vault_locked");23192320 const slotA = await loadSlot(SLOT_A);2321 if (!slotA) throw new Error("missing slot A");23222323 2324 2325 await takeVaultSnapshot(reason);23262327 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2328 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");23292330 2331 const nextSlotA: StoredVault = {2332 ...slotA,2333 nonce,2334 ciphertext,2335 tag,2336 2337 };2338 await writeSlot(SLOT_A, nextSlotA);23392340 2341 2342 2343 2344 2345 2346 try {2347 2348 2349 2350 if (!duressConfigured) {2351 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2352 }2353 2354 2355 2356 2357 2358 if (reason !== "sync_received") {2359 await stampLocalVaultMutation();2360 }2361 } catch (err) {2362 2363 2364 await writeSlot(SLOT_A, slotA).catch(() => {});2365 throw err;2366 }23672368 return nextSlotA;2369}23702371async function persistSlotB(): Promise<void> {2372 if (!derivedKey) throw new Error("vault_locked");23732374 const slotB = await loadSlot(SLOT_B);2375 if (!slotB) throw new Error("missing slot B");23762377 2378 2379 2380 2381 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2382 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");23832384 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2385 2386 2387 2388 try {2389 await stampLocalVaultMutation();2390 } catch (err) {2391 await writeSlot(SLOT_B, slotB).catch(() => {});2392 throw err;2393 }2394}23952396async function persistEntries(2397 reason: VaultSnapshot["reason"] = "local_save",2398): Promise<void> {2399 if (!derivedKey) throw new Error("vault_locked");24002401 if (vaultMode === "decoy") {2402 await persistSlotB();2403 } else {2404 await persistSlotA(reason);2405 }2406 buildDomainIndexSync(entries);2407}24082409function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2410 if (!Array.isArray(values)) return undefined;2411 const seen = new Set<string>();2412 const cleaned: string[] = [];2413 for (const value of values) {2414 const trimmed = typeof value === "string" ? value.trim() : "";2415 if (!trimmed) continue;2416 const key = trimmed.toLowerCase();2417 if (seen.has(key)) continue;2418 seen.add(key);2419 cleaned.push(trimmed);2420 }2421 return cleaned.length > 0 ? cleaned : undefined;2422}24232424function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2425 const title = entry.title.trim();2426 2427 2428 2429 let type: VaultEntry["type"] = entry.type ?? "login";2430 if (type === "passkey") type = "login";24312432 if (!title) throw new Error("title is required");24332434 if (entry.passkey) {2435 const p = entry.passkey;2436 if (2437 !p.rpId ||2438 !p.credentialId ||2439 !p.publicKeyCose ||2440 !p.userHandle ||2441 !p.userName ||2442 (p.algorithm !== -7 && p.algorithm !== -8)2443 ) {2444 throw new Error("invalid passkey credential");2445 }2446 }2447 if (type === "card") {2448 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2449 throw new Error("cardholder name and card number are required");2450 }2451 }24522453 return {2454 type,2455 title,2456 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2457 email: type === "login" ? entry.email?.trim() || undefined : undefined,2458 password: type === "login" ? entry.password : undefined,2459 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2460 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2461 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2462 2463 2464 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2465 totpDigits: type === "login" ? entry.totpDigits : undefined,2466 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2467 notes: entry.notes?.trim() || undefined,2468 breach: type === "login" ? entry.breach : undefined,2469 2470 2471 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2472 tags: sanitizeTags(entry.tags),2473 passkey: entry.passkey,2474 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2475 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2476 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2477 expYear: type === "card" ? entry.expYear?.trim() : undefined,2478 cvc: type === "card" ? entry.cvc?.trim() : undefined,2479 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2480 network: type === "card" ? entry.network : undefined,2481 last4: type === "card" ? entry.last4?.trim() : undefined,2482 };2483}24842485function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2486 const nextPatch: Partial<VaultEntry> = {};24872488 if ("type" in patch) {2489 nextPatch.type = patch.type;2490 }24912492 if ("title" in patch) {2493 const title = patch.title?.trim() ?? "";2494 if (!title) throw new Error("title is required");2495 nextPatch.title = title;2496 }24972498 if ("url" in patch) {2499 nextPatch.url = patch.url?.trim() ?? "";2500 }25012502 if ("additionalUrls" in patch) {2503 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2504 const seen = new Set<string>();2505 const cleaned: string[] = [];2506 for (const value of incoming) {2507 const trimmed = typeof value === "string" ? value.trim() : "";2508 if (!trimmed) continue;2509 const key = trimmed.toLowerCase();2510 if (seen.has(key)) continue;2511 seen.add(key);2512 cleaned.push(trimmed);2513 }2514 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2515 }25162517 if ("username" in patch) {2518 nextPatch.username = patch.username?.trim() ?? "";2519 }25202521 if ("email" in patch) {2522 nextPatch.email = patch.email?.trim() || undefined;2523 }25242525 if ("password" in patch) {2526 const password = patch.password ?? "";2527 2528 nextPatch.password = password || undefined;2529 }25302531 if ("totp" in patch) {2532 nextPatch.totp = patch.totp?.trim() || undefined;2533 }25342535 if ("notes" in patch) {2536 nextPatch.notes = patch.notes?.trim() || undefined;2537 }25382539 if ("breach" in patch) {2540 nextPatch.breach = patch.breach;2541 }25422543 if ("canary" in patch) {2544 2545 2546 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2547 }25482549 if ("passkey" in patch) {2550 2551 2552 2553 if (patch.passkey === undefined || patch.passkey === null) {2554 nextPatch.passkey = undefined;2555 } else {2556 const sanitized = sanitizePasskey(patch.passkey);2557 if (!sanitized) throw new Error("invalid passkey credential");2558 nextPatch.passkey = sanitized;2559 }2560 }25612562 if ("tags" in patch) {2563 2564 nextPatch.tags = sanitizeTags(patch.tags);2565 }25662567 if ("cardholderName" in patch) {2568 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2569 }2570 if ("cardNumber" in patch) {2571 const n = patch.cardNumber ?? "";2572 nextPatch.cardNumber = n || undefined;2573 }2574 if ("expMonth" in patch) {2575 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2576 }2577 if ("expYear" in patch) {2578 nextPatch.expYear = patch.expYear?.trim() || undefined;2579 }2580 if ("cvc" in patch) {2581 nextPatch.cvc = patch.cvc?.trim() || undefined;2582 }2583 if ("cardNickname" in patch) {2584 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2585 }2586 if ("network" in patch) {2587 nextPatch.network = patch.network;2588 }2589 if ("last4" in patch) {2590 nextPatch.last4 = patch.last4?.trim() || undefined;2591 }25922593 return nextPatch;2594}25952596function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2597 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2598 const modifiedAt =2599 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;26002601 const base: VaultEntry = {2602 id: entry.id,2603 type: "type" in entry ? entry.type : "login",2604 title: entry.title,2605 username: entry.username,2606 email: "email" in entry ? entry.email : undefined,2607 password: entry.password,2608 url: entry.url,2609 additionalUrls:2610 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2611 ? entry.additionalUrls2612 : undefined,2613 totp: entry.totp,2614 2615 totpAlgorithm: entry.totpAlgorithm,2616 totpDigits: entry.totpDigits,2617 totpPeriod: entry.totpPeriod,2618 notes: entry.notes,2619 breach: entry.breach,2620 2621 2622 2623 passwordChangedAt:2624 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2625 2626 2627 2628 passwordHistory:2629 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2630 ? entry.passwordHistory2631 : undefined,2632 canary: "canary" in entry ? entry.canary : undefined,2633 passkey: "passkey" in entry ? entry.passkey : undefined,2634 createdAt,2635 modifiedAt,2636 deletedAt: entry.deletedAt,2637 tags: "tags" in entry ? entry.tags : undefined,2638 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2639 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2640 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2641 expYear: "expYear" in entry ? entry.expYear : undefined,2642 cvc: "cvc" in entry ? entry.cvc : undefined,2643 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2644 network: "network" in entry ? entry.network : undefined,2645 last4: "last4" in entry ? entry.last4 : undefined,2646 };26472648 return migrateLegacyPasskey(base, entry);2649}265026512652265326542655265626572658function migrateLegacyPasskey(2659 base: VaultEntry,2660 raw: VaultEntry | LegacyVaultEntry2661): VaultEntry {2662 if (base.passkey) return base;2663 const flat = raw as Partial<VaultEntry>;2664 const isPasskeyType = base.type === "passkey";2665 const hasFlatPasskeyFields =2666 typeof flat.rpId === "string" &&2667 typeof flat.credentialId === "string" &&2668 typeof flat.publicKeyCose === "string" &&2669 typeof flat.userHandle === "string" &&2670 typeof flat.userName === "string";2671 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2672 if (!hasFlatPasskeyFields) return base;26732674 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2675 base.passkey = {2676 rpId: flat.rpId!,2677 rpName: flat.rpName ?? flat.rpId!,2678 credentialId: flat.credentialId!,2679 publicKeyCose: flat.publicKeyCose!,2680 privateKeyJwk: flat.privateKeyJwk,2681 userHandle: flat.userHandle!,2682 userName: flat.userName!,2683 userDisplayName: flat.userDisplayName ?? flat.userName!,2684 algorithm,2685 signCount: flat.signCount ?? 0,2686 aaguid: flat.aaguid ?? "",2687 backupEligible: flat.backupEligible === true,2688 backupState: flat.backupState === true,2689 lastUsedAt: flat.lastUsedAt,2690 browser: flat.browser,2691 transports: flat.transports,2692 createdAt: base.createdAt,2693 };2694 2695 2696 2697 if (isPasskeyType) base.type = "login";2698 if (!base.username) base.username = flat.userName;2699 if (!base.url) base.url = `https://${flat.rpId}`;2700 return base;2701}2702270327042705270627072708function entryDupeKey(e: VaultEntry): string {2709 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2710 2711 2712 2713 2714 if (e.passkey) {2715 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2716 }2717 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2718}271927202721export function countDuplicates(): number {2722 const best = new Map<string, VaultEntry>();2723 let dupeCount = 0;2724 for (const e of entries.filter((e) => !e.deletedAt)) {2725 const key = entryDupeKey(e);2726 const existing = best.get(key);2727 if (!existing) {2728 best.set(key, e);2729 } else {2730 dupeCount++;2731 2732 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2733 best.set(key, e);2734 }2735 }2736 }2737 return dupeCount;2738}273927402741274227432744export async function deduplicateEntries(): Promise<number> {2745 if (!derivedKey) throw new Error("vault_locked");2746 2747 2748 if (vaultMode === "decoy") {2749 throw new Error("deduplicate disabled in decoy mode");2750 }27512752 const best = new Map<string, VaultEntry>();2753 for (const e of entries.filter((en) => !en.deletedAt)) {2754 const key = entryDupeKey(e);2755 const existing = best.get(key);2756 if (!existing) {2757 best.set(key, e);2758 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2759 best.set(key, e);2760 }2761 }27622763 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2764 let removed = 0;2765 entries = entries.map((e) => {2766 if (!e.deletedAt && !keepIds.has(e.id)) {2767 removed++;2768 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2769 }2770 return e;2771 });27722773 if (removed > 0) await persistEntries();2774 return removed;2775}277627772778277927802781278227832784const HISTORY_KEY = "peach_vault_history";2785const HISTORY_MAX = 5;278627872788278927902791function bucketEntryCount(n: number): number {2792 if (n <= 0) return 0;2793 if (n <= 10) return 10;2794 if (n <= 25) return 25;2795 if (n <= 50) return 50;2796 if (n <= 100) return 100;2797 if (n <= 250) return 250;2798 if (n <= 500) return 500;2799 return Math.ceil(n / 500) * 500;2800}28012802280328042805280628072808export async function takeVaultSnapshot(2809 reason: VaultSnapshot["reason"],2810): Promise<void> {2811 try {2812 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2813 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2814 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];28152816 2817 2818 2819 if (!slotA) return;28202821 const vault_a = JSON.stringify(slotA);2822 const vault_b = JSON.stringify(slotB ?? slotA);28232824 2825 2826 2827 2828 2829 2830 let entryCount: number | null = null;2831 if (derivedKey && vaultMode === "real") {2832 try {2833 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2834 } catch {2835 entryCount = null;2836 }2837 }28382839 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2840 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2841 ? existingRaw2842 : { snapshots: [] };28432844 2845 2846 2847 const latest = history.snapshots[0];2848 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2849 return;2850 }28512852 const snapshot: VaultSnapshot = {2853 id: crypto.randomUUID(),2854 savedAt: Date.now(),2855 reason,2856 vault_a,2857 vault_b,2858 entryCount,2859 };28602861 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2862 await chrome.storage.local.set({ [HISTORY_KEY]: history });2863 } catch (err) {2864 2865 2866 2867 2868 console.warn('[Peach] takeVaultSnapshot failed:', err);2869 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2870 try {2871 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2872 } catch {2873 2874 }2875 }2876 }2877}287828792880export async function clearVaultHistory(): Promise<void> {2881 try {2882 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2883 } catch {2884 2885 }2886}288728882889export async function getVaultHistory(): Promise<VaultSnapshot[]> {2890 const raw = await chrome.storage.local.get(HISTORY_KEY);2891 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2892 if (!history || !Array.isArray(history.snapshots)) return [];2893 return history.snapshots;2894}28952896289728982899290029012902export async function restoreVaultSnapshot(2903 snapshotId: string,2904): Promise<{ ok: true } | { ok: false; error: string }> {2905 const history = await getVaultHistory();2906 const snapshot = history.find((s) => s.id === snapshotId);2907 if (!snapshot) return { ok: false, error: "snapshot_not_found" };29082909 2910 2911 2912 await takeVaultSnapshot("restore");29132914 let restoredA: StoredVault;2915 let restoredB: StoredVault;2916 try {2917 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2918 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2919 } catch {2920 return { ok: false, error: "snapshot_corrupt" };2921 }29222923 await writeSlot(SLOT_A, restoredA);2924 await writeSlot(SLOT_B, restoredB);2925 await stampLocalVaultMutation();29262927 2928 2929 2930 lock();29312932 return { ok: true };2933}293429352936293729382939294029412942294329442945294629472948export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2949 if (!derivedKey || vaultMode !== "real") {2950 return { ok: false, error: "vault_locked" };2951 }2952 const slotA = await loadSlot(SLOT_A);2953 if (!slotA) return { ok: false, error: "missing_slot_a" };29542955 2956 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2957 await generateAndWrapRootKey();29582959 2960 const newRootKey = unhex(rawRootKey);2961 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);29622963 2964 2965 2966 2967 await takeVaultSnapshot("local_save");2968 const nextSlotA: StoredVault = {2969 ...slotA,2970 recovery_salt: recoverySalt,2971 recovery_wrapped_root_key: wrappedRootKey,2972 recovery_nonce: wrappedNonce,2973 recovery_tag: wrappedTag,2974 ...pwRoot,2975 };2976 await writeSlot(SLOT_A, nextSlotA);29772978 2979 if (rootKey) zeroKey(rootKey);2980 rootKey = newRootKey;29812982 2983 await clearPairedDevices();29842985 2986 await setRecoveryConfirmPending(); 29872988 await stampLocalVaultMutation();2989 return { ok: true };2990}29912992299329942995299629972998export async function changePassword(2999 currentPassword: string,3000 newPassword: string3001): Promise<boolean> {3002 if (!derivedKey || vaultMode !== "real") return false;30033004 3005 const valid = await verifyMainPassword(currentPassword);3006 if (!valid) return false;30073008 const slotA = await loadSlot(SLOT_A);3009 if (!slotA) return false;3010 if (!rootKey) return false;30113012 3013 const newSalt = generateSalt();3014 const newKey = await deriveKey(newPassword, newSalt);30153016 3017 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3018 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");30193020 3021 3022 3023 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);30243025 const finalSlotA: StoredVault = {3026 ...slotA,3027 salt: hex(newSalt),3028 nonce,3029 ciphertext,3030 tag,3031 ...pwRoot,3032 };30333034 3035 3036 3037 await takeVaultSnapshot("local_save");30383039 3040 3041 3042 3043 await writeSlot(SLOT_A, finalSlotA);30443045 3046 3047 3048 zeroKey(derivedKey);3049 derivedKey = newKey;30503051 3052 3053 3054 3055 void saveSessionKey(newKey, "real");30563057 3058 3059 3060 if (!duressConfigured) {3061 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3062 }3063 await stampLocalVaultMutation();30643065 return true;3066}