types.ts

18.7 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  /**92   * Optional TOTP parameters parsed from an otpauth:// URI. Absent fields fall93   * back to RFC defaults (SHA-1 / 6 digits / 30-second period), so legacy entries94   * carrying only a bare `totp` seed keep generating identical codes95   * (PCH-01-019). Login entries only.96   */97  totpAlgorithm?: "SHA1" | "SHA256" | "SHA512";98  totpDigits?: number;99  totpPeriod?: number;100  notes?: string;101  breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries102  /**103   * When the password field was last changed. Set on first-write of `password`104   * and refreshed on every subsequent change. Defaults to `createdAt` when105   * legacy entries are read by the advanced health analyser.106   */107  passwordChangedAt?: number;108  /**109   * Previously-used passwords for this entry, newest first. A snapshot of the110   * old password is pushed here automatically whenever the password changes111   * (see `updateEntry` in vault.ts). Capped at PASSWORD_HISTORY_MAX. Lets the112   * user reveal, copy, or restore a password they used before — handy when a113   * site rejects a rotation or an old credential resurfaces.114   */115  passwordHistory?: PasswordHistoryItem[];116  canary?: Canary;117  createdAt: number; // Date.now()118  modifiedAt: number;119  deletedAt?: number;120  tags?: string[];121  /** Passkey credential attached to this entry, if any. */122  passkey?: PasskeyData;123  // ── Legacy passkey flat fields (pre-v2.11.0). Populated by vault read124  // migration so that the old import/sanitize paths keep typing; new code125  // should always read `entry.passkey?.*` instead. The migration drops these126  // on next vault save. Do not introduce new readers of these fields.127  rpId?: string;128  rpName?: string;129  credentialId?: string;130  publicKeyCose?: string;131  privateKeyJwk?: JsonWebKey;132  userHandle?: string;133  userName?: string;134  userDisplayName?: string;135  algorithm?: -7 | -8;136  signCount?: number;137  aaguid?: string;138  backupEligible?: boolean;139  backupState?: boolean;140  lastUsedAt?: number;141  browser?: "chrome" | "firefox";142  transports?: string[];143  // Card entry fields144  cardholderName?: string;145  cardNumber?: string;       // full PAN, stored encrypted in vault146  expMonth?: string;147  expYear?: string;148  cvc?: string;149  cardNickname?: string;150  network?: CardNetwork;151  last4?: string;152}153154/**155 * Returns true when an entry has a passkey credential attached. After v2.11.0156 * migration this is the canonical check; pre-migration entries with a flat157 * `rpId`/`credentialId` shape get rewritten by the vault read pass before158 * any consumer sees them.159 */160export function hasPasskey(entry: VaultEntry): boolean {161  return entry.passkey !== undefined && entry.passkey.credentialId.length > 0;162}163164/**165 * Returns true when an entry is "passkey-only" — has a passkey credential166 * but no password to fill into a form. Login fields hide for these entries167 * and the inline autofill selector filters them out (signing in via the168 * passkey is a separate WebAuthn flow). Username may still be populated169 * (the migration mirrors passkey.userName onto the entry's username so the170 * domain-index/search keep working) and is not part of this discriminator.171 */172export function isPasskeyOnly(entry: VaultEntry): boolean {173  // An entry is "passkey only" when it holds SOLELY a passkey credential —174  // no password, no TOTP, and no dedicated email. Username is intentionally175  // excluded (the migration copies passkey.userName onto username for search).176  // Entries with passkey + TOTP or passkey + email are NOT passkey-only: they177  // carry fillable credentials and must show the full login-fields UI.178  return hasPasskey(entry) && !entry.password && !entry.totp && !entry.email;179}180181export interface LegacyVaultEntry {182  id: string;183  title: string;184  username: string;185  password: string;186  url: string;187  totp?: string;188  totpAlgorithm?: "SHA1" | "SHA256" | "SHA512";189  totpDigits?: number;190  totpPeriod?: number;191  notes?: string;192  breach?: number;193  created: number;194  updated: number;195  deletedAt?: number;196}197198export interface CardEntry extends VaultEntry {199  type: "card";200  cardholderName: string;201  cardNumber: string;202  expMonth: string;203  expYear: string;204  cvc: string;205}206207208export interface PendingPasskeyRequest {209  requestId: number;210  type: "create" | "get";211  rpId: string;212  rpName: string;213  userName: string;214  userDisplayName?: string;215  /**216   * Firefox path: when present, the popup performs the navigator.credentials217   * call itself rather than completing a chrome.webAuthenticationProxy218   * request. Absent for Chrome (the existing webAuthenticationProxy flow).219   */220  browser?: "chrome" | "firefox";221  /** Firefox create: base64url challenge minted by the popup-context flow. */222  challenge?: string;223  /** Firefox create: base64url user handle. Generated if absent. */224  userHandle?: string;225}226227export interface PasskeyProviderState {228  supported: boolean;229  enabled: boolean;230  attached: boolean;231  browserLabel: string;232  statusLabel: string;233  conflict?: string;234}235236export interface StoredVault {237  version: 1; // AAD for AES-GCM --- prevents downgrade238  salt: string; // hex --- 16 bytes, CSPRNG, stored not secret239  nonce: string; // hex --- 12 bytes, CSPRNG, fresh per write240  ciphertext: string; // hex --- AES-256-GCM output241  tag: string; // hex --- 16-byte GCM auth tag242  // Recovery escrow: root key wrapped under recovery words via AES-256-GCM.243  // Absent on vaults created before v2.12 (migrateToRecovery adds them on first unlock).244  recovery_salt?: string; // hex --- 16 bytes, CSPRNG, HKDF salt for recovery key245  recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key246  recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above247  recovery_tag?: string; // hex --- 16-byte GCM auth tag for the above248  // Second wrapping of the same root key under the password-derived key.249  // Lets unlock() recover the rootKey bytes without the user's recovery250  // words, so GET_RECOVERY_WORDS works across lock/unlock cycles. Refreshed251  // on every password change so the words stay valid (H2/COR-006).252  pw_root_ciphertext?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key (hex-encoded)253  pw_root_nonce?: string; // hex --- 12-byte nonce254  pw_root_tag?: string; // hex --- 16-byte GCM auth tag255}256257// Background-bound message types — sent from popup/content scripts,258// handled in src/background/messages.ts. Everything in this group must259// have a matching case in that file's switch statement.260export type MessageType =261  | "GET_SYNC_ALERTS"262  | "CLEAR_CLIPBOARD"263  | "GET_PENDING_VOUCHED_DEVICES"264  | "CONFIRM_VOUCHED_DEVICE"265  | "REJECT_VOUCHED_DEVICE"266  | "VOUCHED_DEVICE_PENDING"267  | "ROTATE_SYNC_IDENTITY"268  | "DEVICE_KEY_ROTATED"269  | "UNLOCK"270  | "UNLOCK_WITH_PIN"271  | "LOCK"272  | "IS_UNLOCKED"273  | "HAS_VAULT"274  | "GET_ENTRIES"275  | "ADD_ENTRY"276  | "BATCH_ADD_ENTRIES"277  | "RESTORE_ENTRIES"278  | "CLEAR_VAULT_ENTRIES"279  | "UPDATE_ENTRY"280  | "ADD_ADDITIONAL_URL"281  | "DELETE_ENTRY"282  | "AUTOFILL_QUERY"283  | "COPY"284  | "CREATE_VAULT"285  | "CREATE_VAULT_FROM_ROOT_KEY"286  | "GET_ENTRY_FOR_FILL"287  | "GET_ENTRY_FOR_EDIT"288  | "CHECK_ALL_BREACHES"289  | "GET_SETTINGS"290  | "UPDATE_SETTINGS"291  | "SETUP_PIN"292  | "DISABLE_PIN"293  | "FIRE_CANARY_PING"294  | "ACTIVATE_LICENSE"295  | "GET_LICENSE"296  | "GET_LICENSE_KEY"297  | "IS_DURESS_ENABLED"298  | "SETUP_DURESS"299  | "DISABLE_DURESS"300  | "VERIFY_MAIN_PASSWORD"301  | "CHANGE_PASSWORD"302  | "GET_SITE_PASSWORD_POLICY"303  | "GET_LOCAL_IDENTITY"304  | "LIST_PAIRED_DEVICES"305  | "RENAME_PAIRED_DEVICE"306  | "UNPAIR_DEVICE"307  | "RENAME_LOCAL_DEVICE"308  // Relay pairing. START/CONFIRM/CANCEL are popup→bg requests handled in the309  // switch; ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT are bg→popup pushes.310  | "START_ADOPTION_SOURCE"311  | "START_ADOPTION_RECEIVER"312  | "CANCEL_ADOPTION_SOURCE"313  | "SOURCE_SAS_DECISION"314  | "ADOPTION_SOURCE_SAS"315  | "ADOPTION_SOURCE_RESULT"316  | "TRIGGER_SYNC"317  | "GET_SYNC_STATE"318  | "GET_SYNC_SETTINGS"319  | "SET_SYNC_SETTINGS"320  | "GET_PENDING_PASSKEY"321  | "APPROVE_PASSKEY"322  | "DENY_PASSKEY"323  | "GET_PASSKEY_PROVIDER_STATE"324  | "SET_PASSKEY_PROVIDER_ENABLED"325  | "GET_DELETED_ENTRIES"326  | "RESTORE_ENTRY"327  | "PURGE_ENTRY"328  | "PURGE_EXPIRED_TRASH"329  | "GET_VAULT_HISTORY"330  | "RESTORE_VAULT_SNAPSHOT"331  | "COUNT_DUPLICATES"332  | "DEDUPLICATE_ENTRIES"333  | "RECORD_MANUAL_SUMMON"334  | "DECLINE_CARD_SAVE"335  | "IS_CARD_DECLINED"336  | "GET_VAULT_STATUS"337  | "GET_ADVANCED_HEALTH_REPORT"338  | "DEACTIVATE_LICENSE"339  | "GET_IDENTITIES"340  | "DESTROY_VAULT"341  | "INITIATE_FIREFOX_PASSKEY_CREATE"342  | "COMPLETE_FIREFOX_PASSKEY_CREATE"343  | "PASSKEY_OFFER_DECLINE"344  | "GET_PASSKEY_OFFER_DECLINED"345  | "GET_PASSKEY_FOR_DOMAIN"346  // Firefox content-script proxy path (v2.12.0). Sent FROM the page-world347  // monkey-patch through the extension-world relay TO the background. See348  // src/shared/passkey-flags.ts for removal conditions.349  | "PASSKEY_HAS_CREDENTIALS"350  | "PASSKEY_CREATE"351  | "PASSKEY_GET"352  // Android / iOS message types — activate only when Capacitor.isNativePlatform() is true.353  | "UNLOCK_WITH_KEY"354  | "GET_DERIVED_KEY"355  // Recovery word messages356  | "VALIDATE_RECOVERY_WORDS"357  | "RECOVER_WITH_WORDS"358  | "GET_RECOVERY_WORDS"359  | "GET_IDENTITY_SUGGESTIONS"360  | "TOUCH_AUTO_LOCK"361  | "POPULATE_INLINE_VAULT"362  | "SAVE_NOTE"363  | "DELETE_NOTE"364  | "GET_NOTES"365  | "SEARCH_VAULT"366  | "ML_INIT"367  | "ML_PREDICT_BATCH"368  // Content-script-bound message types — sent BY the background TO content369  // scripts. They never reach messages.ts's switch. Kept in this union so370  // existing call sites type-check; do not add cases for them in the371  // background handler.372  | "PING"            // background → content: liveness probe373  | "SUMMON_AUTOFILL" // background → content: open the in-page palette374  | "VAULT_LOCKED"    // background → content/popup: vault just locked375  | "VAULT_UNLOCKED"  // background → content/popup: vault just unlocked376  | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free377  | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup378  | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section379  | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)380  | "OPEN_DETACHED_POPUP" // popup → background: open the popup HTML in a standalone window (file-picker flows)381  | "SHOW_PASSKEY_TOAST"    // background → content tab: show passkey auth toast382  | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast383  | "PASSKEY_AUTH_RESPONSE" // content → background: user approved or denied the toast384  | "SHOULD_PLAY_CELEBRATION"385  | "MARK_CELEBRATION_PLAYED"386  // Peach Codex (v2.42.0). Generation runs in the popup — the SW only387  // sources vault data and applies the restored slots.388  | "GET_CODEX_VAULT_DATA"389  | "ESTIMATE_CODEX_PAGES"390  | "GENERATE_CODEX"391  | "RESTORE_FROM_CODEX";392393export type ProFeature =394  | "sync"395  | "passkeys"396  | "advanced_health"397  | "ios_app"398  | "codex"399  | "canary_entry"400  | "duress_vault";401402/**403 * License export shape included in PeachScript and encrypted JSON backups.404 * `celebrationPlayed` is intentionally absent — a new device deserves the moment.405 */406export interface PeachLicenseExport {407  key: string;408  activatedAt?: number;409}410411export interface AdvancedHealthEntryReport {412  id: string;413  title: string;414  domain: string;415  url?: string;416  score: number;417  ageDays: number;418  reasons: string[];419  factors: {420    breached: boolean;421    breachCount: number;422    reused: boolean;423    aged: boolean;424    weak: boolean;425    missingTotpOnSensitive: boolean;426  };427}428429export interface AdvancedHealthReport {430  vaultScore: number;431  generatedAt: number;432  entryCount: number;433  reusedGroups: number;434  agedCount: number;435  remediation: AdvancedHealthEntryReport[];436}437438export const ACCENT_COLORS = [439  { name: "peach",    hex: "#F49C70" },440  { name: "rose",     hex: "#FF8AA6" },441  { name: "plum",     hex: "#D597F2" },442  { name: "lavender", hex: "#A6A8FF" },443  { name: "sky",      hex: "#5BC8EE" },444  { name: "mint",     hex: "#5EE0B0" },445  { name: "sage",     hex: "#B5D870" },446  { name: "butter",   hex: "#FFD45A" },447  { name: "sand",     hex: "#EDBC85" },448  { name: "clay",     hex: "#F08660" },449  { name: "slate",    hex: "#80BEDE" },450  { name: "stone",    hex: "#C8B8A8" },451] as const;452453export type AccentColorName = typeof ACCENT_COLORS[number]["name"];454455export interface SavedIdentity {456  value: string;457  type: "email" | "username";458  lastUsed: number;459  useCount: number;460  exampleDomain?: string;461}462463export interface PasswordPolicy {464  minLength?: number;465  maxLength?: number;466  requiresUppercase?: boolean;467  requiresLowercase?: boolean;468  requiresNumbers?: boolean;469  requiresSymbols?: boolean;470  forbidsSymbols?: boolean;471  allowedSymbols?: string;472  /** Characters the site explicitly disallows. Filtered from every pool before generation. */473  forbiddenChars?: string;474}475476/**477 * A single vault history snapshot. The `vault_a` and `vault_b` fields are the478 * raw encrypted blobs from chrome.storage.local — already AES-256-GCM479 * protected by the master password, so storing them in history adds no480 * security surface.481 *482 * Snapshots are taken BEFORE every vault write so the saved blob represents483 * what is about to be lost, not what was just written. The reason field484 * documents the trigger so the UI can render "saved" / "synced" / "imported"485 * / "restored" labels next to each row.486 */487export interface VaultSnapshot {488  id: string;489  savedAt: number;490  reason: "local_save" | "sync_received" | "import" | "restore";491  vault_a: string;492  vault_b: string;493  /**494   * Coarse size bucket (ceiling) of active entries when the snapshot was taken;495   * null if vault was locked or in decoy mode. PCH-01-023: bucketed, not exact,496   * so the plaintext value cannot leak vault size or per-write deltas. UI should497   * render it as "up to N", never as an exact count.498   */499  entryCount: number | null;500}501502export interface VaultHistory {503  /** Newest first; capped at HISTORY_MAX (10). */504  snapshots: VaultSnapshot[];505}506507export interface Message {508  type: MessageType;509  payload?: unknown;510}511512// Canonical snake_case error codes returned by message handlers. Keep this513// union the authoritative catalogue — new handlers must reuse an entry or514// add one here. Free-form strings still appear (network/operation failures515// surface their raw .message), so error stays string at runtime; the union516// is the documented set callers should switch on.517export type ErrorCode =518  | "vault_locked"519  | "vault_corrupted"520  | "no_vault"521  | "entry_not_found"522  | "invalid_payload"523  | "invalid_message"524  | "invalid_sender"525  | "wrong_passphrase"526  | "incorrect_current_password"527  | "recovery_not_setup"528  | "android_only"529  | "not_available"530  | "pro_required"531  | "pairing_not_verified"532  | "sync_rejected_unpaired_initiator"533  | "vault_not_applied"534  | "url_required"535  | "domain_required"536  | "rpid_required"537  | "rpid_and_username_required"538  | "publickeyhex_required"539  | "missing_payload"540  | "login_entries_only"541  | "passkey_not_fillable"542  | "invalid_webhook_url"543  | "invalid_feedback_endpoint"544  | "message_empty"545  | "network_error"546  | "operation_failed"547  | "adopt_invalid_root_key"548  | "adopt_vault_id_mismatch"549  | "unknown_message_type";550551export interface MessageResponse {552  ok: boolean;553  data?: unknown;554  error?: string;555  vaultId?: string;556  unlocked?: boolean;557  // Set on UNLOCK responses when the vault decrypted but failed schema558  // validation (M7/COR-007). The popup may offer a history-restore flow.559  canRestoreFromHistory?: boolean;560  // Set on COPY responses: the effective clipboard auto-clear delay in seconds,561  // so the content script's TOTP toast can advertise the real configured time.562  clearSeconds?: number;563}