backup.ts

8.6 KB · raw

1import { decrypt, encrypt } from "../background/aes";2import { IMPORT_LIMITS, importTooLarge } from "./import-limits";3import type { PeachLicenseExport, VaultEntry } from "./types";45/**6 * Encrypted Peach JSON envelope (legacy v2). v2.5.0 removed the7 * encrypted-JSON export option from the UI in favour of PeachScript and the8 * QR matrix code (see src/crypto/peachPayload.ts). The import path remains9 * so users can still restore older `.json` backups, and the export helper10 * below is kept private to tests that exercise the import path with11 * round-trip fixtures.12 */13export interface PeachExportEnvelope {14  version: 2;15  type: "peach-export";16  kdf: "PBKDF2-SHA256";17  salt: string;18  nonce: string;19  ciphertext: string;20  tag: string;21}2223export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2425/** @internal — test fixture generator only. No UI code path calls this. */26export async function exportEntriesToPeachJson(27  entries: VaultEntry[],28  password: string,29  license?: PeachLicenseExport30): Promise<PeachExportEnvelope> {31  const salt = crypto.getRandomValues(new Uint8Array(16));32  const key = await derivePeachExportKey(password, salt);33  const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };34  if (license) inner.license = license;35  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));3637  return {38    version: 2,39    type: "peach-export",40    kdf: "PBKDF2-SHA256",41    salt: toHex(salt),42    nonce,43    ciphertext,44    tag,45  };46}4748export interface PeachJsonImportResult {49  entries: ImportableEntry[];50  licenseKey?: string;51  licenseActivatedAt?: number;52}5354export async function importEntriesFromPeachJson(55  raw: string,56  password: string57): Promise<PeachJsonImportResult> {58  // MV-5: defense in depth — parseImportSource caps first when this is59  // reached through it, but this function is also exported directly.60  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {61    throw importTooLarge(62      `peach export ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,63    );64  }65  let envelope: Partial<PeachExportEnvelope>;66  try {67    envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;68  } catch {69    throw new Error("INVALID_PEACH_EXPORT");70  }7172  if (73    envelope.type !== "peach-export" ||74    envelope.kdf !== "PBKDF2-SHA256" ||75    envelope.version !== 2 ||76    !envelope.salt ||77    !envelope.nonce ||78    !envelope.ciphertext ||79    !envelope.tag80  ) {81    throw new Error("INVALID_PEACH_EXPORT");82  }8384  let plaintext: string;85  try {86    const key = await derivePeachExportKey(password, fromHex(envelope.salt));87    plaintext = await decrypt(88      key,89      envelope.nonce,90      envelope.ciphertext,91      envelope.tag92    );93  } catch {94    // LV-1: an export sealed before NFC normalization with a non-NFC95    // passphrase derives a different key. Retry once with the raw bytes.96    if (password === password.normalize("NFC")) {97      throw new Error("INVALID_EXPORT_PASSWORD");98    }99    try {100      const key = await derivePeachExportKey(password, fromHex(envelope.salt), {101        raw: true,102      });103      plaintext = await decrypt(104        key,105        envelope.nonce,106        envelope.ciphertext,107        envelope.tag108      );109    } catch {110      throw new Error("INVALID_EXPORT_PASSWORD");111    }112  }113114  let parsed: unknown;115  try {116    parsed = JSON.parse(plaintext);117  } catch {118    throw new Error("INVALID_PEACH_EXPORT");119  }120121  // Handle both old format (plain entries array) and new format ({ entries, license? })122  let rawEntries: unknown[];123  let licenseKey: string | undefined;124  let licenseActivatedAt: number | undefined;125126  if (Array.isArray(parsed)) {127    rawEntries = parsed;128  } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {129    const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };130    rawEntries = obj.entries;131    if (obj.license && typeof obj.license.key === "string") {132      licenseKey = obj.license.key;133      licenseActivatedAt = obj.license.activatedAt;134    }135  } else {136    throw new Error("INVALID_PEACH_EXPORT");137  }138139  const entries = rawEntries140    .map(normalizeImportedEntry)141    .filter((entry): entry is ImportableEntry => entry !== null);142143  return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };144}145146export async function derivePeachExportKey(147  password: string,148  salt: Uint8Array,149  opts: { raw?: boolean } = {}150): Promise<Uint8Array> {151  // LV-1: NFC-normalize the export passphrase to match the master-password152  // KDF chokepoint. `raw:true` is used only by the import fallback to open153  // pre-LV-1 exports sealed under un-normalized bytes.154  const pw = opts.raw ? password : password.normalize("NFC");155  const material = await crypto.subtle.importKey(156    "raw",157    new TextEncoder().encode(pw),158    "PBKDF2",159    false,160    ["deriveBits"]161  );162  const bits = await crypto.subtle.deriveBits(163    {164      name: "PBKDF2",165      salt: salt.buffer as ArrayBuffer,166      iterations: 250000,167      hash: "SHA-256",168    },169    material,170    256171  );172  return new Uint8Array(bits);173}174175function normalizeImportedEntry(value: unknown): ImportableEntry | null {176  if (!value || typeof value !== "object") return null;177178  const candidate = value as Partial<VaultEntry> & {179    name?: string;180    note?: string;181    email?: string;182  };183184  const deletedAt =185    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;186187  if (candidate.type === "note") {188    const title = firstNonEmptyString(189      candidate.title,190      candidate.name,191      "Untitled"192    );193    if (!title) return null;194    return {195      type: "note",196      title,197      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,198      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,199      deletedAt,200    };201  }202203  if (candidate.type === "passkey") {204    if (205      typeof candidate.rpId !== "string" ||206      typeof candidate.rpName !== "string" ||207      typeof candidate.credentialId !== "string" ||208      typeof candidate.publicKeyCose !== "string" ||209      !candidate.privateKeyJwk ||210      typeof candidate.userHandle !== "string" ||211      typeof candidate.userName !== "string" ||212      typeof candidate.userDisplayName !== "string" ||213      (candidate.algorithm !== -7 && candidate.algorithm !== -8)214    ) {215      return null;216    }217218    return {219      type: "passkey",220      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),221      rpId: candidate.rpId,222      rpName: candidate.rpName,223      credentialId: candidate.credentialId,224      publicKeyCose: candidate.publicKeyCose,225      privateKeyJwk: candidate.privateKeyJwk,226      userHandle: candidate.userHandle,227      userName: candidate.userName,228      userDisplayName: candidate.userDisplayName,229      algorithm: candidate.algorithm,230      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,231      aaguid:232        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",233      backupEligible: candidate.backupEligible !== false,234      backupState: candidate.backupState === true,235      lastUsedAt:236        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,237      deletedAt,238    };239  }240241  const password =242    typeof candidate.password === "string" ? candidate.password : "";243  if (!password) return null;244245  const title = firstNonEmptyString(246    candidate.title,247    candidate.name,248    candidate.url,249    candidate.username,250    candidate.email,251    "Untitled"252  );253254  return {255    type: "login",256    title,257    username: firstNonEmptyString(candidate.username, candidate.email, ""),258    password,259    url: firstNonEmptyString(candidate.url, ""),260    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,261    totp: firstNonEmptyString(candidate.totp) || undefined,262    breach:263      typeof candidate.breach === "number" && candidate.breach > 0264        ? candidate.breach265        : undefined,266    canary: candidate.canary ?? undefined,267    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,268    deletedAt,269  };270}271272function firstNonEmptyString(273  ...values: Array<string | null | undefined>274): string {275  for (const value of values) {276    if (typeof value === "string" && value.trim()) {277      return value.trim();278    }279  }280  return "";281}282283function toHex(buf: Uint8Array): string {284  return Array.from(buf)285    .map((byte) => byte.toString(16).padStart(2, "0"))286    .join("");287}288289function fromHex(input: string): Uint8Array {290  const out = new Uint8Array(input.length / 2);291  for (let i = 0; i < input.length; i += 2) {292    out[i / 2] = parseInt(input.slice(i, i + 2), 16);293  }294  return out;295}