backup.ts

9.2 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}145146/**147 * LEGACY IMPORT KDF (PCH-02-005):148 * This path derives with PBKDF2-SHA256 (250,000 iterations) for backwards149 * compatibility with `.json` exports sealed before the Argon2id migration.150 * The encrypted-JSON export UI was removed in v2.5.0, so this helper is151 * import-only, plus test fixture generation.152 *153 * PBKDF2 has no memory-hardness and is materially weaker than the current154 * Argon2id envelope (m=64 MiB, t=3, p=4) against GPU/ASIC attackers at equal155 * wall-clock cost. Do not use this helper for any new format. Users importing156 * an old `.json` backup are prompted to export a fresh backup afterwards.157 */158export async function derivePeachExportKey(159  password: string,160  salt: Uint8Array,161  opts: { raw?: boolean } = {}162): Promise<Uint8Array> {163  // LV-1: NFC-normalize the export passphrase to match the master-password164  // KDF chokepoint. `raw:true` is used only by the import fallback to open165  // pre-LV-1 exports sealed under un-normalized bytes.166  const pw = opts.raw ? password : password.normalize("NFC");167  const material = await crypto.subtle.importKey(168    "raw",169    new TextEncoder().encode(pw),170    "PBKDF2",171    false,172    ["deriveBits"]173  );174  const bits = await crypto.subtle.deriveBits(175    {176      name: "PBKDF2",177      salt: salt.buffer as ArrayBuffer,178      iterations: 250000,179      hash: "SHA-256",180    },181    material,182    256183  );184  return new Uint8Array(bits);185}186187function normalizeImportedEntry(value: unknown): ImportableEntry | null {188  if (!value || typeof value !== "object") return null;189190  const candidate = value as Partial<VaultEntry> & {191    name?: string;192    note?: string;193    email?: string;194  };195196  const deletedAt =197    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;198199  if (candidate.type === "note") {200    const title = firstNonEmptyString(201      candidate.title,202      candidate.name,203      "Untitled"204    );205    if (!title) return null;206    return {207      type: "note",208      title,209      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,210      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,211      deletedAt,212    };213  }214215  if (candidate.type === "passkey") {216    if (217      typeof candidate.rpId !== "string" ||218      typeof candidate.rpName !== "string" ||219      typeof candidate.credentialId !== "string" ||220      typeof candidate.publicKeyCose !== "string" ||221      !candidate.privateKeyJwk ||222      typeof candidate.userHandle !== "string" ||223      typeof candidate.userName !== "string" ||224      typeof candidate.userDisplayName !== "string" ||225      (candidate.algorithm !== -7 && candidate.algorithm !== -8)226    ) {227      return null;228    }229230    return {231      type: "passkey",232      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),233      rpId: candidate.rpId,234      rpName: candidate.rpName,235      credentialId: candidate.credentialId,236      publicKeyCose: candidate.publicKeyCose,237      privateKeyJwk: candidate.privateKeyJwk,238      userHandle: candidate.userHandle,239      userName: candidate.userName,240      userDisplayName: candidate.userDisplayName,241      algorithm: candidate.algorithm,242      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,243      aaguid:244        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",245      backupEligible: candidate.backupEligible !== false,246      backupState: candidate.backupState === true,247      lastUsedAt:248        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,249      deletedAt,250    };251  }252253  const password =254    typeof candidate.password === "string" ? candidate.password : "";255  if (!password) return null;256257  const title = firstNonEmptyString(258    candidate.title,259    candidate.name,260    candidate.url,261    candidate.username,262    candidate.email,263    "Untitled"264  );265266  return {267    type: "login",268    title,269    username: firstNonEmptyString(candidate.username, candidate.email, ""),270    password,271    url: firstNonEmptyString(candidate.url, ""),272    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,273    totp: firstNonEmptyString(candidate.totp) || undefined,274    breach:275      typeof candidate.breach === "number" && candidate.breach > 0276        ? candidate.breach277        : undefined,278    canary: candidate.canary ?? undefined,279    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,280    deletedAt,281  };282}283284function firstNonEmptyString(285  ...values: Array<string | null | undefined>286): string {287  for (const value of values) {288    if (typeof value === "string" && value.trim()) {289      return value.trim();290    }291  }292  return "";293}294295function toHex(buf: Uint8Array): string {296  return Array.from(buf)297    .map((byte) => byte.toString(16).padStart(2, "0"))298    .join("");299}300301function fromHex(input: string): Uint8Array {302  const out = new Uint8Array(input.length / 2);303  for (let i = 0; i < input.length; i += 2) {304    out[i / 2] = parseInt(input.slice(i, i + 2), 16);305  }306  return out;307}