/** * types.ts — cross-boundary types and the `MessageType` union. * * `MessageType` mixes background-bound and content-script-bound messages. * Background-bound types have a case in `src/background/messages.ts`. * Content-script-bound types (PING, SUMMON_AUTOFILL, VAULT_LOCKED, * RESTART_AUTO_LOCK, etc.) are sent *to* content scripts or the popup and * have no handler in messages.ts by design — that absence is not a missing * handler. Add new cross-boundary types here, not inline in call sites. */ export interface ManualSummonRecord { firstSummonedAt: number; summonCount: number; lastSummonedAt: number; } export interface Canary { webhookUrl: string; label?: string; lastPinged?: number; pingCount?: number; } export type CardNetwork = "visa" | "mastercard" | "amex" | "discover" | "unknown"; /** * Passkey credential data attached to a VaultEntry. New in v2.11.0 — passkeys * are no longer a separate entry type; instead they hang off any login entry. * A passkey-only entry is a `type:"login"` entry with no username/password * but a `passkey` sub-object set. A login that ALSO has a passkey is just a * normal login with `passkey` populated. * * privateKeyJwk is present only on Chrome-created passkeys, where Peach is * the authenticator. Firefox-created passkeys delegate to the platform * authenticator, so the field is absent — `browser` distinguishes the two. */ export interface PasskeyData { rpId: string; rpName: string; credentialId: string; publicKeyCose: string; privateKeyJwk?: JsonWebKey; userHandle: string; userName: string; userDisplayName: string; algorithm: -7 | -8; signCount: number; aaguid: string; backupEligible: boolean; backupState: boolean; lastUsedAt?: number; browser?: "chrome" | "firefox"; transports?: string[]; /** When the credential was first registered with Peach. */ createdAt: number; } export interface VaultEntry { id: string; // crypto.randomUUID() /** * Entry type. As of v2.11.0 passkeys are stored on `type:"login"` entries * via the `passkey` sub-object — `"passkey"` here is a legacy value still * accepted on read but rewritten to `"login"` on next vault save. */ type?: "login" | "note" | "passkey" | "card"; title: string; username?: string; // only for login entries email?: string; // separate email address — for sites that require both a username and an email password?: string; // only for login entries url?: string; // only for login entries /** * Additional sites this entry should match for autofill. Login entries only. * Each value is a full origin or URL (e.g. `https://login.foo.com`). * The vault domain index keys against the hostname of every entry in this * list, in addition to `url`. */ additionalUrls?: string[]; totp?: string; // base32 seed, stored encrypted — only for login entries notes?: string; breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries /** * When the password field was last changed. Set on first-write of `password` * and refreshed on every subsequent change. Defaults to `createdAt` when * legacy entries are read by the advanced health analyser. */ passwordChangedAt?: number; canary?: Canary; createdAt: number; // Date.now() modifiedAt: number; deletedAt?: number; tags?: string[]; /** Passkey credential attached to this entry, if any. */ passkey?: PasskeyData; // ── Legacy passkey flat fields (pre-v2.11.0). Populated by vault read // migration so that the old import/sanitize paths keep typing; new code // should always read `entry.passkey?.*` instead. The migration drops these // on next vault save. Do not introduce new readers of these fields. rpId?: string; rpName?: string; credentialId?: string; publicKeyCose?: string; privateKeyJwk?: JsonWebKey; userHandle?: string; userName?: string; userDisplayName?: string; algorithm?: -7 | -8; signCount?: number; aaguid?: string; backupEligible?: boolean; backupState?: boolean; lastUsedAt?: number; browser?: "chrome" | "firefox"; transports?: string[]; // Card entry fields cardholderName?: string; cardNumber?: string; // full PAN, stored encrypted in vault expMonth?: string; expYear?: string; cvc?: string; cardNickname?: string; network?: CardNetwork; last4?: string; } /** * Returns true when an entry has a passkey credential attached. After v2.11.0 * migration this is the canonical check; pre-migration entries with a flat * `rpId`/`credentialId` shape get rewritten by the vault read pass before * any consumer sees them. */ export function hasPasskey(entry: VaultEntry): boolean { return entry.passkey !== undefined && entry.passkey.credentialId.length > 0; } /** * Returns true when an entry is "passkey-only" — has a passkey credential * but no password to fill into a form. Login fields hide for these entries * and the inline autofill selector filters them out (signing in via the * passkey is a separate WebAuthn flow). Username may still be populated * (the migration mirrors passkey.userName onto the entry's username so the * domain-index/search keep working) and is not part of this discriminator. */ export function isPasskeyOnly(entry: VaultEntry): boolean { return hasPasskey(entry) && !entry.password; } export interface LegacyVaultEntry { id: string; title: string; username: string; password: string; url: string; totp?: string; notes?: string; breach?: number; created: number; updated: number; deletedAt?: number; } export interface CardEntry extends VaultEntry { type: "card"; cardholderName: string; cardNumber: string; expMonth: string; expYear: string; cvc: string; } export interface PendingPasskeyRequest { requestId: number; type: "create" | "get"; rpId: string; rpName: string; userName: string; userDisplayName?: string; /** * Firefox path: when present, the popup performs the navigator.credentials * call itself rather than completing a chrome.webAuthenticationProxy * request. Absent for Chrome (the existing webAuthenticationProxy flow). */ browser?: "chrome" | "firefox"; /** Firefox create: base64url challenge minted by the popup-context flow. */ challenge?: string; /** Firefox create: base64url user handle. Generated if absent. */ userHandle?: string; } export interface PasskeyProviderState { supported: boolean; enabled: boolean; attached: boolean; browserLabel: string; statusLabel: string; conflict?: string; } export interface StoredVault { version: 1; // AAD for AES-GCM --- prevents downgrade salt: string; // hex --- 16 bytes, CSPRNG, stored not secret nonce: string; // hex --- 12 bytes, CSPRNG, fresh per write ciphertext: string; // hex --- AES-256-GCM output tag: string; // hex --- 16-byte GCM auth tag // Recovery escrow: root key wrapped under recovery words via AES-256-GCM. // Absent on vaults created before v2.12 (migrateToRecovery adds them on first unlock). recovery_salt?: string; // hex --- 16 bytes, CSPRNG, HKDF salt for recovery key recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above recovery_tag?: string; // hex --- 16-byte GCM auth tag for the above // Second wrapping of the same root key under the password-derived key. // Lets unlock() recover the rootKey bytes without the user's recovery // words, so GET_RECOVERY_WORDS works across lock/unlock cycles. Refreshed // on every password change so the words stay valid (H2/COR-006). pw_root_ciphertext?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key (hex-encoded) pw_root_nonce?: string; // hex --- 12-byte nonce pw_root_tag?: string; // hex --- 16-byte GCM auth tag } // Background-bound message types — sent from popup/content scripts, // handled in src/background/messages.ts. Everything in this group must // have a matching case in that file's switch statement. export type MessageType = | "UNLOCK" | "LOCK" | "IS_UNLOCKED" | "HAS_VAULT" | "GET_ENTRIES" | "ADD_ENTRY" | "BATCH_ADD_ENTRIES" | "RESTORE_ENTRIES" | "CLEAR_VAULT_ENTRIES" | "UPDATE_ENTRY" | "ADD_ADDITIONAL_URL" | "DELETE_ENTRY" | "AUTOFILL_QUERY" | "COPY" | "CREATE_VAULT" | "GET_ENTRY_FOR_FILL" | "CHECK_ALL_BREACHES" | "GET_SETTINGS" | "UPDATE_SETTINGS" | "FIRE_CANARY_PING" | "ACTIVATE_LICENSE" | "GET_LICENSE" | "GET_LICENSE_KEY" | "IS_DURESS_ENABLED" | "SETUP_DURESS" | "DISABLE_DURESS" | "VERIFY_MAIN_PASSWORD" | "CHANGE_PASSWORD" | "GET_SITE_PASSWORD_POLICY" | "CHIRP_GET_LOCAL_IDENTITY" | "CHIRP_COMPLETE_PAIRING" | "CHIRP_LIST_PAIRED_DEVICES" | "CHIRP_UNPAIR_DEVICE" | "CHIRP_RENAME_LOCAL_DEVICE" | "GET_PENDING_PASSKEY" | "APPROVE_PASSKEY" | "DENY_PASSKEY" | "GET_PASSKEY_PROVIDER_STATE" | "SET_PASSKEY_PROVIDER_ENABLED" | "GET_DELETED_ENTRIES" | "RESTORE_ENTRY" | "PURGE_ENTRY" | "PURGE_EXPIRED_TRASH" | "GET_VAULT_HISTORY" | "RESTORE_VAULT_SNAPSHOT" | "COUNT_DUPLICATES" | "DEDUPLICATE_ENTRIES" | "RECORD_MANUAL_SUMMON" | "DECLINE_CARD_SAVE" | "IS_CARD_DECLINED" | "SUBMIT_FEEDBACK" | "GET_VAULT_STATUS" | "GET_ADVANCED_HEALTH_REPORT" | "INITIATE_PASSWORD_CHANGE" | "DEACTIVATE_LICENSE" | "GET_IDENTITIES" | "DESTROY_VAULT" | "INITIATE_FIREFOX_PASSKEY_CREATE" | "COMPLETE_FIREFOX_PASSKEY_CREATE" | "PASSKEY_OFFER_DECLINE" | "GET_PASSKEY_OFFER_DECLINED" | "GET_PASSKEY_FOR_DOMAIN" // Firefox content-script proxy path (v2.12.0). Sent FROM the page-world // monkey-patch through the extension-world relay TO the background. See // src/shared/passkey-flags.ts for removal conditions. | "PASSKEY_HAS_CREDENTIALS" | "PASSKEY_CREATE" | "PASSKEY_GET" // Android / iOS message types — activate only when Capacitor.isNativePlatform() is true. | "UNLOCK_WITH_KEY" | "GET_DERIVED_KEY" // Recovery word messages | "VALIDATE_RECOVERY_WORDS" | "RECOVER_WITH_WORDS" | "GET_RECOVERY_WORDS" | "GET_IDENTITY_SUGGESTIONS" | "TOUCH_AUTO_LOCK" | "POPULATE_INLINE_VAULT" | "SAVE_NOTE" | "DELETE_NOTE" | "GET_NOTES" | "SEARCH_VAULT" | "ML_INIT" | "ML_PREDICT_BATCH" // Content-script-bound message types — sent BY the background TO content // scripts. They never reach messages.ts's switch. Kept in this union so // existing call sites type-check; do not add cases for them in the // background handler. | "PING" // background → content: liveness probe | "SUMMON_AUTOFILL" // background → content: open the in-page palette | "VAULT_LOCKED" // background → content/popup: vault just locked | "VAULT_UNLOCKED" // background → content/popup: vault just unlocked | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section | "SHOW_PASSKEY_TOAST" // background → content tab: show passkey auth toast | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast | "SHOULD_PLAY_CELEBRATION" | "MARK_CELEBRATION_PLAYED" // Peach Codex (v2.42.0). Generation runs in the popup — the SW only // sources vault data and applies the restored slots. | "GET_CODEX_VAULT_DATA" | "ESTIMATE_CODEX_PAGES" | "GENERATE_CODEX" | "RESTORE_FROM_CODEX"; export type ProFeature = | "sync" | "passkeys" | "advanced_health" | "smart_password_change" | "ios_app" | "codex"; /** * License export shape included in PeachScript and encrypted JSON backups. * `celebrationPlayed` is intentionally absent — a new device deserves the moment. */ export interface PeachLicenseExport { key: string; activatedAt?: number; } export interface AdvancedHealthEntryReport { id: string; title: string; domain: string; url?: string; score: number; ageDays: number; reasons: string[]; factors: { breached: boolean; breachCount: number; reused: boolean; aged: boolean; weak: boolean; missingTotpOnSensitive: boolean; }; } export interface AdvancedHealthReport { vaultScore: number; generatedAt: number; entryCount: number; reusedGroups: number; agedCount: number; remediation: AdvancedHealthEntryReport[]; } export const ACCENT_COLORS = [ { name: "peach", hex: "#F49C70" }, { name: "rose", hex: "#FF8AA6" }, { name: "plum", hex: "#D597F2" }, { name: "lavender", hex: "#A6A8FF" }, { name: "sky", hex: "#5BC8EE" }, { name: "mint", hex: "#5EE0B0" }, { name: "sage", hex: "#B5D870" }, { name: "butter", hex: "#FFD45A" }, { name: "sand", hex: "#EDBC85" }, { name: "clay", hex: "#F08660" }, { name: "slate", hex: "#80BEDE" }, { name: "stone", hex: "#C8B8A8" }, ] as const; export type AccentColorName = typeof ACCENT_COLORS[number]["name"]; export interface SavedIdentity { value: string; type: "email" | "username"; lastUsed: number; useCount: number; exampleDomain?: string; } export interface PasswordPolicy { minLength?: number; maxLength?: number; requiresUppercase?: boolean; requiresLowercase?: boolean; requiresNumbers?: boolean; requiresSymbols?: boolean; forbidsSymbols?: boolean; allowedSymbols?: string; /** Characters the site explicitly disallows. Filtered from every pool before generation. */ forbiddenChars?: string; } /** * A single vault history snapshot. The `vault_a` and `vault_b` fields are the * raw encrypted blobs from chrome.storage.local — already AES-256-GCM * protected by the master password, so storing them in history adds no * security surface. * * Snapshots are taken BEFORE every vault write so the saved blob represents * what is about to be lost, not what was just written. The reason field * documents the trigger so the UI can render "saved" / "synced" / "imported" * / "restored" labels next to each row. */ export interface VaultSnapshot { id: string; savedAt: number; reason: "local_save" | "sync_received" | "import" | "restore"; vault_a: string; vault_b: string; /** Active entry count when the snapshot was taken; null if vault was locked. */ entryCount: number | null; } export interface VaultHistory { /** Newest first; capped at HISTORY_MAX (10). */ snapshots: VaultSnapshot[]; } export interface Message { type: MessageType; payload?: unknown; } // Canonical snake_case error codes returned by message handlers. Keep this // union the authoritative catalogue — new handlers must reuse an entry or // add one here. Free-form strings still appear (network/operation failures // surface their raw .message), so error stays string at runtime; the union // is the documented set callers should switch on. export type ErrorCode = | "vault_locked" | "vault_corrupted" | "no_vault" | "entry_not_found" | "invalid_payload" | "invalid_message" | "invalid_sender" | "wrong_passphrase" | "incorrect_current_password" | "recovery_not_setup" | "android_only" | "not_available" | "pro_required" | "pairing_not_verified" | "sync_rejected_unpaired_initiator" | "vault_not_applied" | "url_required" | "domain_required" | "rpid_required" | "rpid_and_username_required" | "publickeyhex_required" | "missing_payload" | "login_entries_only" | "passkey_not_fillable" | "invalid_webhook_url" | "invalid_feedback_endpoint" | "message_empty" | "network_error" | "no_reset_url" | "failed_to_open_tab" | "operation_failed" | "unknown_message_type"; export interface MessageResponse { ok: boolean; data?: unknown; error?: string; unlocked?: boolean; // Set on UNLOCK responses when the vault decrypted but failed schema // validation (M7/COR-007). The popup may offer a history-restore flow. canRestoreFromHistory?: boolean; }