backup.ts

7.8 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    throw new Error("INVALID_EXPORT_PASSWORD");95  }9697  let parsed: unknown;98  try {99    parsed = JSON.parse(plaintext);100  } catch {101    throw new Error("INVALID_PEACH_EXPORT");102  }103104  // Handle both old format (plain entries array) and new format ({ entries, license? })105  let rawEntries: unknown[];106  let licenseKey: string | undefined;107  let licenseActivatedAt: number | undefined;108109  if (Array.isArray(parsed)) {110    rawEntries = parsed;111  } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {112    const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };113    rawEntries = obj.entries;114    if (obj.license && typeof obj.license.key === "string") {115      licenseKey = obj.license.key;116      licenseActivatedAt = obj.license.activatedAt;117    }118  } else {119    throw new Error("INVALID_PEACH_EXPORT");120  }121122  const entries = rawEntries123    .map(normalizeImportedEntry)124    .filter((entry): entry is ImportableEntry => entry !== null);125126  return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };127}128129export async function derivePeachExportKey(130  password: string,131  salt: Uint8Array132): Promise<Uint8Array> {133  const material = await crypto.subtle.importKey(134    "raw",135    new TextEncoder().encode(password),136    "PBKDF2",137    false,138    ["deriveBits"]139  );140  const bits = await crypto.subtle.deriveBits(141    {142      name: "PBKDF2",143      salt: salt.buffer as ArrayBuffer,144      iterations: 250000,145      hash: "SHA-256",146    },147    material,148    256149  );150  return new Uint8Array(bits);151}152153function normalizeImportedEntry(value: unknown): ImportableEntry | null {154  if (!value || typeof value !== "object") return null;155156  const candidate = value as Partial<VaultEntry> & {157    name?: string;158    note?: string;159    email?: string;160  };161162  const deletedAt =163    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;164165  if (candidate.type === "note") {166    const title = firstNonEmptyString(167      candidate.title,168      candidate.name,169      "Untitled"170    );171    if (!title) return null;172    return {173      type: "note",174      title,175      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,176      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,177      deletedAt,178    };179  }180181  if (candidate.type === "passkey") {182    if (183      typeof candidate.rpId !== "string" ||184      typeof candidate.rpName !== "string" ||185      typeof candidate.credentialId !== "string" ||186      typeof candidate.publicKeyCose !== "string" ||187      !candidate.privateKeyJwk ||188      typeof candidate.userHandle !== "string" ||189      typeof candidate.userName !== "string" ||190      typeof candidate.userDisplayName !== "string" ||191      (candidate.algorithm !== -7 && candidate.algorithm !== -8)192    ) {193      return null;194    }195196    return {197      type: "passkey",198      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),199      rpId: candidate.rpId,200      rpName: candidate.rpName,201      credentialId: candidate.credentialId,202      publicKeyCose: candidate.publicKeyCose,203      privateKeyJwk: candidate.privateKeyJwk,204      userHandle: candidate.userHandle,205      userName: candidate.userName,206      userDisplayName: candidate.userDisplayName,207      algorithm: candidate.algorithm,208      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,209      aaguid:210        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",211      backupEligible: candidate.backupEligible !== false,212      backupState: candidate.backupState === true,213      lastUsedAt:214        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,215      deletedAt,216    };217  }218219  const password =220    typeof candidate.password === "string" ? candidate.password : "";221  if (!password) return null;222223  const title = firstNonEmptyString(224    candidate.title,225    candidate.name,226    candidate.url,227    candidate.username,228    candidate.email,229    "Untitled"230  );231232  return {233    type: "login",234    title,235    username: firstNonEmptyString(candidate.username, candidate.email, ""),236    password,237    url: firstNonEmptyString(candidate.url, ""),238    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,239    totp: firstNonEmptyString(candidate.totp) || undefined,240    breach:241      typeof candidate.breach === "number" && candidate.breach > 0242        ? candidate.breach243        : undefined,244    canary: candidate.canary ?? undefined,245    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,246    deletedAt,247  };248}249250function firstNonEmptyString(251  ...values: Array<string | null | undefined>252): string {253  for (const value of values) {254    if (typeof value === "string" && value.trim()) {255      return value.trim();256    }257  }258  return "";259}260261function toHex(buf: Uint8Array): string {262  return Array.from(buf)263    .map((byte) => byte.toString(16).padStart(2, "0"))264    .join("");265}266267function fromHex(input: string): Uint8Array {268  const out = new Uint8Array(input.length / 2);269  for (let i = 0; i < input.length; i += 2) {270    out[i / 2] = parseInt(input.slice(i, i + 2), 16);271  }272  return out;273}