backup.ts

7.4 KB · raw

1import { decrypt, encrypt } from "../background/aes";2import type { PeachLicenseExport, VaultEntry } from "./types";34/**5 * Encrypted Peach JSON envelope (legacy v2). v2.5.0 removed the6 * encrypted-JSON export option from the UI in favour of PeachScript and the7 * QR matrix code (see src/crypto/peachPayload.ts). The import path remains8 * so users can still restore older `.json` backups, and the export helper9 * below is kept private to tests that exercise the import path with10 * round-trip fixtures.11 */12export interface PeachExportEnvelope {13  version: 2;14  type: "peach-export";15  kdf: "PBKDF2-SHA256";16  salt: string;17  nonce: string;18  ciphertext: string;19  tag: string;20}2122export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2324/** @internal — test fixture generator only. No UI code path calls this. */25export async function exportEntriesToPeachJson(26  entries: VaultEntry[],27  password: string,28  license?: PeachLicenseExport29): Promise<PeachExportEnvelope> {30  const salt = crypto.getRandomValues(new Uint8Array(16));31  const key = await derivePeachExportKey(password, salt);32  const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };33  if (license) inner.license = license;34  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));3536  return {37    version: 2,38    type: "peach-export",39    kdf: "PBKDF2-SHA256",40    salt: toHex(salt),41    nonce,42    ciphertext,43    tag,44  };45}4647export interface PeachJsonImportResult {48  entries: ImportableEntry[];49  licenseKey?: string;50  licenseActivatedAt?: number;51}5253export async function importEntriesFromPeachJson(54  raw: string,55  password: string56): Promise<PeachJsonImportResult> {57  let envelope: Partial<PeachExportEnvelope>;58  try {59    envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;60  } catch {61    throw new Error("INVALID_PEACH_EXPORT");62  }6364  if (65    envelope.type !== "peach-export" ||66    envelope.kdf !== "PBKDF2-SHA256" ||67    envelope.version !== 2 ||68    !envelope.salt ||69    !envelope.nonce ||70    !envelope.ciphertext ||71    !envelope.tag72  ) {73    throw new Error("INVALID_PEACH_EXPORT");74  }7576  let plaintext: string;77  try {78    const key = await derivePeachExportKey(password, fromHex(envelope.salt));79    plaintext = await decrypt(80      key,81      envelope.nonce,82      envelope.ciphertext,83      envelope.tag84    );85  } catch {86    throw new Error("INVALID_EXPORT_PASSWORD");87  }8889  let parsed: unknown;90  try {91    parsed = JSON.parse(plaintext);92  } catch {93    throw new Error("INVALID_PEACH_EXPORT");94  }9596  // Handle both old format (plain entries array) and new format ({ entries, license? })97  let rawEntries: unknown[];98  let licenseKey: string | undefined;99  let licenseActivatedAt: number | undefined;100101  if (Array.isArray(parsed)) {102    rawEntries = parsed;103  } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {104    const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };105    rawEntries = obj.entries;106    if (obj.license && typeof obj.license.key === "string") {107      licenseKey = obj.license.key;108      licenseActivatedAt = obj.license.activatedAt;109    }110  } else {111    throw new Error("INVALID_PEACH_EXPORT");112  }113114  const entries = rawEntries115    .map(normalizeImportedEntry)116    .filter((entry): entry is ImportableEntry => entry !== null);117118  return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };119}120121export async function derivePeachExportKey(122  password: string,123  salt: Uint8Array124): Promise<Uint8Array> {125  const material = await crypto.subtle.importKey(126    "raw",127    new TextEncoder().encode(password),128    "PBKDF2",129    false,130    ["deriveBits"]131  );132  const bits = await crypto.subtle.deriveBits(133    {134      name: "PBKDF2",135      salt: salt.buffer as ArrayBuffer,136      iterations: 250000,137      hash: "SHA-256",138    },139    material,140    256141  );142  return new Uint8Array(bits);143}144145function normalizeImportedEntry(value: unknown): ImportableEntry | null {146  if (!value || typeof value !== "object") return null;147148  const candidate = value as Partial<VaultEntry> & {149    name?: string;150    note?: string;151    email?: string;152  };153154  const deletedAt =155    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;156157  if (candidate.type === "note") {158    const title = firstNonEmptyString(159      candidate.title,160      candidate.name,161      "Untitled"162    );163    if (!title) return null;164    return {165      type: "note",166      title,167      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,168      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,169      deletedAt,170    };171  }172173  if (candidate.type === "passkey") {174    if (175      typeof candidate.rpId !== "string" ||176      typeof candidate.rpName !== "string" ||177      typeof candidate.credentialId !== "string" ||178      typeof candidate.publicKeyCose !== "string" ||179      !candidate.privateKeyJwk ||180      typeof candidate.userHandle !== "string" ||181      typeof candidate.userName !== "string" ||182      typeof candidate.userDisplayName !== "string" ||183      (candidate.algorithm !== -7 && candidate.algorithm !== -8)184    ) {185      return null;186    }187188    return {189      type: "passkey",190      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),191      rpId: candidate.rpId,192      rpName: candidate.rpName,193      credentialId: candidate.credentialId,194      publicKeyCose: candidate.publicKeyCose,195      privateKeyJwk: candidate.privateKeyJwk,196      userHandle: candidate.userHandle,197      userName: candidate.userName,198      userDisplayName: candidate.userDisplayName,199      algorithm: candidate.algorithm,200      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,201      aaguid:202        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",203      backupEligible: candidate.backupEligible !== false,204      backupState: candidate.backupState === true,205      lastUsedAt:206        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,207      deletedAt,208    };209  }210211  const password =212    typeof candidate.password === "string" ? candidate.password : "";213  if (!password) return null;214215  const title = firstNonEmptyString(216    candidate.title,217    candidate.name,218    candidate.url,219    candidate.username,220    candidate.email,221    "Untitled"222  );223224  return {225    type: "login",226    title,227    username: firstNonEmptyString(candidate.username, candidate.email, ""),228    password,229    url: firstNonEmptyString(candidate.url, ""),230    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,231    totp: firstNonEmptyString(candidate.totp) || undefined,232    breach:233      typeof candidate.breach === "number" && candidate.breach > 0234        ? candidate.breach235        : undefined,236    canary: candidate.canary ?? undefined,237    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,238    deletedAt,239  };240}241242function firstNonEmptyString(243  ...values: Array<string | null | undefined>244): string {245  for (const value of values) {246    if (typeof value === "string" && value.trim()) {247      return value.trim();248    }249  }250  return "";251}252253function toHex(buf: Uint8Array): string {254  return Array.from(buf)255    .map((byte) => byte.toString(16).padStart(2, "0"))256    .join("");257}258259function fromHex(input: string): Uint8Array {260  const out = new Uint8Array(input.length / 2);261  for (let i = 0; i < input.length; i += 2) {262    out[i / 2] = parseInt(input.slice(i, i + 2), 16);263  }264  return out;265}