types.ts

16.2 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}5859export interface VaultEntry {60  id: string; // crypto.randomUUID()61  /**62   * Entry type. As of v2.11.0 passkeys are stored on `type:"login"` entries63   * via the `passkey` sub-object — `"passkey"` here is a legacy value still64   * accepted on read but rewritten to `"login"` on next vault save.65   */66  type?: "login" | "note" | "passkey" | "card";67  title: string;68  username?: string; // only for login entries69  email?: string; // separate email address — for sites that require both a username and an email70  password?: string; // only for login entries71  url?: string; // only for login entries72  /**73   * Additional sites this entry should match for autofill. Login entries only.74   * Each value is a full origin or URL (e.g. `https://login.foo.com`).75   * The vault domain index keys against the hostname of every entry in this76   * list, in addition to `url`.77   */78  additionalUrls?: string[];79  totp?: string; // base32 seed, stored encrypted — only for login entries80  notes?: string;81  breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries82  /**83   * When the password field was last changed. Set on first-write of `password`84   * and refreshed on every subsequent change. Defaults to `createdAt` when85   * legacy entries are read by the advanced health analyser.86   */87  passwordChangedAt?: number;88  canary?: Canary;89  createdAt: number; // Date.now()90  modifiedAt: number;91  deletedAt?: number;92  tags?: string[];93  /** Passkey credential attached to this entry, if any. */94  passkey?: PasskeyData;95  // ── Legacy passkey flat fields (pre-v2.11.0). Populated by vault read96  // migration so that the old import/sanitize paths keep typing; new code97  // should always read `entry.passkey?.*` instead. The migration drops these98  // on next vault save. Do not introduce new readers of these fields.99  rpId?: string;100  rpName?: string;101  credentialId?: string;102  publicKeyCose?: string;103  privateKeyJwk?: JsonWebKey;104  userHandle?: string;105  userName?: string;106  userDisplayName?: string;107  algorithm?: -7 | -8;108  signCount?: number;109  aaguid?: string;110  backupEligible?: boolean;111  backupState?: boolean;112  lastUsedAt?: number;113  browser?: "chrome" | "firefox";114  transports?: string[];115  // Card entry fields116  cardholderName?: string;117  cardNumber?: string;       // full PAN, stored encrypted in vault118  expMonth?: string;119  expYear?: string;120  cvc?: string;121  cardNickname?: string;122  network?: CardNetwork;123  last4?: string;124}125126/**127 * Returns true when an entry has a passkey credential attached. After v2.11.0128 * migration this is the canonical check; pre-migration entries with a flat129 * `rpId`/`credentialId` shape get rewritten by the vault read pass before130 * any consumer sees them.131 */132export function hasPasskey(entry: VaultEntry): boolean {133  return entry.passkey !== undefined && entry.passkey.credentialId.length > 0;134}135136/**137 * Returns true when an entry is "passkey-only" — has a passkey credential138 * but no password to fill into a form. Login fields hide for these entries139 * and the inline autofill selector filters them out (signing in via the140 * passkey is a separate WebAuthn flow). Username may still be populated141 * (the migration mirrors passkey.userName onto the entry's username so the142 * domain-index/search keep working) and is not part of this discriminator.143 */144export function isPasskeyOnly(entry: VaultEntry): boolean {145  // An entry is "passkey only" when it holds SOLELY a passkey credential —146  // no password, no TOTP, and no dedicated email. Username is intentionally147  // excluded (the migration copies passkey.userName onto username for search).148  // Entries with passkey + TOTP or passkey + email are NOT passkey-only: they149  // carry fillable credentials and must show the full login-fields UI.150  return hasPasskey(entry) && !entry.password && !entry.totp && !entry.email;151}152153export interface LegacyVaultEntry {154  id: string;155  title: string;156  username: string;157  password: string;158  url: string;159  totp?: string;160  notes?: string;161  breach?: number;162  created: number;163  updated: number;164  deletedAt?: number;165}166167export interface CardEntry extends VaultEntry {168  type: "card";169  cardholderName: string;170  cardNumber: string;171  expMonth: string;172  expYear: string;173  cvc: string;174}175176177export interface PendingPasskeyRequest {178  requestId: number;179  type: "create" | "get";180  rpId: string;181  rpName: string;182  userName: string;183  userDisplayName?: string;184  /**185   * Firefox path: when present, the popup performs the navigator.credentials186   * call itself rather than completing a chrome.webAuthenticationProxy187   * request. Absent for Chrome (the existing webAuthenticationProxy flow).188   */189  browser?: "chrome" | "firefox";190  /** Firefox create: base64url challenge minted by the popup-context flow. */191  challenge?: string;192  /** Firefox create: base64url user handle. Generated if absent. */193  userHandle?: string;194}195196export interface PasskeyProviderState {197  supported: boolean;198  enabled: boolean;199  attached: boolean;200  browserLabel: string;201  statusLabel: string;202  conflict?: string;203}204205export interface StoredVault {206  version: 1; // AAD for AES-GCM --- prevents downgrade207  salt: string; // hex --- 16 bytes, CSPRNG, stored not secret208  nonce: string; // hex --- 12 bytes, CSPRNG, fresh per write209  ciphertext: string; // hex --- AES-256-GCM output210  tag: string; // hex --- 16-byte GCM auth tag211  // Recovery escrow: root key wrapped under recovery words via AES-256-GCM.212  // Absent on vaults created before v2.12 (migrateToRecovery adds them on first unlock).213  recovery_salt?: string; // hex --- 16 bytes, CSPRNG, HKDF salt for recovery key214  recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key215  recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above216  recovery_tag?: string; // hex --- 16-byte GCM auth tag for the above217  // Second wrapping of the same root key under the password-derived key.218  // Lets unlock() recover the rootKey bytes without the user's recovery219  // words, so GET_RECOVERY_WORDS works across lock/unlock cycles. Refreshed220  // on every password change so the words stay valid (H2/COR-006).221  pw_root_ciphertext?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key (hex-encoded)222  pw_root_nonce?: string; // hex --- 12-byte nonce223  pw_root_tag?: string; // hex --- 16-byte GCM auth tag224}225226// Background-bound message types — sent from popup/content scripts,227// handled in src/background/messages.ts. Everything in this group must228// have a matching case in that file's switch statement.229export type MessageType =230  | "UNLOCK"231  | "LOCK"232  | "IS_UNLOCKED"233  | "HAS_VAULT"234  | "GET_ENTRIES"235  | "ADD_ENTRY"236  | "BATCH_ADD_ENTRIES"237  | "RESTORE_ENTRIES"238  | "CLEAR_VAULT_ENTRIES"239  | "UPDATE_ENTRY"240  | "ADD_ADDITIONAL_URL"241  | "DELETE_ENTRY"242  | "AUTOFILL_QUERY"243  | "COPY"244  | "CREATE_VAULT"245  | "GET_ENTRY_FOR_FILL"246  | "GET_ENTRY_FOR_EDIT"247  | "CHECK_ALL_BREACHES"248  | "GET_SETTINGS"249  | "UPDATE_SETTINGS"250  | "FIRE_CANARY_PING"251  | "ACTIVATE_LICENSE"252  | "GET_LICENSE"253  | "GET_LICENSE_KEY"254  | "IS_DURESS_ENABLED"255  | "SETUP_DURESS"256  | "DISABLE_DURESS"257  | "VERIFY_MAIN_PASSWORD"258  | "CHANGE_PASSWORD"259  | "GET_SITE_PASSWORD_POLICY"260  | "CHIRP_GET_LOCAL_IDENTITY"261  | "CHIRP_COMPLETE_PAIRING"262  | "CHIRP_LIST_PAIRED_DEVICES"263  | "CHIRP_UNPAIR_DEVICE"264  | "CHIRP_RENAME_LOCAL_DEVICE"265  | "TRIGGER_SYNC"266  | "GET_SYNC_STATE"267  | "GET_PENDING_PASSKEY"268  | "APPROVE_PASSKEY"269  | "DENY_PASSKEY"270  | "GET_PASSKEY_PROVIDER_STATE"271  | "SET_PASSKEY_PROVIDER_ENABLED"272  | "GET_DELETED_ENTRIES"273  | "RESTORE_ENTRY"274  | "PURGE_ENTRY"275  | "PURGE_EXPIRED_TRASH"276  | "GET_VAULT_HISTORY"277  | "RESTORE_VAULT_SNAPSHOT"278  | "COUNT_DUPLICATES"279  | "DEDUPLICATE_ENTRIES"280  | "RECORD_MANUAL_SUMMON"281  | "DECLINE_CARD_SAVE"282  | "IS_CARD_DECLINED"283  | "SUBMIT_FEEDBACK"284  | "GET_VAULT_STATUS"285  | "GET_ADVANCED_HEALTH_REPORT"286  | "DEACTIVATE_LICENSE"287  | "GET_IDENTITIES"288  | "DESTROY_VAULT"289  | "INITIATE_FIREFOX_PASSKEY_CREATE"290  | "COMPLETE_FIREFOX_PASSKEY_CREATE"291  | "PASSKEY_OFFER_DECLINE"292  | "GET_PASSKEY_OFFER_DECLINED"293  | "GET_PASSKEY_FOR_DOMAIN"294  // Firefox content-script proxy path (v2.12.0). Sent FROM the page-world295  // monkey-patch through the extension-world relay TO the background. See296  // src/shared/passkey-flags.ts for removal conditions.297  | "PASSKEY_HAS_CREDENTIALS"298  | "PASSKEY_CREATE"299  | "PASSKEY_GET"300  // Android / iOS message types — activate only when Capacitor.isNativePlatform() is true.301  | "UNLOCK_WITH_KEY"302  | "GET_DERIVED_KEY"303  // Recovery word messages304  | "VALIDATE_RECOVERY_WORDS"305  | "RECOVER_WITH_WORDS"306  | "GET_RECOVERY_WORDS"307  | "GET_IDENTITY_SUGGESTIONS"308  | "TOUCH_AUTO_LOCK"309  | "POPULATE_INLINE_VAULT"310  | "SAVE_NOTE"311  | "DELETE_NOTE"312  | "GET_NOTES"313  | "SEARCH_VAULT"314  | "ML_INIT"315  | "ML_PREDICT_BATCH"316  // Content-script-bound message types — sent BY the background TO content317  // scripts. They never reach messages.ts's switch. Kept in this union so318  // existing call sites type-check; do not add cases for them in the319  // background handler.320  | "PING"            // background → content: liveness probe321  | "SUMMON_AUTOFILL" // background → content: open the in-page palette322  | "VAULT_LOCKED"    // background → content/popup: vault just locked323  | "VAULT_UNLOCKED"  // background → content/popup: vault just unlocked324  | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free325  | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup326  | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section327  | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)328  | "SHOW_PASSKEY_TOAST"    // background → content tab: show passkey auth toast329  | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast330  | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast331  | "SHOULD_PLAY_CELEBRATION"332  | "MARK_CELEBRATION_PLAYED"333  // Peach Codex (v2.42.0). Generation runs in the popup — the SW only334  // sources vault data and applies the restored slots.335  | "GET_CODEX_VAULT_DATA"336  | "ESTIMATE_CODEX_PAGES"337  | "GENERATE_CODEX"338  | "RESTORE_FROM_CODEX";339340export type ProFeature =341  | "sync"342  | "passkeys"343  | "advanced_health"344  | "ios_app"345  | "codex"346  | "canary_entry"347  | "duress_vault";348349/**350 * License export shape included in PeachScript and encrypted JSON backups.351 * `celebrationPlayed` is intentionally absent — a new device deserves the moment.352 */353export interface PeachLicenseExport {354  key: string;355  activatedAt?: number;356}357358export interface AdvancedHealthEntryReport {359  id: string;360  title: string;361  domain: string;362  url?: string;363  score: number;364  ageDays: number;365  reasons: string[];366  factors: {367    breached: boolean;368    breachCount: number;369    reused: boolean;370    aged: boolean;371    weak: boolean;372    missingTotpOnSensitive: boolean;373  };374}375376export interface AdvancedHealthReport {377  vaultScore: number;378  generatedAt: number;379  entryCount: number;380  reusedGroups: number;381  agedCount: number;382  remediation: AdvancedHealthEntryReport[];383}384385export const ACCENT_COLORS = [386  { name: "peach",    hex: "#F49C70" },387  { name: "rose",     hex: "#FF8AA6" },388  { name: "plum",     hex: "#D597F2" },389  { name: "lavender", hex: "#A6A8FF" },390  { name: "sky",      hex: "#5BC8EE" },391  { name: "mint",     hex: "#5EE0B0" },392  { name: "sage",     hex: "#B5D870" },393  { name: "butter",   hex: "#FFD45A" },394  { name: "sand",     hex: "#EDBC85" },395  { name: "clay",     hex: "#F08660" },396  { name: "slate",    hex: "#80BEDE" },397  { name: "stone",    hex: "#C8B8A8" },398] as const;399400export type AccentColorName = typeof ACCENT_COLORS[number]["name"];401402export interface SavedIdentity {403  value: string;404  type: "email" | "username";405  lastUsed: number;406  useCount: number;407  exampleDomain?: string;408}409410export interface PasswordPolicy {411  minLength?: number;412  maxLength?: number;413  requiresUppercase?: boolean;414  requiresLowercase?: boolean;415  requiresNumbers?: boolean;416  requiresSymbols?: boolean;417  forbidsSymbols?: boolean;418  allowedSymbols?: string;419  /** Characters the site explicitly disallows. Filtered from every pool before generation. */420  forbiddenChars?: string;421}422423/**424 * A single vault history snapshot. The `vault_a` and `vault_b` fields are the425 * raw encrypted blobs from chrome.storage.local — already AES-256-GCM426 * protected by the master password, so storing them in history adds no427 * security surface.428 *429 * Snapshots are taken BEFORE every vault write so the saved blob represents430 * what is about to be lost, not what was just written. The reason field431 * documents the trigger so the UI can render "saved" / "synced" / "imported"432 * / "restored" labels next to each row.433 */434export interface VaultSnapshot {435  id: string;436  savedAt: number;437  reason: "local_save" | "sync_received" | "import" | "restore";438  vault_a: string;439  vault_b: string;440  /** Active entry count when the snapshot was taken; null if vault was locked. */441  entryCount: number | null;442}443444export interface VaultHistory {445  /** Newest first; capped at HISTORY_MAX (10). */446  snapshots: VaultSnapshot[];447}448449export interface Message {450  type: MessageType;451  payload?: unknown;452}453454// Canonical snake_case error codes returned by message handlers. Keep this455// union the authoritative catalogue — new handlers must reuse an entry or456// add one here. Free-form strings still appear (network/operation failures457// surface their raw .message), so error stays string at runtime; the union458// is the documented set callers should switch on.459export type ErrorCode =460  | "vault_locked"461  | "vault_corrupted"462  | "no_vault"463  | "entry_not_found"464  | "invalid_payload"465  | "invalid_message"466  | "invalid_sender"467  | "wrong_passphrase"468  | "incorrect_current_password"469  | "recovery_not_setup"470  | "android_only"471  | "not_available"472  | "pro_required"473  | "pairing_not_verified"474  | "sync_rejected_unpaired_initiator"475  | "vault_not_applied"476  | "url_required"477  | "domain_required"478  | "rpid_required"479  | "rpid_and_username_required"480  | "publickeyhex_required"481  | "missing_payload"482  | "login_entries_only"483  | "passkey_not_fillable"484  | "invalid_webhook_url"485  | "invalid_feedback_endpoint"486  | "message_empty"487  | "network_error"488  | "operation_failed"489  | "unknown_message_type";490491export interface MessageResponse {492  ok: boolean;493  data?: unknown;494  error?: string;495  unlocked?: boolean;496  // Set on UNLOCK responses when the vault decrypted but failed schema497  // validation (M7/COR-007). The popup may offer a history-restore flow.498  canRestoreFromHistory?: boolean;499}