123456789101112import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";16import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";171819import { triggerSyncIfReachable } from "./sync-trigger";20import { clearPairedDevices } from "./paired-devices";21import {22 rootKeyToWords,23 wordsToRootKeyMaterial,24 deriveRecoveryKey,25} from "../shared/recovery-words";26import type {27 AccentColorName,28 Canary,29 LegacyVaultEntry,30 ManualSummonRecord,31 PasskeyData,32 SavedIdentity,33 StoredVault,34 VaultEntry,35 VaultHistory,36 VaultSnapshot,37} from "../shared/types";38import { decrypt, encrypt, zeroKey } from "./aes";39import { clearAutoLock, touchAutoLock } from "./alarms";40import { setRecoveryConfirmPending } from "./recovery-pending";4142type EntryDraft = Omit<43 VaultEntry,44 "id" | "createdAt" | "modifiedAt" | "deletedAt"45>;464748495051const SLOT_A = "peach_vault_a";52const SLOT_B = "peach_vault_b";535455const LEGACY_MAIN = "peach_vault";56const LEGACY_DECOY = "peach_decoy_vault";57const LEGACY_DURESS_FLAG = "peach_duress";58596061const PAD_BLOCK = 1024;6263646566const PASSWORD_HISTORY_MAX = 50;6768697071727374757677const DURESS_SLOT_SIZE = 262_144;7879let derivedKey: Uint8Array | null = null;8081828384let rootKey: Uint8Array | null = null;85let entries: VaultEntry[] = [];86let domainIndex: Map<string, VaultEntry[]> = new Map();87let vaultMode: "real" | "decoy" = "real";88899091let duressConfigured = false;9293let migrationRan = false;949596979899100101102103104let lastUnlockFailureReason: string | null = null;105106export function consumeLastUnlockFailureReason(): string | null {107 const r = lastUnlockFailureReason;108 lastUnlockFailureReason = null;109 return r;110}111112113114115116export type TextSize = "small" | "medium" | "large";117118export interface VaultSettings {119 accentColor?: AccentColorName;120 theme?: "dark" | "light";121 autoLockMinutes?: number;122 clipboardClearSeconds?: number;123 lockOnClose?: boolean;124 breachMonitoring?: boolean;125 passkeyProviderEnabled?: boolean;126 trashRetentionDays?: number;127 manualSummonDomains?: Record<string, ManualSummonRecord>;128 declinedCardFingerprints?: string[];129 130131132133134 textSize?: TextSize;135 136137138139140 reduceMotion?: boolean;141 142143144145146 biometricsEnabled?: boolean;147 148149150151152153154 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;155}156157let settings: VaultSettings = {};158159export function getSettings(): VaultSettings {160 return settings;161}162163export async function ensureSettingsLoaded(): Promise<VaultSettings> {164 await loadSettings();165 return settings;166}167168export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {169 await loadSettings();170 171 172 173 174 const safe = Object.fromEntries(175 Object.entries(patch).filter(176 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),177 ),178 ) as Partial<VaultSettings>;179 settings = { ...settings, ...safe };180 await persistSettings();181}182183async function persistSettings(): Promise<void> {184 try {185 await chrome.storage.local.set({ peach_settings: settings });186 } catch (err) {187 throw mapStorageError(err);188 }189}190191function mapStorageError(err: unknown): Error {192 const message = err instanceof Error ? err.message : String(err);193 if (/QUOTA_BYTES|quota/i.test(message)) {194 return new Error("STORAGE_QUOTA_EXCEEDED");195 }196 return err instanceof Error ? err : new Error(message);197}198199async function loadSettings(): Promise<void> {200 const result = await chrome.storage.local.get("peach_settings");201 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};202}203204205206function fireSyncTrigger(reason: "vault_change"): void {207 void Promise.resolve()208 .then(() => triggerSyncIfReachable({ reason }))209 .catch(() => {210 211 });212}213214215216217218export async function checkBreach(password: string): Promise<number> {219 220 221 const encoder = new TextEncoder();222 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));223 const hashHex = Array.from(new Uint8Array(hashBuffer))224 .map((b) => b.toString(16).padStart(2, "0"))225 .join("")226 .toUpperCase();227228 const prefix = hashHex.slice(0, 5);229 const suffix = hashHex.slice(5);230231 try {232 233 234 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {235 headers: { "Add-Padding": "true" },236 });237 if (!res.ok) return 0;238 const text = await res.text();239 const match = text.split("\r\n").find((l) => l.startsWith(suffix));240 return match ? parseInt(match.split(":")[1], 10) : 0;241 } catch {242 return 0; 243 }244}245246export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {247 const fingerprint = await cardFingerprint(last4, expMonth, expYear);248 await loadSettings();249 settings = {250 ...settings,251 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],252 };253 await persistSettings();254}255256export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {257 await loadSettings();258 const fingerprint = await cardFingerprint(last4, expMonth, expYear);259 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);260}261262async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {263 const encoder = new TextEncoder();264 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));265 return Array.from(new Uint8Array(buf).slice(0, 8))266 .map((b) => b.toString(16).padStart(2, "0"))267 .join("");268}269270function hex(buf: Uint8Array): string {271 return Array.from(buf)272 .map((b) => b.toString(16).padStart(2, "0"))273 .join("");274}275276function unhex(s: string): Uint8Array {277 const out = new Uint8Array(s.length / 2);278 for (let i = 0; i < s.length; i += 2) {279 out[i / 2] = parseInt(s.slice(i, i + 2), 16);280 }281 return out;282}283284285286287288289290291292293294295296297298299300301302303async function generateAndWrapRootKey(): Promise<{304 rawRootKey: string; 305 recoverySalt: string;306 wrappedRootKey: string;307 wrappedNonce: string;308 wrappedTag: string;309}> {310 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));311312 313 const words = await rootKeyToWords(rootKeyBytes);314 const wordMaterial = await wordsToRootKeyMaterial(words);315 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));316 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);317318 319 320 const nonce = crypto.getRandomValues(new Uint8Array(12));321 const encoded = new TextEncoder().encode(hex(rootKeyBytes));322 const raw = await crypto.subtle.encrypt(323 {324 name: "AES-GCM",325 iv: nonce,326 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,327 tagLength: 128,328 },329 recoveryKey,330 encoded331 );332 333 const rawU8 = new Uint8Array(raw);334 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));335 const tag = hex(rawU8.slice(rawU8.length - 16));336337 return {338 rawRootKey: hex(rootKeyBytes),339 recoverySalt: hex(recoverySalt),340 wrappedRootKey: ciphertext,341 wrappedNonce: hex(nonce),342 wrappedTag: tag,343 };344}345346async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{347 recoverySalt: string;348 wrappedRootKey: string;349 wrappedNonce: string;350 wrappedTag: string;351}> {352 if (rootKeyBytes.length !== 32) {353 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");354 }355356 const words = await rootKeyToWords(rootKeyBytes);357 const wordMaterial = await wordsToRootKeyMaterial(words);358 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));359 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);360 const nonce = crypto.getRandomValues(new Uint8Array(12));361 const encoded = new TextEncoder().encode(hex(rootKeyBytes));362 const raw = await crypto.subtle.encrypt(363 {364 name: "AES-GCM",365 iv: nonce,366 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,367 tagLength: 128,368 },369 recoveryKey,370 encoded,371 );372 const rawU8 = new Uint8Array(raw);373 return {374 recoverySalt: hex(recoverySalt),375 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),376 wrappedNonce: hex(nonce),377 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),378 };379}380381382383384385386export async function unwrapRootKeyForRecovery(387 wrappedRootKey: string,388 wrappedNonce: string,389 wrappedTag: string,390 recoverySaltHex: string,391 words: string[]392): Promise<void> {393 const wordMaterial = await wordsToRootKeyMaterial(words);394 const recoverySalt = unhex(recoverySaltHex);395 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);396397 398 399 400 401 402 const nonceRaw = unhex(wrappedNonce);403 const ciphertextRaw = unhex(wrappedRootKey);404 const tagRaw = unhex(wrappedTag);405 406 407 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;408 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);409 combined.set(ciphertextRaw, 0);410 combined.set(tagRaw, ciphertextRaw.length);411 const raw = await crypto.subtle.decrypt(412 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },413 recoveryKey,414 combined415 );416 const decryptedHex = new TextDecoder().decode(raw);417 rootKey = unhex(decryptedHex);418}419420421422423424425426427428429export async function rewrapRootKeyForPassword(430 newDerivedKey: Uint8Array,431 _newSalt: Uint8Array432): Promise<void> {433 if (!rootKey) throw new Error("root key not set");434 const slotA = await loadSlot(SLOT_A);435 if (!slotA) throw new Error("missing slot A");436 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);437 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });438}439440441442443444445async function reencryptVaultForNewKey(446 newKey: Uint8Array,447 slotA: StoredVault448): Promise<void> {449 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);450 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");451 await writeSlot(SLOT_A, {452 ...slotA,453 nonce,454 ciphertext,455 tag,456 });457}458459460461462463464export async function changePasswordWithNewKey(465 newSalt: Uint8Array,466 newKey: Uint8Array467): Promise<void> {468 const slotA = await loadSlot(SLOT_A);469 if (!slotA) throw new Error("missing slot A");470 471 472 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });473 if (derivedKey) zeroKey(derivedKey);474 derivedKey = newKey;475 476 477 478 void saveSessionKey(newKey, "real");479 480 481 482 483 if (rootKey) {484 const slotAfter = await loadSlot(SLOT_A);485 if (slotAfter) {486 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);487 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });488 }489 }490 if (!duressConfigured) {491 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));492 }493 await stampLocalVaultMutation();494}495496497498499500501export { deriveKey, generateSalt };502503export async function loadSlotA(): Promise<StoredVault | null> {504 return loadSlot(SLOT_A);505}506507508509510511512513514515516517518519async function deriveRootKeyFromDerivedKey(520 derivedKeyBytes: Uint8Array,521 slotA: StoredVault,522): Promise<Uint8Array | null> {523 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {524 return null;525 }526 try {527 const rootHex = await decrypt(528 derivedKeyBytes,529 slotA.pw_root_nonce,530 slotA.pw_root_ciphertext,531 slotA.pw_root_tag,532 "pw_root_v1",533 );534 return unhex(rootHex);535 } catch {536 return null;537 }538}539540541542async function wrapRootKeyForDerivedKey(543 derivedKeyBytes: Uint8Array,544 rootKeyBytes: Uint8Array,545): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {546 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");547 return {548 pw_root_ciphertext: ciphertext,549 pw_root_nonce: nonce,550 pw_root_tag: tag,551 };552}553554555556557558559560561export async function migrateToRecovery(): Promise<void> {562 if (!derivedKey || vaultMode !== "real") {563 throw new Error("vault must be unlocked in real mode");564 }565 const slotA = await loadSlot(SLOT_A);566 if (!slotA) throw new Error("missing slot A");567 const hasRecoveryEscrow =568 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;569 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;570 if (hasRecoveryEscrow && hasPwRoot) return; 571572 573 574 575 576 577 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =578 await generateAndWrapRootKey();579 rootKey = unhex(rawRootKey);580581 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);582 const nextSlotA: StoredVault = {583 ...slotA,584 recovery_salt: recoverySalt,585 recovery_wrapped_root_key: wrappedRootKey,586 recovery_nonce: wrappedNonce,587 recovery_tag: wrappedTag,588 ...pwRoot,589 };590 await writeSlot(SLOT_A, nextSlotA);591 await stampLocalVaultMutation();592}593594595596597598599export async function getRecoveryWords(): Promise<string[]> {600 if (!rootKey) {601 throw new Error("recovery not set up — vault may need migration");602 }603 return rootKeyToWords(rootKey);604}605606async function loadSlot(key: string): Promise<StoredVault | null> {607 const result = await chrome.storage.local.get(key);608 return (result as Record<string, StoredVault | undefined>)[key] ?? null;609}610611async function writeSlot(key: string, vault: StoredVault): Promise<void> {612 try {613 await chrome.storage.local.set({ [key]: vault });614 } catch (err) {615 throw mapStorageError(err);616 }617}618619async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {620 const raw = await chrome.storage.local.get("peach_last_modified_at");621 const previous = Number(622 (raw as Record<string, unknown>).peach_last_modified_at,623 );624 const next = Math.max(625 Date.now(),626 Number.isFinite(previous) ? previous + 1 : 0,627 );628 try {629 await chrome.storage.local.set({630 [SLOT_A]: slotA,631 [SLOT_B]: slotB,632 peach_last_modified_at: String(next),633 });634 } catch (err) {635 throw mapStorageError(err);636 }637}638639640641642643644645646let lastEntryTimestamp = 0;647function monotonicEntryTimestamp(currentModifiedAt?: number): number {648 const base = Math.max(649 Date.now(),650 (currentModifiedAt ?? 0) + 1,651 lastEntryTimestamp + 1,652 );653 lastEntryTimestamp = base;654 return base;655}656657async function stampLocalVaultMutation(): Promise<void> {658 const raw = await chrome.storage.local.get("peach_last_modified_at");659 const previous = Number(660 (raw as Record<string, unknown>).peach_last_modified_at,661 );662 const next = Math.max(663 Date.now(),664 Number.isFinite(previous) ? previous + 1 : 0,665 );666 await chrome.storage.local.set({667 peach_last_modified_at: String(next),668 });669}670671672673674675676async function ensureMigrated(): Promise<void> {677 if (migrationRan) return;678 migrationRan = true;679680 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];681 const raw = await chrome.storage.local.get(keys);682 const slotAExists = raw[SLOT_A] !== undefined;683 const slotBExists = raw[SLOT_B] !== undefined;684 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;685 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;686687 688 if (!slotAExists && !legacyMain) return;689690 691 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;692693 let didMigrate = false;694 if (legacyMain && !slotAExists) {695 await writeSlot(SLOT_A, legacyMain);696 didMigrate = true;697 }698699 700 if (!slotBExists) {701 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;702 if (legacyDecoy) {703 await writeSlot(SLOT_B, legacyDecoy);704 didMigrate = true;705 } else if (target) {706 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));707 didMigrate = true;708 }709 }710711 const toRemove: string[] = [];712 if (legacyMain) toRemove.push(LEGACY_MAIN);713 if (legacyDecoy) toRemove.push(LEGACY_DECOY);714 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);715 if (toRemove.length > 0) {716 await chrome.storage.local.remove(toRemove);717 didMigrate = true;718 }719 if (didMigrate) {720 await stampLocalVaultMutation();721 }722}723724725726727728729730731732733734function padPlaintext(json: string, minBytes = 0): string {735 const encoded = new TextEncoder().encode(json);736 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);737 738 const target = Math.max(bucket, minBytes);739 const padLen = target - encoded.length;740 return json + " ".repeat(padLen);741}742743744745746747748749750751752753const CSPRNG_CHUNK = 65_536;754755function fillRandom(buf: Uint8Array): Uint8Array {756 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {757 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);758 const chunk = new Uint8Array(end - offset);759 crypto.getRandomValues(chunk);760 buf.set(chunk, offset);761 }762 return buf;763}764765function generateNoiseBlob(ciphertextBytes: number): StoredVault {766 const salt = crypto.getRandomValues(new Uint8Array(16));767 const nonce = crypto.getRandomValues(new Uint8Array(12));768 const cipher = fillRandom(new Uint8Array(ciphertextBytes));769 const tag = crypto.getRandomValues(new Uint8Array(16));770 return {771 version: 1,772 salt: hex(salt),773 nonce: hex(nonce),774 ciphertext: hex(cipher),775 tag: hex(tag),776 };777}778779780781782783type VaultPayload = {784 entries: VaultEntry[];785 duressConfigured?: boolean;786};787788type DecryptedPayload = {789 entries: VaultEntry[];790 duressConfigured: boolean;791 792793794795796797798 migratedFromLegacy: boolean;799};800801function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {802 const envelope: VaultPayload = { entries, duressConfigured: duress };803 return padPlaintext(JSON.stringify(envelope), minBytes);804}805806function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {807 if ("created" in raw || "updated" in raw) return true;808 const flat = raw as Partial<VaultEntry>;809 if (flat.type === "passkey") return true;810 811 if (flat.rpId !== undefined && flat.passkey === undefined) return true;812 return false;813}814815function decodePayload(plain: string): DecryptedPayload {816 const parsed = JSON.parse(plain) as817 | Array<VaultEntry | LegacyVaultEntry>818 | VaultPayload;819820 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];821 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);822823 const normalized = rawEntries.map(normalizeEntry);824 const { merged } = mergePasskeyOnlyIntoLogins(normalized);825826 if (Array.isArray(parsed)) {827 return {828 entries: normalized,829 duressConfigured: false,830 migratedFromLegacy: true, 831 };832 }833834 return {835 entries: normalized,836 duressConfigured: parsed.duressConfigured === true,837 migratedFromLegacy: flatMigrationNeeded || merged,838 };839}840841842843844845846847848849850851852853854855856857858859860861862863864function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {865 let merged = false;866 const now = Date.now();867868 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);869 const byRpId = new Map<string, VaultEntry[]>();870 for (const e of candidates) {871 const rpId = e.passkey!.rpId;872 const list = byRpId.get(rpId);873 if (list) list.push(e);874 else byRpId.set(rpId, [e]);875 }876877 for (const [rpId, group] of byRpId) {878 if (group.length === 0) continue;879 group.sort((a, b) => {880 const aT =881 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;882 const bT =883 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;884 return bT - aT;885 });886 const winner = group[0]!;887888 889 890 891 const loginTarget = entries.find((e) => {892 if (e.deletedAt || e.passkey || e.type !== "login") return false;893 if (e.url && extractDomain(e.url) === rpId) return true;894 for (const extra of e.additionalUrls ?? []) {895 if (extractDomain(extra) === rpId) return true;896 }897 return false;898 });899900 if (loginTarget) {901 loginTarget.passkey = winner.passkey;902 loginTarget.modifiedAt = now;903 for (const dup of group) {904 if (!dup.deletedAt) {905 dup.deletedAt = now;906 dup.modifiedAt = now;907 }908 }909 merged = true;910 } else if (group.length > 1) {911 for (let i = 1; i < group.length; i += 1) {912 const dup = group[i]!;913 if (!dup.deletedAt) {914 dup.deletedAt = now;915 dup.modifiedAt = now;916 }917 }918 merged = true;919 }920 }921922 return { merged };923}924925926927928929930931function hasValidVaultSchema(v: unknown): v is StoredVault {932 if (typeof v !== "object" || v === null) return false;933 const r = v as Record<string, unknown>;934 return (935 r.version === 1 &&936 typeof r.salt === "string" &&937 typeof r.nonce === "string" &&938 typeof r.ciphertext === "string" &&939 typeof r.tag === "string"940 );941}942943export type TryDecryptResult =944 | { status: "ok"; payload: DecryptedPayload }945 | { status: "wrong_key" }946 | { status: "corrupted"; reason: string };947948949950951952953export async function tryDecryptSlot(954 vault: StoredVault,955 key: Uint8Array,956 aadTag?: string,957): Promise<DecryptedPayload | null> {958 const res = await tryDecryptSlotResult(vault, key, aadTag);959 return res.status === "ok" ? res.payload : null;960}961962export async function tryDecryptSlotResult(963 vault: StoredVault,964 key: Uint8Array,965 aadTag?: string,966): Promise<TryDecryptResult> {967 if (!hasValidVaultSchema(vault)) {968 return { status: "corrupted", reason: "invalid_slot_schema" };969 }970 let plain: string;971 try {972 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);973 } catch {974 return { status: "wrong_key" };975 }976 try {977 const parsed = JSON.parse(plain) as unknown;978 if (typeof parsed !== "object" || parsed === null) {979 return { status: "corrupted", reason: "invalid_payload_shape" };980 }981 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {982 return { status: "corrupted", reason: "invalid_payload_shape" };983 }984 return { status: "ok", payload: decodePayload(plain) };985 } catch {986 return { status: "corrupted", reason: "invalid_payload_json" };987 }988}989990991992993994function buildDomainIndexSync(all: VaultEntry[]): void {995 domainIndex = new Map();996997 for (const entry of all) {998 if (entry.deletedAt) continue;999 if (entry.type === "note" || entry.type === "card") continue;10001001 1002 1003 1004 1005 const domains = new Set<string>();1006 const primary = extractDomain(entry.url ?? "");1007 if (primary) domains.add(primary);1008 for (const extra of entry.additionalUrls ?? []) {1009 const d = extractDomain(extra);1010 if (d) domains.add(d);1011 }1012 if (domains.size === 0) continue;10131014 for (const domain of domains) {1015 const bucket = domainIndex.get(domain) ?? [];1016 bucket.push(entry);1017 domainIndex.set(domain, bucket);1018 }1019 }1020}10211022export async function createVault(password: string): Promise<void> {1023 await ensureMigrated();10241025 const salt = generateSalt();1026 derivedKey = await deriveKey(password, salt);1027 entries = [];1028 vaultMode = "real";1029 duressConfigured = false;1030 buildDomainIndexSync(entries);10311032 1033 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1034 await generateAndWrapRootKey();1035 rootKey = unhex(rawRootKey);10361037 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1038 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");10391040 1041 1042 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);10431044 const slotA: StoredVault = {1045 version: 1,1046 salt: hex(salt),1047 nonce,1048 ciphertext,1049 tag,1050 recovery_salt: recoverySalt,1051 recovery_wrapped_root_key: wrappedRootKey,1052 recovery_nonce: wrappedNonce,1053 recovery_tag: wrappedTag,1054 ...pwRoot,1055 };1056 await writeSlot(SLOT_A, slotA);10571058 1059 1060 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1061 await stampLocalVaultMutation();10621063 touchAutoLock();1064}1065106610671068106910701071export async function createVaultFromRootKey(1072 password: string,1073 rootKeyHex: string,1074): Promise<{ vaultId: string }> {1075 if (typeof password !== "string" || password.length === 0) {1076 throw new Error("ADOPT_INVALID_PASSWORD");1077 }1078 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1079 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1080 }10811082 const rootKeyBytes = unhex(rootKeyHex);1083 if (rootKeyBytes.length !== 32) {1084 rootKeyBytes.fill(0);1085 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1086 }10871088 let nextDerivedKey: Uint8Array | null = null;1089 let sessionRootKey: Uint8Array | null = null;1090 try {1091 await ensureMigrated();1092 if ((await loadSlot(SLOT_A)) !== null) {1093 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1094 }10951096 const nextEntries: VaultEntry[] = [];1097 const nextDuressConfigured = false;1098 const vaultId = await computeVaultId(rootKeyBytes);1099 const salt = generateSalt();1100 nextDerivedKey = await deriveKey(password, salt);1101 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);11021103 sessionRootKey = new Uint8Array(rootKeyBytes);1104 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1105 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1106 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1107 const slotA: StoredVault = {1108 version: 1,1109 salt: hex(salt),1110 nonce,1111 ciphertext,1112 tag,1113 recovery_salt: recovery.recoverySalt,1114 recovery_wrapped_root_key: recovery.wrappedRootKey,1115 recovery_nonce: recovery.wrappedNonce,1116 recovery_tag: recovery.wrappedTag,1117 ...pwRoot,1118 };1119 const slotB = generateNoiseBlob(ciphertext.length / 2);1120 await commitCreatedVaultSlots(slotA, slotB);11211122 const previousDerivedKey = derivedKey;1123 const previousRootKey = rootKey;1124 derivedKey = nextDerivedKey;1125 rootKey = sessionRootKey;1126 nextDerivedKey = null;1127 sessionRootKey = null;1128 entries = nextEntries;1129 vaultMode = "real";1130 duressConfigured = nextDuressConfigured;1131 buildDomainIndexSync(entries);1132 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1133 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1134 touchAutoLock();11351136 return { vaultId };1137 } finally {1138 rootKeyBytes.fill(0);1139 if (nextDerivedKey) zeroKey(nextDerivedKey);1140 if (sessionRootKey) sessionRootKey.fill(0);1141 }1142}11431144export async function hasVault(): Promise<boolean> {1145 await ensureMigrated();1146 return (await loadSlot(SLOT_A)) !== null;1147}1148114911501151115211531154export async function isDuressEnabled(): Promise<boolean> {1155 return vaultMode === "real" && duressConfigured;1156}11571158export async function setupDuress(duressPassword: string): Promise<void> {1159 if (!derivedKey || vaultMode !== "real") {1160 throw new Error("vault must be unlocked in real mode");1161 }1162 await ensureMigrated();11631164 1165 1166 1167 const slotA = await loadSlot(SLOT_A);1168 if (!slotA) throw new Error("missing slot A");11691170 const decoySalt = generateSalt();1171 const decoyKey = await deriveKey(duressPassword, decoySalt);1172 try {1173 1174 1175 1176 1177 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1178 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1179 await writeSlot(SLOT_B, {1180 version: 1,1181 salt: hex(decoySalt),1182 nonce,1183 ciphertext,1184 tag,1185 });1186 } finally {1187 zeroKey(decoyKey);1188 }11891190 1191 1192 duressConfigured = true;1193 await persistSlotA();1194}11951196export async function disableDuress(): Promise<void> {1197 if (!derivedKey || vaultMode !== "real") {1198 throw new Error("vault must be unlocked in real mode");1199 }1200 await ensureMigrated();12011202 1203 1204 1205 const slotA = await loadSlot(SLOT_A);1206 if (slotA) {1207 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1208 }12091210 duressConfigured = false;1211 await persistSlotA();1212}12131214121512161217121812191220export async function verifyMainPassword(password: string): Promise<boolean> {1221 await ensureMigrated();1222 const slotA = await loadSlot(SLOT_A);1223 if (!slotA) return false;1224 const key = await deriveKey(password, unhex(slotA.salt));1225 try {1226 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1227 } finally {1228 zeroKey(key);1229 }1230}12311232export async function unlock(password: string): Promise<boolean> {1233 1234 1235 1236 if (await attemptUnlockWithDerive(password, deriveKey)) return true;12371238 const nfc = password.normalize("NFC");1239 if (password === nfc) return false; 12401241 if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1242 1243 1244 1245 if (vaultMode === "real") {1246 try {1247 const newSalt = generateSalt();1248 const newKey = await deriveKey(password, newSalt);1249 await changePasswordWithNewKey(newSalt, newKey);1250 } catch (healErr) {1251 console.warn("[Peach] LV-1 NFC heal failed:", healErr);1252 }1253 }1254 return true;1255 }1256 return false;1257}12581259async function attemptUnlockWithDerive(1260 password: string,1261 derive: typeof deriveKey,1262): Promise<boolean> {1263 await ensureMigrated();12641265 1266 1267 1268 1269 1270 const [slotA, slotB] = await Promise.all([1271 loadSlot(SLOT_A),1272 loadSlot(SLOT_B),1273 ]);12741275 1276 const [keyA, keyB] = await Promise.all([1277 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1278 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1279 ]);12801281 1282 1283 1284 const [resultA, resultB] = await Promise.all([1285 keyA && slotA1286 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1287 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1288 keyB && slotB1289 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1290 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1291 ]);1292 const payloadA = resultA.status === "ok" ? resultA.payload : null;1293 const payloadB = resultB.status === "ok" ? resultB.payload : null;12941295 if (payloadA && keyA) {1296 derivedKey = keyA;1297 void saveSessionKey(keyA, "real");1298 vaultMode = "real";1299 entries = payloadA.entries;1300 duressConfigured = payloadA.duressConfigured;1301 buildDomainIndexSync(entries);1302 await loadSettings();1303 touchAutoLock();1304 if (keyB) zeroKey(keyB);13051306 1307 if (slotA) {1308 const needsRecoverySetup =1309 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1310 if (!needsRecoverySetup) {1311 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1312 }1313 1314 1315 1316 1317 if (needsRecoverySetup || !rootKey) {1318 try {1319 await migrateToRecovery();1320 if (needsRecoverySetup) {1321 1322 1323 1324 await chrome.storage.local.set({ peach_recovery_pending: '1' });1325 }1326 } catch (migrationErr) {1327 console.warn('[Peach] recovery migration failed:', migrationErr);1328 }1329 }1330 }13311332 1333 1334 1335 if (payloadA.migratedFromLegacy) {1336 await persistSlotA();1337 }1338 return true;1339 }13401341 if (payloadB && keyB) {1342 derivedKey = keyB;1343 void saveSessionKey(keyB, "decoy");1344 vaultMode = "decoy";1345 entries = payloadB.entries;1346 duressConfigured = false;1347 buildDomainIndexSync(entries);1348 await loadSettings();1349 touchAutoLock();1350 if (keyA) zeroKey(keyA);1351 return true;1352 }13531354 if (keyA) zeroKey(keyA);1355 if (keyB) zeroKey(keyB);1356 1357 1358 if (resultA.status === "corrupted") {1359 lastUnlockFailureReason = resultA.reason;1360 } else if (resultB.status === "corrupted") {1361 lastUnlockFailureReason = resultB.reason;1362 }1363 return false;1364}13651366136713681369137013711372const SESSION_KEY_STORAGE = "peach_session_dk";13731374async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1375 if (!chrome.storage.session) return;1376 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1377}13781379async function clearSessionKey(): Promise<void> {1380 if (!chrome.storage.session) return;1381 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1382}13831384138513861387138813891390export async function restoreFromSession(): Promise<boolean> {1391 if (derivedKey !== null) return true;1392 if (!chrome.storage.session) return false;1393 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1394 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1395 if (!entry?.keyHex) return false;13961397 const key = unhex(entry.keyHex);1398 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1399 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1400 const slot = await loadSlot(slotKey);1401 if (!slot) { await clearSessionKey(); return false; }14021403 const result = await tryDecryptSlotResult(slot, key, aadTag);1404 if (result.status !== "ok") { await clearSessionKey(); return false; }14051406 derivedKey = key;1407 vaultMode = entry.mode;1408 entries = result.payload.entries;1409 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1410 buildDomainIndexSync(entries);1411 await loadSettings();1412 touchAutoLock();14131414 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1415 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1416 }14171418 return true;1419}14201421export function lock(): void {1422 void clearSessionKey();1423 if (derivedKey) {1424 zeroKey(derivedKey);1425 derivedKey = null;1426 }1427 if (rootKey) {1428 rootKey.fill(0);1429 rootKey = null;1430 }14311432 entries = [];1433 domainIndex = new Map();1434 vaultMode = "real";1435 duressConfigured = false;1436 clearAutoLock();1437}14381439export function isUnlocked(): boolean {1440 return derivedKey !== null;1441}1442144314441445export async function unlockWithKey(1446 masterKeyHex: string,1447): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1448 try {1449 const masterKeyBytes = unhex(masterKeyHex);1450 const stored = await loadSlot(SLOT_A);1451 if (!stored) return { ok: false, error: "invalid_key" };1452 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1453 if (!payload) return { ok: false, error: "invalid_key" };14541455 derivedKey = masterKeyBytes;1456 vaultMode = "real";1457 entries = payload.entries;1458 duressConfigured = payload.duressConfigured;1459 buildDomainIndexSync(entries);1460 await loadSettings();1461 touchAutoLock();14621463 1464 1465 1466 1467 1468 if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1469 try {1470 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1471 } catch { }1472 }1473 return { ok: true, entries };1474 } catch {1475 return { ok: false, error: "invalid_key" };1476 }1477}14781479148014811482148314841485148614871488148914901491149214931494149514961497export async function unlockFromCodex(params: {1498 slotA: string; 1499 slotB: string | null; 1500 timestamp: number;1501 masterPassphrase: string;1502}): Promise<1503 | { ok: true; entryCount: number }1504 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1505> {1506 let parsedSlotA: StoredVault;1507 let parsedSlotB: StoredVault | null = null;1508 try {1509 const rawA = JSON.parse(params.slotA) as unknown;1510 if (!hasValidVaultSchema(rawA)) {1511 return { ok: false, reason: "invalid_slot_format" };1512 }1513 parsedSlotA = rawA;1514 if (params.slotB !== null) {1515 const rawB = JSON.parse(params.slotB) as unknown;1516 if (!hasValidVaultSchema(rawB)) {1517 return { ok: false, reason: "invalid_slot_format" };1518 }1519 parsedSlotB = rawB;1520 }1521 } catch {1522 return { ok: false, reason: "invalid_slot_format" };1523 }15241525 1526 1527 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1528 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1529 if (!trialPayload) {1530 zeroKey(trialKey);1531 return { ok: false, reason: "wrong_passphrase" };1532 }1533 1534 1535 zeroKey(trialKey);15361537 1538 1539 await takeVaultSnapshot("restore");15401541 await writeSlot(SLOT_A, parsedSlotA);1542 if (parsedSlotB) {1543 await writeSlot(SLOT_B, parsedSlotB);1544 } else {1545 1546 1547 1548 await writeSlot(1549 SLOT_B,1550 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1551 );1552 }1553 await chrome.storage.local.set({1554 peach_last_modified_at: String(params.timestamp),1555 });15561557 1558 1559 const ok = await unlock(params.masterPassphrase);1560 if (!ok) {1561 1562 1563 1564 return { ok: false, reason: "wrong_passphrase" };1565 }1566 return { ok: true, entryCount: getEntries().length };1567}1568156915701571export function getDerivedKeyHex(): string {1572 if (!derivedKey) throw new Error("vault_not_unlocked");1573 return hex(derivedKey);1574}15751576157715781579158015811582158315841585158615871588158915901591export function getRootKeyHex(): string | null {1592 return rootKey ? hex(rootKey) : null;1593}15941595export function getEntries(): VaultEntry[] {1596 if (!derivedKey) throw new Error("vault_locked");1597 return entries.filter((entry) => !entry.deletedAt);1598}15991600const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;16011602export function getIdentities(): SavedIdentity[] {1603 if (!derivedKey) throw new Error("vault_locked");1604 const active = entries.filter((e) => !e.deletedAt && e.type === "login");16051606 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1607 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();16081609 for (const entry of active) {1610 const domain = extractDomain(entry.url ?? "") ?? "";1611 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;16121613 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1614 const existing = map.get(v);1615 if (!existing || modifiedAt > existing.lastUsed) {1616 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1617 } else {1618 map.set(v, { ...existing, count: existing.count + 1 });1619 }1620 }16211622 const uVal = entry.username?.trim();1623 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);16241625 1626 1627 const eVal = entry.email?.trim();1628 if (eVal) tally(emailMap, eVal);1629 }16301631 const toIdentity = (type: "email" | "username") =>1632 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1633 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1634 });16351636 return [1637 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1638 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1639 ];1640}16411642export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1643 if (!derivedKey) throw new Error("vault_locked");16441645 const now = Date.now();1646 const added: VaultEntry[] = [];1647 for (const draft of drafts) {1648 try {1649 const sanitized = sanitizeEntryDraft(draft);1650 const nextEntry: VaultEntry = {1651 ...sanitized,1652 id: crypto.randomUUID(),1653 createdAt: now,1654 modifiedAt: monotonicEntryTimestamp(),1655 };1656 entries.push(nextEntry);1657 added.push(nextEntry);1658 } catch {1659 1660 }1661 }16621663 if (added.length > 0) {1664 await persistEntries();1665 }16661667 return added;1668}166916701671167216731674167516761677export async function restoreEntries(1678 incoming: VaultEntry[],1679 mode: "replace" | "merge"1680): Promise<{ added: number; updated: number; skipped: number }> {1681 if (!derivedKey) throw new Error("vault_locked");16821683 1684 1685 1686 if (mode === "replace" && incoming.length === 0) {1687 throw new Error("refuse to replace with empty entry set");1688 }16891690 let added = 0;1691 let updated = 0;1692 let skipped = 0;16931694 if (mode === "replace") {1695 entries = [];1696 for (const entry of incoming) {1697 const normalized = sanitizeRestoredEntry(entry);1698 if (!normalized) {1699 skipped += 1;1700 continue;1701 }1702 entries.push(normalized);1703 added += 1;1704 }1705 } else {1706 const byId = new Map<string, VaultEntry>();1707 for (const e of entries) byId.set(e.id, e);17081709 for (const entry of incoming) {1710 const normalized = sanitizeRestoredEntry(entry);1711 if (!normalized) {1712 skipped += 1;1713 continue;1714 }1715 const existing = byId.get(normalized.id);1716 if (!existing) {1717 entries.push(normalized);1718 added += 1;1719 } else if (normalized.modifiedAt > existing.modifiedAt) {1720 const idx = entries.findIndex((e) => e.id === existing.id);1721 if (idx !== -1) {1722 entries[idx] = normalized;1723 updated += 1;1724 }1725 } else {1726 skipped += 1;1727 }1728 }1729 }17301731 await persistEntries("import");1732 return { added, updated, skipped };1733}173417351736173717381739174017411742function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1743 if (!entry || typeof entry !== "object") return null;1744 const title = typeof entry.title === "string" ? entry.title.trim() : "";1745 if (!title) return null;1746 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1747 const now = Date.now();1748 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1749 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;17501751 1752 1753 1754 1755 const normalized = normalizeEntry({1756 ...entry,1757 id,1758 title,1759 type: entry.type ?? "login",1760 createdAt,1761 modifiedAt,1762 });17631764 if (normalized.passkey) {1765 normalized.passkey = sanitizePasskey(normalized.passkey);1766 }1767 if (normalized.canary) {1768 normalized.canary = sanitizeCanary(normalized.canary);1769 }1770 1771 normalized.tags = sanitizeTags(normalized.tags);17721773 1774 1775 if (1776 normalized.type === "login" &&1777 !normalized.passkey &&1778 typeof normalized.password !== "string"1779 ) {1780 return null;1781 }1782 if (entry.passkey && !normalized.passkey) {1783 1784 1785 return null;1786 }17871788 return normalized;1789}1790179117921793179417951796function sanitizePasskey(pk: unknown): PasskeyData | undefined {1797 if (!pk || typeof pk !== "object") return undefined;1798 const r = pk as Record<string, unknown>;1799 if (1800 typeof r.rpId !== "string" || !r.rpId ||1801 typeof r.credentialId !== "string" || !r.credentialId ||1802 typeof r.publicKeyCose !== "string" ||1803 typeof r.userHandle !== "string" ||1804 typeof r.userName !== "string"1805 ) {1806 return undefined;1807 }1808 return {1809 rpId: r.rpId,1810 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1811 credentialId: r.credentialId,1812 publicKeyCose: r.publicKeyCose,1813 privateKeyJwk:1814 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1815 ? (r.privateKeyJwk as JsonWebKey)1816 : undefined,1817 userHandle: r.userHandle,1818 userName: r.userName,1819 userDisplayName:1820 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1821 algorithm: r.algorithm === -8 ? -8 : -7,1822 signCount: typeof r.signCount === "number" ? r.signCount : 0,1823 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1824 backupEligible: r.backupEligible === true,1825 backupState: r.backupState === true,1826 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1827 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1828 transports: Array.isArray(r.transports)1829 ? r.transports.filter((t): t is string => typeof t === "string")1830 : undefined,1831 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1832 };1833}18341835183618371838183918401841184218431844function sanitizeCanary(c: unknown): Canary | undefined {1845 if (!c || typeof c !== "object") return undefined;1846 const r = c as Record<string, unknown>;1847 if (typeof r.webhookUrl !== "string") return undefined;1848 let url: URL;1849 try {1850 url = new URL(r.webhookUrl);1851 } catch {1852 return undefined;1853 }1854 if (url.protocol !== "https:") return undefined;1855 return {1856 webhookUrl: url.toString(),1857 label: typeof r.label === "string" ? r.label : undefined,1858 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1859 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1860 };1861}18621863186418651866186718681869function sanitizeTags(tags: unknown): string[] | undefined {1870 if (!Array.isArray(tags)) return undefined;1871 const out: string[] = [];1872 for (const t of tags) {1873 if (typeof t !== "string") continue;1874 const trimmed = t.trim();1875 if (!trimmed) continue;1876 out.push(trimmed.slice(0, 256));1877 if (out.length >= 255) break;1878 }1879 return out.length > 0 ? out : undefined;1880}188118821883188418851886export async function clearVaultEntries(1887 confirmation: "REPLACE_ALL"1888): Promise<void> {1889 if (confirmation !== "REPLACE_ALL") {1890 throw new Error("clearVaultEntries requires explicit confirmation");1891 }1892 if (!derivedKey) throw new Error("vault_locked");1893 for (const entry of entries) {1894 if (!entry.deletedAt) {1895 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1896 entry.deletedAt = stamp;1897 entry.modifiedAt = stamp;1898 }1899 }1900 await persistEntries();1901}19021903export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1904 if (!derivedKey) throw new Error("vault_locked");19051906 const sanitized = sanitizeEntryDraft(entry);1907 const now = Date.now();1908 const stamp = monotonicEntryTimestamp();1909 const nextEntry: VaultEntry = {1910 ...sanitized,1911 id: crypto.randomUUID(),1912 createdAt: now,1913 modifiedAt: stamp,1914 1915 1916 passwordChangedAt: sanitized.password ? stamp : undefined,1917 };19181919 1920 1921 1922 const rollback = entries;1923 entries = [...entries, nextEntry];1924 try {1925 await persistEntries();1926 } catch (err) {1927 entries = rollback;1928 throw err;1929 }19301931 1932 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1933 checkBreach(nextEntry.password).then(async (count) => {1934 if (count > 0) {1935 const idx = entries.findIndex((e) => e.id === nextEntry.id);1936 if (idx !== -1) {1937 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1938 await persistEntries();1939 }1940 }1941 });1942 }19431944 fireSyncTrigger("vault_change");19451946 return nextEntry;1947}19481949195019511952195319541955export async function consolidatePasskeyEntries(): Promise<void> {1956 if (!derivedKey) return;1957 const { merged } = mergePasskeyOnlyIntoLogins(entries);1958 if (merged) {1959 await persistEntries();1960 }1961}19621963export async function updateEntry(1964 id: string,1965 patch: Partial<VaultEntry>1966): Promise<void> {1967 if (!derivedKey) throw new Error("vault_locked");19681969 const idx = entries.findIndex((entry) => entry.id === id);1970 if (idx === -1 || entries[idx].deletedAt) {1971 throw new Error("entry_not_found");1972 }19731974 const current = entries[idx];1975 const sanitizedPatch = sanitizeEntryPatch(patch);1976 1977 1978 1979 const passwordChanged =1980 "password" in patch &&1981 typeof sanitizedPatch.password === "string" &&1982 sanitizedPatch.password !== current.password;1983 const stamp = monotonicEntryTimestamp(current.modifiedAt);1984 1985 1986 1987 1988 1989 1990 1991 const priorPassword = current.password;1992 const shouldCapture =1993 passwordChanged &&1994 typeof priorPassword === "string" &&1995 priorPassword.length > 0 &&1996 current.passwordHistory?.[0]?.password !== priorPassword;1997 const nextHistory = shouldCapture1998 ? [1999 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2000 ...(current.passwordHistory ?? []),2001 ].slice(0, PASSWORD_HISTORY_MAX)2002 : current.passwordHistory;2003 entries[idx] = {2004 ...current,2005 ...sanitizedPatch,2006 modifiedAt: stamp,2007 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2008 };2009201020112012 2013 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2014 entries[idx] = current;2015 throw new Error("card number is required for card entries");2016 }20172018 2019 2020 2021 2022 try {2023 await persistEntries();2024 } catch (err) {2025 entries[idx] = current;2026 throw err;2027 }20282029 2030 2031 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2032 entries[idx] = { ...entries[idx], breach: undefined };2033 await persistEntries();2034 const password = entries[idx].password;2035 if (password) {2036 checkBreach(password).then(async (count) => {2037 const i = entries.findIndex((e) => e.id === id);2038 if (i !== -1 && count > 0) {2039 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2040 await persistEntries();2041 }2042 });2043 }2044 }20452046 fireSyncTrigger("vault_change");2047}20482049export async function deleteEntry(id: string): Promise<void> {2050 if (!derivedKey) throw new Error("vault_locked");20512052 const entry = entries.find((item) => item.id === id);2053 if (!entry || entry.deletedAt) {2054 throw new Error("entry_not_found");2055 }20562057 2058 2059 2060 const previousModifiedAt = entry.modifiedAt;2061 const stamp = monotonicEntryTimestamp(entry.modifiedAt);2062 entry.deletedAt = stamp;2063 entry.modifiedAt = stamp;2064 2065 2066 try {2067 await persistEntries();2068 } catch (err) {2069 delete entry.deletedAt;2070 entry.modifiedAt = previousModifiedAt;2071 throw err;2072 }2073 fireSyncTrigger("vault_change");2074}20752076export function getDeletedEntries(): VaultEntry[] {2077 if (!derivedKey) return [];2078 return entries.filter((e) => !!e.deletedAt);2079}2080208120822083208420852086export async function getVaultVersion(): Promise<number> {2087 try {2088 const raw = await chrome.storage.local.get("peach_last_modified_at");2089 const v = (raw as Record<string, unknown>).peach_last_modified_at;2090 if (typeof v === "number") return v;2091 if (typeof v === "string" && v !== "") {2092 const n = Number.parseInt(v, 10);2093 return Number.isFinite(n) ? n : 0;2094 }2095 return 0;2096 } catch {2097 return 0;2098 }2099}210021012102210321042105210621072108210921102111export async function addEntryDirect(entry: VaultEntry): Promise<void> {2112 if (!derivedKey) throw new Error("vault_locked");2113 entries = [...entries, entry];2114 await persistEntries("sync_received");2115}21162117export async function updateEntryDirect(patch: VaultEntry): Promise<void> {2118 if (!derivedKey) throw new Error("vault_locked");2119 const idx = entries.findIndex((e) => e.id === patch.id);2120 if (idx === -1) throw new Error("entry_not_found");2121 entries[idx] = { ...entries[idx], ...patch };2122 await persistEntries("sync_received");2123}21242125export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {2126 if (!derivedKey) throw new Error("vault_locked");2127 const idx = entries.findIndex((e) => e.id === id);2128 if (idx === -1) throw new Error("entry_not_found");2129 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };2130 await persistEntries("sync_received");2131}2132213321342135213621372138213921402141214221432144export async function applyMergedRemoteEntries(2145 remoteEntries: VaultEntry[],2146 remoteTombstones: Record<string, number>,2147 context: { deviceName?: string } = {},2148): Promise<{ received: number; applied: number; conflicts: number }> {2149 2150 2151 2152 2153 2154 2155 if (!derivedKey) throw new Error("vault_locked");21562157 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2158 let applied = 0;2159 let conflicts = 0;21602161 const byId = new Map<string, VaultEntry>();2162 for (const e of entries) byId.set(e.id, e);21632164 const alerts: SyncAlert[] = [];2165 const deviceName = context.deviceName ?? "paired device";2166 const mergeNow = Date.now();21672168 for (const remote of remoteEntries) {2169 const sanitized = sanitizeRestoredEntry(remote);2170 if (!sanitized) continue;2171 2172 2173 2174 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2175 if (typeof sanitized.deletedAt === "number") {2176 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2177 }2178 const tombstoneTs = remoteTombstones[sanitized.id];2179 if (tombstoneTs !== undefined && clampSyncTimestamp(tombstoneTs, mergeNow) >= sanitized.modifiedAt) {2180 continue;2181 }2182 const local = byId.get(sanitized.id);2183 if (!local) {2184 entries.push(sanitized);2185 byId.set(sanitized.id, sanitized);2186 applied += 1;2187 } else if (sanitized.modifiedAt > local.modifiedAt) {2188 const idx = entries.findIndex((e) => e.id === local.id);2189 if (idx !== -1) {2190 2191 2192 if (2193 !local.deletedAt &&2194 typeof sanitized.password === "string" &&2195 typeof local.password === "string" &&2196 sanitized.password !== local.password2197 ) {2198 alerts.push({2199 type: "REMOTE_PASSWORD_CHANGE",2200 entryId: sanitized.id,2201 entryTitle: sanitized.title,2202 deviceName,2203 timestamp: mergeNow,2204 });2205 }2206 entries[idx] = sanitized;2207 byId.set(sanitized.id, sanitized);2208 applied += 1;2209 if (!local.deletedAt) conflicts += 1;2210 }2211 }2212 }22132214 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2215 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt)) continue;2216 2217 const deletedAt = clampSyncTimestamp(rawDeletedAt, mergeNow);2218 const local = byId.get(id);2219 if (!local) continue;2220 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2221 if (deletedAt < local.modifiedAt) continue;2222 const idx = entries.findIndex((e) => e.id === id);2223 if (idx !== -1) {2224 2225 if (!local.deletedAt) {2226 alerts.push({2227 type: "REMOTE_DELETE",2228 entryId: id,2229 entryTitle: local.title,2230 deviceName,2231 timestamp: mergeNow,2232 });2233 }2234 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2235 applied += 1;2236 }2237 }22382239 if (applied > 0) {2240 await persistEntries("sync_received");2241 buildDomainIndexSync(entries);2242 }2243 2244 2245 await recordSyncAlerts(alerts);2246 return { received, applied, conflicts };2247}22482249export async function restoreEntry(id: string): Promise<void> {2250 if (!derivedKey) throw new Error("vault_locked");2251 const entry = entries.find((e) => e.id === id && e.deletedAt);2252 if (!entry) throw new Error("deleted entry not found");2253 delete entry.deletedAt;2254 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);2255 await persistEntries();2256 buildDomainIndexSync(entries);2257}22582259export async function purgeEntry(id: string): Promise<void> {2260 if (!derivedKey) throw new Error("vault_locked");2261 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2262 if (idx === -1) throw new Error("deleted entry not found");2263 entries.splice(idx, 1);2264 await persistEntries();2265}226622672268export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2269 if (!derivedKey || retentionMs === 0) return 0;2270 const cutoff = Date.now() - retentionMs;2271 const before = entries.length;2272 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2273 const purged = before - entries.length;2274 if (purged > 0) await persistEntries();2275 return purged;2276}22772278export function queryDomain(domain: string): VaultEntry[] {2279 return domainIndex.get(domain) ?? [];2280}22812282export function getEntryById(id: string): VaultEntry | undefined {2283 return entries.find((entry) => entry.id === id && !entry.deletedAt);2284}22852286async function persistSlotA(2287 reason: VaultSnapshot["reason"] = "local_save",2288): Promise<StoredVault> {2289 if (!derivedKey) throw new Error("vault_locked");22902291 const slotA = await loadSlot(SLOT_A);2292 if (!slotA) throw new Error("missing slot A");22932294 2295 2296 await takeVaultSnapshot(reason);22972298 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2299 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");23002301 2302 const nextSlotA: StoredVault = {2303 ...slotA,2304 nonce,2305 ciphertext,2306 tag,2307 2308 };2309 await writeSlot(SLOT_A, nextSlotA);23102311 2312 2313 2314 2315 2316 2317 try {2318 2319 2320 2321 if (!duressConfigured) {2322 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2323 }2324 2325 2326 2327 2328 2329 if (reason !== "sync_received") {2330 await stampLocalVaultMutation();2331 }2332 } catch (err) {2333 2334 2335 await writeSlot(SLOT_A, slotA).catch(() => {});2336 throw err;2337 }23382339 return nextSlotA;2340}23412342async function persistSlotB(): Promise<void> {2343 if (!derivedKey) throw new Error("vault_locked");23442345 const slotB = await loadSlot(SLOT_B);2346 if (!slotB) throw new Error("missing slot B");23472348 2349 2350 2351 2352 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2353 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");23542355 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2356 2357 2358 2359 try {2360 await stampLocalVaultMutation();2361 } catch (err) {2362 await writeSlot(SLOT_B, slotB).catch(() => {});2363 throw err;2364 }2365}23662367async function persistEntries(2368 reason: VaultSnapshot["reason"] = "local_save",2369): Promise<void> {2370 if (!derivedKey) throw new Error("vault_locked");23712372 if (vaultMode === "decoy") {2373 await persistSlotB();2374 } else {2375 await persistSlotA(reason);2376 }2377 buildDomainIndexSync(entries);2378}23792380function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2381 if (!Array.isArray(values)) return undefined;2382 const seen = new Set<string>();2383 const cleaned: string[] = [];2384 for (const value of values) {2385 const trimmed = typeof value === "string" ? value.trim() : "";2386 if (!trimmed) continue;2387 const key = trimmed.toLowerCase();2388 if (seen.has(key)) continue;2389 seen.add(key);2390 cleaned.push(trimmed);2391 }2392 return cleaned.length > 0 ? cleaned : undefined;2393}23942395function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2396 const title = entry.title.trim();2397 2398 2399 2400 let type: VaultEntry["type"] = entry.type ?? "login";2401 if (type === "passkey") type = "login";24022403 if (!title) throw new Error("title is required");24042405 if (entry.passkey) {2406 const p = entry.passkey;2407 if (2408 !p.rpId ||2409 !p.credentialId ||2410 !p.publicKeyCose ||2411 !p.userHandle ||2412 !p.userName ||2413 (p.algorithm !== -7 && p.algorithm !== -8)2414 ) {2415 throw new Error("invalid passkey credential");2416 }2417 }2418 if (type === "card") {2419 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2420 throw new Error("cardholder name and card number are required");2421 }2422 }24232424 return {2425 type,2426 title,2427 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2428 email: type === "login" ? entry.email?.trim() || undefined : undefined,2429 password: type === "login" ? entry.password : undefined,2430 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2431 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2432 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2433 2434 2435 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2436 totpDigits: type === "login" ? entry.totpDigits : undefined,2437 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2438 notes: entry.notes?.trim() || undefined,2439 breach: type === "login" ? entry.breach : undefined,2440 2441 2442 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2443 tags: sanitizeTags(entry.tags),2444 passkey: entry.passkey,2445 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2446 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2447 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2448 expYear: type === "card" ? entry.expYear?.trim() : undefined,2449 cvc: type === "card" ? entry.cvc?.trim() : undefined,2450 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2451 network: type === "card" ? entry.network : undefined,2452 last4: type === "card" ? entry.last4?.trim() : undefined,2453 };2454}24552456function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2457 const nextPatch: Partial<VaultEntry> = {};24582459 if ("type" in patch) {2460 nextPatch.type = patch.type;2461 }24622463 if ("title" in patch) {2464 const title = patch.title?.trim() ?? "";2465 if (!title) throw new Error("title is required");2466 nextPatch.title = title;2467 }24682469 if ("url" in patch) {2470 nextPatch.url = patch.url?.trim() ?? "";2471 }24722473 if ("additionalUrls" in patch) {2474 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2475 const seen = new Set<string>();2476 const cleaned: string[] = [];2477 for (const value of incoming) {2478 const trimmed = typeof value === "string" ? value.trim() : "";2479 if (!trimmed) continue;2480 const key = trimmed.toLowerCase();2481 if (seen.has(key)) continue;2482 seen.add(key);2483 cleaned.push(trimmed);2484 }2485 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2486 }24872488 if ("username" in patch) {2489 nextPatch.username = patch.username?.trim() ?? "";2490 }24912492 if ("email" in patch) {2493 nextPatch.email = patch.email?.trim() || undefined;2494 }24952496 if ("password" in patch) {2497 const password = patch.password ?? "";2498 2499 nextPatch.password = password || undefined;2500 }25012502 if ("totp" in patch) {2503 nextPatch.totp = patch.totp?.trim() || undefined;2504 }25052506 if ("notes" in patch) {2507 nextPatch.notes = patch.notes?.trim() || undefined;2508 }25092510 if ("breach" in patch) {2511 nextPatch.breach = patch.breach;2512 }25132514 if ("canary" in patch) {2515 2516 2517 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2518 }25192520 if ("passkey" in patch) {2521 2522 2523 2524 if (patch.passkey === undefined || patch.passkey === null) {2525 nextPatch.passkey = undefined;2526 } else {2527 const sanitized = sanitizePasskey(patch.passkey);2528 if (!sanitized) throw new Error("invalid passkey credential");2529 nextPatch.passkey = sanitized;2530 }2531 }25322533 if ("tags" in patch) {2534 2535 nextPatch.tags = sanitizeTags(patch.tags);2536 }25372538 if ("cardholderName" in patch) {2539 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2540 }2541 if ("cardNumber" in patch) {2542 const n = patch.cardNumber ?? "";2543 nextPatch.cardNumber = n || undefined;2544 }2545 if ("expMonth" in patch) {2546 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2547 }2548 if ("expYear" in patch) {2549 nextPatch.expYear = patch.expYear?.trim() || undefined;2550 }2551 if ("cvc" in patch) {2552 nextPatch.cvc = patch.cvc?.trim() || undefined;2553 }2554 if ("cardNickname" in patch) {2555 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2556 }2557 if ("network" in patch) {2558 nextPatch.network = patch.network;2559 }2560 if ("last4" in patch) {2561 nextPatch.last4 = patch.last4?.trim() || undefined;2562 }25632564 return nextPatch;2565}25662567function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2568 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2569 const modifiedAt =2570 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;25712572 const base: VaultEntry = {2573 id: entry.id,2574 type: "type" in entry ? entry.type : "login",2575 title: entry.title,2576 username: entry.username,2577 email: "email" in entry ? entry.email : undefined,2578 password: entry.password,2579 url: entry.url,2580 additionalUrls:2581 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2582 ? entry.additionalUrls2583 : undefined,2584 totp: entry.totp,2585 2586 totpAlgorithm: entry.totpAlgorithm,2587 totpDigits: entry.totpDigits,2588 totpPeriod: entry.totpPeriod,2589 notes: entry.notes,2590 breach: entry.breach,2591 2592 2593 2594 passwordChangedAt:2595 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2596 2597 2598 2599 passwordHistory:2600 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2601 ? entry.passwordHistory2602 : undefined,2603 canary: "canary" in entry ? entry.canary : undefined,2604 passkey: "passkey" in entry ? entry.passkey : undefined,2605 createdAt,2606 modifiedAt,2607 deletedAt: entry.deletedAt,2608 tags: "tags" in entry ? entry.tags : undefined,2609 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2610 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2611 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2612 expYear: "expYear" in entry ? entry.expYear : undefined,2613 cvc: "cvc" in entry ? entry.cvc : undefined,2614 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2615 network: "network" in entry ? entry.network : undefined,2616 last4: "last4" in entry ? entry.last4 : undefined,2617 };26182619 return migrateLegacyPasskey(base, entry);2620}262126222623262426252626262726282629function migrateLegacyPasskey(2630 base: VaultEntry,2631 raw: VaultEntry | LegacyVaultEntry2632): VaultEntry {2633 if (base.passkey) return base;2634 const flat = raw as Partial<VaultEntry>;2635 const isPasskeyType = base.type === "passkey";2636 const hasFlatPasskeyFields =2637 typeof flat.rpId === "string" &&2638 typeof flat.credentialId === "string" &&2639 typeof flat.publicKeyCose === "string" &&2640 typeof flat.userHandle === "string" &&2641 typeof flat.userName === "string";2642 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2643 if (!hasFlatPasskeyFields) return base;26442645 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2646 base.passkey = {2647 rpId: flat.rpId!,2648 rpName: flat.rpName ?? flat.rpId!,2649 credentialId: flat.credentialId!,2650 publicKeyCose: flat.publicKeyCose!,2651 privateKeyJwk: flat.privateKeyJwk,2652 userHandle: flat.userHandle!,2653 userName: flat.userName!,2654 userDisplayName: flat.userDisplayName ?? flat.userName!,2655 algorithm,2656 signCount: flat.signCount ?? 0,2657 aaguid: flat.aaguid ?? "",2658 backupEligible: flat.backupEligible === true,2659 backupState: flat.backupState === true,2660 lastUsedAt: flat.lastUsedAt,2661 browser: flat.browser,2662 transports: flat.transports,2663 createdAt: base.createdAt,2664 };2665 2666 2667 2668 if (isPasskeyType) base.type = "login";2669 if (!base.username) base.username = flat.userName;2670 if (!base.url) base.url = `https://${flat.rpId}`;2671 return base;2672}2673267426752676267726782679function entryDupeKey(e: VaultEntry): string {2680 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2681 2682 2683 2684 2685 if (e.passkey) {2686 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2687 }2688 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2689}269026912692export function countDuplicates(): number {2693 const best = new Map<string, VaultEntry>();2694 let dupeCount = 0;2695 for (const e of entries.filter((e) => !e.deletedAt)) {2696 const key = entryDupeKey(e);2697 const existing = best.get(key);2698 if (!existing) {2699 best.set(key, e);2700 } else {2701 dupeCount++;2702 2703 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2704 best.set(key, e);2705 }2706 }2707 }2708 return dupeCount;2709}271027112712271327142715export async function deduplicateEntries(): Promise<number> {2716 if (!derivedKey) throw new Error("vault_locked");2717 2718 2719 if (vaultMode === "decoy") {2720 throw new Error("deduplicate disabled in decoy mode");2721 }27222723 const best = new Map<string, VaultEntry>();2724 for (const e of entries.filter((en) => !en.deletedAt)) {2725 const key = entryDupeKey(e);2726 const existing = best.get(key);2727 if (!existing) {2728 best.set(key, e);2729 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2730 best.set(key, e);2731 }2732 }27332734 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2735 let removed = 0;2736 entries = entries.map((e) => {2737 if (!e.deletedAt && !keepIds.has(e.id)) {2738 removed++;2739 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2740 }2741 return e;2742 });27432744 if (removed > 0) await persistEntries();2745 return removed;2746}274727482749275027512752275327542755const HISTORY_KEY = "peach_vault_history";2756const HISTORY_MAX = 5;275727582759276027612762function bucketEntryCount(n: number): number {2763 if (n <= 0) return 0;2764 if (n <= 10) return 10;2765 if (n <= 25) return 25;2766 if (n <= 50) return 50;2767 if (n <= 100) return 100;2768 if (n <= 250) return 250;2769 if (n <= 500) return 500;2770 return Math.ceil(n / 500) * 500;2771}27722773277427752776277727782779export async function takeVaultSnapshot(2780 reason: VaultSnapshot["reason"],2781): Promise<void> {2782 try {2783 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2784 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2785 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];27862787 2788 2789 2790 if (!slotA) return;27912792 const vault_a = JSON.stringify(slotA);2793 const vault_b = JSON.stringify(slotB ?? slotA);27942795 2796 2797 2798 2799 2800 2801 let entryCount: number | null = null;2802 if (derivedKey && vaultMode === "real") {2803 try {2804 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2805 } catch {2806 entryCount = null;2807 }2808 }28092810 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2811 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2812 ? existingRaw2813 : { snapshots: [] };28142815 2816 2817 2818 const latest = history.snapshots[0];2819 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2820 return;2821 }28222823 const snapshot: VaultSnapshot = {2824 id: crypto.randomUUID(),2825 savedAt: Date.now(),2826 reason,2827 vault_a,2828 vault_b,2829 entryCount,2830 };28312832 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2833 await chrome.storage.local.set({ [HISTORY_KEY]: history });2834 } catch (err) {2835 2836 2837 2838 2839 console.warn('[Peach] takeVaultSnapshot failed:', err);2840 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2841 try {2842 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2843 } catch {2844 2845 }2846 }2847 }2848}284928502851export async function clearVaultHistory(): Promise<void> {2852 try {2853 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2854 } catch {2855 2856 }2857}285828592860export async function getVaultHistory(): Promise<VaultSnapshot[]> {2861 const raw = await chrome.storage.local.get(HISTORY_KEY);2862 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2863 if (!history || !Array.isArray(history.snapshots)) return [];2864 return history.snapshots;2865}28662867286828692870287128722873export async function restoreVaultSnapshot(2874 snapshotId: string,2875): Promise<{ ok: true } | { ok: false; error: string }> {2876 const history = await getVaultHistory();2877 const snapshot = history.find((s) => s.id === snapshotId);2878 if (!snapshot) return { ok: false, error: "snapshot_not_found" };28792880 2881 2882 2883 await takeVaultSnapshot("restore");28842885 let restoredA: StoredVault;2886 let restoredB: StoredVault;2887 try {2888 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2889 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2890 } catch {2891 return { ok: false, error: "snapshot_corrupt" };2892 }28932894 await writeSlot(SLOT_A, restoredA);2895 await writeSlot(SLOT_B, restoredB);2896 await stampLocalVaultMutation();28972898 2899 2900 2901 lock();29022903 return { ok: true };2904}290529062907290829092910291129122913291429152916291729182919export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2920 if (!derivedKey || vaultMode !== "real") {2921 return { ok: false, error: "vault_locked" };2922 }2923 const slotA = await loadSlot(SLOT_A);2924 if (!slotA) return { ok: false, error: "missing_slot_a" };29252926 2927 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2928 await generateAndWrapRootKey();29292930 2931 const newRootKey = unhex(rawRootKey);2932 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);29332934 2935 2936 2937 2938 await takeVaultSnapshot("local_save");2939 const nextSlotA: StoredVault = {2940 ...slotA,2941 recovery_salt: recoverySalt,2942 recovery_wrapped_root_key: wrappedRootKey,2943 recovery_nonce: wrappedNonce,2944 recovery_tag: wrappedTag,2945 ...pwRoot,2946 };2947 await writeSlot(SLOT_A, nextSlotA);29482949 2950 if (rootKey) zeroKey(rootKey);2951 rootKey = newRootKey;29522953 2954 await clearPairedDevices();29552956 2957 await setRecoveryConfirmPending(); 29582959 await stampLocalVaultMutation();2960 return { ok: true };2961}29622963296429652966296729682969export async function changePassword(2970 currentPassword: string,2971 newPassword: string2972): Promise<boolean> {2973 if (!derivedKey || vaultMode !== "real") return false;29742975 2976 const valid = await verifyMainPassword(currentPassword);2977 if (!valid) return false;29782979 const slotA = await loadSlot(SLOT_A);2980 if (!slotA) return false;2981 if (!rootKey) return false;29822983 2984 const newSalt = generateSalt();2985 const newKey = await deriveKey(newPassword, newSalt);29862987 2988 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2989 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");29902991 2992 2993 2994 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);29952996 const finalSlotA: StoredVault = {2997 ...slotA,2998 salt: hex(newSalt),2999 nonce,3000 ciphertext,3001 tag,3002 ...pwRoot,3003 };30043005 3006 3007 3008 await takeVaultSnapshot("local_save");30093010 3011 3012 3013 3014 await writeSlot(SLOT_A, finalSlotA);30153016 3017 3018 3019 zeroKey(derivedKey);3020 derivedKey = newKey;30213022 3023 3024 3025 3026 void saveSessionKey(newKey, "real");30273028 3029 3030 3031 if (!duressConfigured) {3032 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3033 }3034 await stampLocalVaultMutation();30353036 return true;3037}