types.ts

17.3 KB · raw

1/**2 * types.ts — cross-boundary types and the `MessageType` union.3 *4 * `MessageType` mixes background-bound and content-script-bound messages.5 * Background-bound types have a case in `src/background/messages.ts`.6 * Content-script-bound types (PING, SUMMON_AUTOFILL, VAULT_LOCKED,7 * RESTART_AUTO_LOCK, etc.) are sent *to* content scripts or the popup and8 * have no handler in messages.ts by design — that absence is not a missing9 * handler. Add new cross-boundary types here, not inline in call sites.10 */1112export interface ManualSummonRecord {13  firstSummonedAt: number;14  summonCount: number;15  lastSummonedAt: number;16}1718export interface Canary {19  webhookUrl: string;20  label?: string;21  lastPinged?: number;22  pingCount?: number;23}2425export type CardNetwork = "visa" | "mastercard" | "amex" | "discover" | "unknown";2627/**28 * Passkey credential data attached to a VaultEntry. New in v2.11.0 — passkeys29 * are no longer a separate entry type; instead they hang off any login entry.30 * A passkey-only entry is a `type:"login"` entry with no username/password31 * but a `passkey` sub-object set. A login that ALSO has a passkey is just a32 * normal login with `passkey` populated.33 *34 * privateKeyJwk is present only on Chrome-created passkeys, where Peach is35 * the authenticator. Firefox-created passkeys delegate to the platform36 * authenticator, so the field is absent — `browser` distinguishes the two.37 */38export interface PasskeyData {39  rpId: string;40  rpName: string;41  credentialId: string;42  publicKeyCose: string;43  privateKeyJwk?: JsonWebKey;44  userHandle: string;45  userName: string;46  userDisplayName: string;47  algorithm: -7 | -8;48  signCount: number;49  aaguid: string;50  backupEligible: boolean;51  backupState: boolean;52  lastUsedAt?: number;53  browser?: "chrome" | "firefox";54  transports?: string[];55  /** When the credential was first registered with Peach. */56  createdAt: number;57}5859/** One past password kept in an entry's {@link VaultEntry.passwordHistory}. */60export interface PasswordHistoryItem {61  /** The old password value, replaced by a newer one. */62  password: string;63  /**64   * When this password stopped being current — i.e. the `passwordChangedAt`65   * (or `createdAt`) it carried at the moment it was swapped out.66   */67  changedAt: number;68}6970export interface VaultEntry {71  id: string; // crypto.randomUUID()72  /**73   * Entry type. As of v2.11.0 passkeys are stored on `type:"login"` entries74   * via the `passkey` sub-object — `"passkey"` here is a legacy value still75   * accepted on read but rewritten to `"login"` on next vault save.76   */77  type?: "login" | "note" | "passkey" | "card";78  title: string;79  username?: string; // only for login entries80  email?: string; // separate email address — for sites that require both a username and an email81  password?: string; // only for login entries82  url?: string; // only for login entries83  /**84   * Additional sites this entry should match for autofill. Login entries only.85   * Each value is a full origin or URL (e.g. `https://login.foo.com`).86   * The vault domain index keys against the hostname of every entry in this87   * list, in addition to `url`.88   */89  additionalUrls?: string[];90  totp?: string; // base32 seed, stored encrypted — only for login entries91  notes?: string;92  breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries93  /**94   * When the password field was last changed. Set on first-write of `password`95   * and refreshed on every subsequent change. Defaults to `createdAt` when96   * legacy entries are read by the advanced health analyser.97   */98  passwordChangedAt?: number;99  /**100   * Previously-used passwords for this entry, newest first. A snapshot of the101   * old password is pushed here automatically whenever the password changes102   * (see `updateEntry` in vault.ts). Capped at PASSWORD_HISTORY_MAX. Lets the103   * user reveal, copy, or restore a password they used before — handy when a104   * site rejects a rotation or an old credential resurfaces.105   */106  passwordHistory?: PasswordHistoryItem[];107  canary?: Canary;108  createdAt: number; // Date.now()109  modifiedAt: number;110  deletedAt?: number;111  tags?: string[];112  /** Passkey credential attached to this entry, if any. */113  passkey?: PasskeyData;114  // ── Legacy passkey flat fields (pre-v2.11.0). Populated by vault read115  // migration so that the old import/sanitize paths keep typing; new code116  // should always read `entry.passkey?.*` instead. The migration drops these117  // on next vault save. Do not introduce new readers of these fields.118  rpId?: string;119  rpName?: string;120  credentialId?: string;121  publicKeyCose?: string;122  privateKeyJwk?: JsonWebKey;123  userHandle?: string;124  userName?: string;125  userDisplayName?: string;126  algorithm?: -7 | -8;127  signCount?: number;128  aaguid?: string;129  backupEligible?: boolean;130  backupState?: boolean;131  lastUsedAt?: number;132  browser?: "chrome" | "firefox";133  transports?: string[];134  // Card entry fields135  cardholderName?: string;136  cardNumber?: string;       // full PAN, stored encrypted in vault137  expMonth?: string;138  expYear?: string;139  cvc?: string;140  cardNickname?: string;141  network?: CardNetwork;142  last4?: string;143}144145/**146 * Returns true when an entry has a passkey credential attached. After v2.11.0147 * migration this is the canonical check; pre-migration entries with a flat148 * `rpId`/`credentialId` shape get rewritten by the vault read pass before149 * any consumer sees them.150 */151export function hasPasskey(entry: VaultEntry): boolean {152  return entry.passkey !== undefined && entry.passkey.credentialId.length > 0;153}154155/**156 * Returns true when an entry is "passkey-only" — has a passkey credential157 * but no password to fill into a form. Login fields hide for these entries158 * and the inline autofill selector filters them out (signing in via the159 * passkey is a separate WebAuthn flow). Username may still be populated160 * (the migration mirrors passkey.userName onto the entry's username so the161 * domain-index/search keep working) and is not part of this discriminator.162 */163export function isPasskeyOnly(entry: VaultEntry): boolean {164  // An entry is "passkey only" when it holds SOLELY a passkey credential —165  // no password, no TOTP, and no dedicated email. Username is intentionally166  // excluded (the migration copies passkey.userName onto username for search).167  // Entries with passkey + TOTP or passkey + email are NOT passkey-only: they168  // carry fillable credentials and must show the full login-fields UI.169  return hasPasskey(entry) && !entry.password && !entry.totp && !entry.email;170}171172export interface LegacyVaultEntry {173  id: string;174  title: string;175  username: string;176  password: string;177  url: string;178  totp?: string;179  notes?: string;180  breach?: number;181  created: number;182  updated: number;183  deletedAt?: number;184}185186export interface CardEntry extends VaultEntry {187  type: "card";188  cardholderName: string;189  cardNumber: string;190  expMonth: string;191  expYear: string;192  cvc: string;193}194195196export interface PendingPasskeyRequest {197  requestId: number;198  type: "create" | "get";199  rpId: string;200  rpName: string;201  userName: string;202  userDisplayName?: string;203  /**204   * Firefox path: when present, the popup performs the navigator.credentials205   * call itself rather than completing a chrome.webAuthenticationProxy206   * request. Absent for Chrome (the existing webAuthenticationProxy flow).207   */208  browser?: "chrome" | "firefox";209  /** Firefox create: base64url challenge minted by the popup-context flow. */210  challenge?: string;211  /** Firefox create: base64url user handle. Generated if absent. */212  userHandle?: string;213}214215export interface PasskeyProviderState {216  supported: boolean;217  enabled: boolean;218  attached: boolean;219  browserLabel: string;220  statusLabel: string;221  conflict?: string;222}223224export interface StoredVault {225  version: 1; // AAD for AES-GCM --- prevents downgrade226  salt: string; // hex --- 16 bytes, CSPRNG, stored not secret227  nonce: string; // hex --- 12 bytes, CSPRNG, fresh per write228  ciphertext: string; // hex --- AES-256-GCM output229  tag: string; // hex --- 16-byte GCM auth tag230  // Recovery escrow: root key wrapped under recovery words via AES-256-GCM.231  // Absent on vaults created before v2.12 (migrateToRecovery adds them on first unlock).232  recovery_salt?: string; // hex --- 16 bytes, CSPRNG, HKDF salt for recovery key233  recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key234  recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above235  recovery_tag?: string; // hex --- 16-byte GCM auth tag for the above236  // Second wrapping of the same root key under the password-derived key.237  // Lets unlock() recover the rootKey bytes without the user's recovery238  // words, so GET_RECOVERY_WORDS works across lock/unlock cycles. Refreshed239  // on every password change so the words stay valid (H2/COR-006).240  pw_root_ciphertext?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key (hex-encoded)241  pw_root_nonce?: string; // hex --- 12-byte nonce242  pw_root_tag?: string; // hex --- 16-byte GCM auth tag243}244245// Background-bound message types — sent from popup/content scripts,246// handled in src/background/messages.ts. Everything in this group must247// have a matching case in that file's switch statement.248export type MessageType =249  | "GET_SYNC_ALERTS"250  | "CLEAR_CLIPBOARD"251  | "GET_PENDING_VOUCHED_DEVICES"252  | "CONFIRM_VOUCHED_DEVICE"253  | "REJECT_VOUCHED_DEVICE"254  | "VOUCHED_DEVICE_PENDING"255  | "ROTATE_SYNC_IDENTITY"256  | "DEVICE_KEY_ROTATED"257  | "UNLOCK"258  | "LOCK"259  | "IS_UNLOCKED"260  | "HAS_VAULT"261  | "GET_ENTRIES"262  | "ADD_ENTRY"263  | "BATCH_ADD_ENTRIES"264  | "RESTORE_ENTRIES"265  | "CLEAR_VAULT_ENTRIES"266  | "UPDATE_ENTRY"267  | "ADD_ADDITIONAL_URL"268  | "DELETE_ENTRY"269  | "AUTOFILL_QUERY"270  | "COPY"271  | "CREATE_VAULT"272  | "GET_ENTRY_FOR_FILL"273  | "GET_ENTRY_FOR_EDIT"274  | "CHECK_ALL_BREACHES"275  | "GET_SETTINGS"276  | "UPDATE_SETTINGS"277  | "FIRE_CANARY_PING"278  | "ACTIVATE_LICENSE"279  | "GET_LICENSE"280  | "GET_LICENSE_KEY"281  | "IS_DURESS_ENABLED"282  | "SETUP_DURESS"283  | "DISABLE_DURESS"284  | "VERIFY_MAIN_PASSWORD"285  | "CHANGE_PASSWORD"286  | "GET_SITE_PASSWORD_POLICY"287  | "CHIRP_GET_LOCAL_IDENTITY"288  | "CHIRP_COMPLETE_PAIRING"289  | "CHIRP_LIST_PAIRED_DEVICES"290  | "CHIRP_UNPAIR_DEVICE"291  | "CHIRP_RENAME_LOCAL_DEVICE"292  | "TRIGGER_SYNC"293  | "GET_SYNC_STATE"294  | "GET_PENDING_PASSKEY"295  | "APPROVE_PASSKEY"296  | "DENY_PASSKEY"297  | "GET_PASSKEY_PROVIDER_STATE"298  | "SET_PASSKEY_PROVIDER_ENABLED"299  | "GET_DELETED_ENTRIES"300  | "RESTORE_ENTRY"301  | "PURGE_ENTRY"302  | "PURGE_EXPIRED_TRASH"303  | "GET_VAULT_HISTORY"304  | "RESTORE_VAULT_SNAPSHOT"305  | "COUNT_DUPLICATES"306  | "DEDUPLICATE_ENTRIES"307  | "RECORD_MANUAL_SUMMON"308  | "DECLINE_CARD_SAVE"309  | "IS_CARD_DECLINED"310  | "SUBMIT_FEEDBACK"311  | "GET_VAULT_STATUS"312  | "GET_ADVANCED_HEALTH_REPORT"313  | "DEACTIVATE_LICENSE"314  | "GET_IDENTITIES"315  | "DESTROY_VAULT"316  | "INITIATE_FIREFOX_PASSKEY_CREATE"317  | "COMPLETE_FIREFOX_PASSKEY_CREATE"318  | "PASSKEY_OFFER_DECLINE"319  | "GET_PASSKEY_OFFER_DECLINED"320  | "GET_PASSKEY_FOR_DOMAIN"321  // Firefox content-script proxy path (v2.12.0). Sent FROM the page-world322  // monkey-patch through the extension-world relay TO the background. See323  // src/shared/passkey-flags.ts for removal conditions.324  | "PASSKEY_HAS_CREDENTIALS"325  | "PASSKEY_CREATE"326  | "PASSKEY_GET"327  // Android / iOS message types — activate only when Capacitor.isNativePlatform() is true.328  | "UNLOCK_WITH_KEY"329  | "GET_DERIVED_KEY"330  // Recovery word messages331  | "VALIDATE_RECOVERY_WORDS"332  | "RECOVER_WITH_WORDS"333  | "GET_RECOVERY_WORDS"334  | "GET_IDENTITY_SUGGESTIONS"335  | "TOUCH_AUTO_LOCK"336  | "POPULATE_INLINE_VAULT"337  | "SAVE_NOTE"338  | "DELETE_NOTE"339  | "GET_NOTES"340  | "SEARCH_VAULT"341  | "ML_INIT"342  | "ML_PREDICT_BATCH"343  // Content-script-bound message types — sent BY the background TO content344  // scripts. They never reach messages.ts's switch. Kept in this union so345  // existing call sites type-check; do not add cases for them in the346  // background handler.347  | "PING"            // background → content: liveness probe348  | "SUMMON_AUTOFILL" // background → content: open the in-page palette349  | "VAULT_LOCKED"    // background → content/popup: vault just locked350  | "VAULT_UNLOCKED"  // background → content/popup: vault just unlocked351  | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free352  | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup353  | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section354  | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)355  | "SHOW_PASSKEY_TOAST"    // background → content tab: show passkey auth toast356  | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast357  | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast358  | "SHOULD_PLAY_CELEBRATION"359  | "MARK_CELEBRATION_PLAYED"360  // Peach Codex (v2.42.0). Generation runs in the popup — the SW only361  // sources vault data and applies the restored slots.362  | "GET_CODEX_VAULT_DATA"363  | "ESTIMATE_CODEX_PAGES"364  | "GENERATE_CODEX"365  | "RESTORE_FROM_CODEX";366367export type ProFeature =368  | "sync"369  | "passkeys"370  | "advanced_health"371  | "ios_app"372  | "codex"373  | "canary_entry"374  | "duress_vault";375376/**377 * License export shape included in PeachScript and encrypted JSON backups.378 * `celebrationPlayed` is intentionally absent — a new device deserves the moment.379 */380export interface PeachLicenseExport {381  key: string;382  activatedAt?: number;383}384385export interface AdvancedHealthEntryReport {386  id: string;387  title: string;388  domain: string;389  url?: string;390  score: number;391  ageDays: number;392  reasons: string[];393  factors: {394    breached: boolean;395    breachCount: number;396    reused: boolean;397    aged: boolean;398    weak: boolean;399    missingTotpOnSensitive: boolean;400  };401}402403export interface AdvancedHealthReport {404  vaultScore: number;405  generatedAt: number;406  entryCount: number;407  reusedGroups: number;408  agedCount: number;409  remediation: AdvancedHealthEntryReport[];410}411412export const ACCENT_COLORS = [413  { name: "peach",    hex: "#F49C70" },414  { name: "rose",     hex: "#FF8AA6" },415  { name: "plum",     hex: "#D597F2" },416  { name: "lavender", hex: "#A6A8FF" },417  { name: "sky",      hex: "#5BC8EE" },418  { name: "mint",     hex: "#5EE0B0" },419  { name: "sage",     hex: "#B5D870" },420  { name: "butter",   hex: "#FFD45A" },421  { name: "sand",     hex: "#EDBC85" },422  { name: "clay",     hex: "#F08660" },423  { name: "slate",    hex: "#80BEDE" },424  { name: "stone",    hex: "#C8B8A8" },425] as const;426427export type AccentColorName = typeof ACCENT_COLORS[number]["name"];428429export interface SavedIdentity {430  value: string;431  type: "email" | "username";432  lastUsed: number;433  useCount: number;434  exampleDomain?: string;435}436437export interface PasswordPolicy {438  minLength?: number;439  maxLength?: number;440  requiresUppercase?: boolean;441  requiresLowercase?: boolean;442  requiresNumbers?: boolean;443  requiresSymbols?: boolean;444  forbidsSymbols?: boolean;445  allowedSymbols?: string;446  /** Characters the site explicitly disallows. Filtered from every pool before generation. */447  forbiddenChars?: string;448}449450/**451 * A single vault history snapshot. The `vault_a` and `vault_b` fields are the452 * raw encrypted blobs from chrome.storage.local — already AES-256-GCM453 * protected by the master password, so storing them in history adds no454 * security surface.455 *456 * Snapshots are taken BEFORE every vault write so the saved blob represents457 * what is about to be lost, not what was just written. The reason field458 * documents the trigger so the UI can render "saved" / "synced" / "imported"459 * / "restored" labels next to each row.460 */461export interface VaultSnapshot {462  id: string;463  savedAt: number;464  reason: "local_save" | "sync_received" | "import" | "restore";465  vault_a: string;466  vault_b: string;467  /** Active entry count when the snapshot was taken; null if vault was locked. */468  entryCount: number | null;469}470471export interface VaultHistory {472  /** Newest first; capped at HISTORY_MAX (10). */473  snapshots: VaultSnapshot[];474}475476export interface Message {477  type: MessageType;478  payload?: unknown;479}480481// Canonical snake_case error codes returned by message handlers. Keep this482// union the authoritative catalogue — new handlers must reuse an entry or483// add one here. Free-form strings still appear (network/operation failures484// surface their raw .message), so error stays string at runtime; the union485// is the documented set callers should switch on.486export type ErrorCode =487  | "vault_locked"488  | "vault_corrupted"489  | "no_vault"490  | "entry_not_found"491  | "invalid_payload"492  | "invalid_message"493  | "invalid_sender"494  | "wrong_passphrase"495  | "incorrect_current_password"496  | "recovery_not_setup"497  | "android_only"498  | "not_available"499  | "pro_required"500  | "pairing_not_verified"501  | "sync_rejected_unpaired_initiator"502  | "vault_not_applied"503  | "url_required"504  | "domain_required"505  | "rpid_required"506  | "rpid_and_username_required"507  | "publickeyhex_required"508  | "missing_payload"509  | "login_entries_only"510  | "passkey_not_fillable"511  | "invalid_webhook_url"512  | "invalid_feedback_endpoint"513  | "message_empty"514  | "network_error"515  | "operation_failed"516  | "unknown_message_type";517518export interface MessageResponse {519  ok: boolean;520  data?: unknown;521  error?: string;522  unlocked?: boolean;523  // Set on UNLOCK responses when the vault decrypted but failed schema524  // validation (M7/COR-007). The popup may offer a history-restore flow.525  canRestoreFromHistory?: boolean;526}