backup.ts

7.1 KB · raw

1import { decrypt, encrypt } from "../background/aes";2import type { PeachLicenseExport, VaultEntry } from "./types";34export interface PeachExportEnvelope {5  version: 2;6  type: "peach-export";7  kdf: "PBKDF2-SHA256";8  salt: string;9  nonce: string;10  ciphertext: string;11  tag: string;12}1314export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;1516export async function exportEntriesToPeachJson(17  entries: VaultEntry[],18  password: string,19  license?: PeachLicenseExport20): Promise<PeachExportEnvelope> {21  const salt = crypto.getRandomValues(new Uint8Array(16));22  const key = await derivePeachExportKey(password, salt);23  // Inner payload: { entries, license? } — allows license restore on import.24  // Old exports contained only the entries array; importers handle both shapes.25  const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };26  if (license) inner.license = license;27  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));2829  return {30    version: 2,31    type: "peach-export",32    kdf: "PBKDF2-SHA256",33    salt: toHex(salt),34    nonce,35    ciphertext,36    tag,37  };38}3940export interface PeachJsonImportResult {41  entries: ImportableEntry[];42  licenseKey?: string;43  licenseActivatedAt?: number;44}4546export async function importEntriesFromPeachJson(47  raw: string,48  password: string49): Promise<PeachJsonImportResult> {50  let envelope: Partial<PeachExportEnvelope>;51  try {52    envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;53  } catch {54    throw new Error("INVALID_PEACH_EXPORT");55  }5657  if (58    envelope.type !== "peach-export" ||59    envelope.kdf !== "PBKDF2-SHA256" ||60    envelope.version !== 2 ||61    !envelope.salt ||62    !envelope.nonce ||63    !envelope.ciphertext ||64    !envelope.tag65  ) {66    throw new Error("INVALID_PEACH_EXPORT");67  }6869  let plaintext: string;70  try {71    const key = await derivePeachExportKey(password, fromHex(envelope.salt));72    plaintext = await decrypt(73      key,74      envelope.nonce,75      envelope.ciphertext,76      envelope.tag77    );78  } catch {79    throw new Error("INVALID_EXPORT_PASSWORD");80  }8182  let parsed: unknown;83  try {84    parsed = JSON.parse(plaintext);85  } catch {86    throw new Error("INVALID_PEACH_EXPORT");87  }8889  // Handle both old format (plain entries array) and new format ({ entries, license? })90  let rawEntries: unknown[];91  let licenseKey: string | undefined;92  let licenseActivatedAt: number | undefined;9394  if (Array.isArray(parsed)) {95    rawEntries = parsed;96  } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {97    const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };98    rawEntries = obj.entries;99    if (obj.license && typeof obj.license.key === "string") {100      licenseKey = obj.license.key;101      licenseActivatedAt = obj.license.activatedAt;102    }103  } else {104    throw new Error("INVALID_PEACH_EXPORT");105  }106107  const entries = rawEntries108    .map(normalizeImportedEntry)109    .filter((entry): entry is ImportableEntry => entry !== null);110111  return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };112}113114export async function derivePeachExportKey(115  password: string,116  salt: Uint8Array117): Promise<Uint8Array> {118  const material = await crypto.subtle.importKey(119    "raw",120    new TextEncoder().encode(password),121    "PBKDF2",122    false,123    ["deriveBits"]124  );125  const bits = await crypto.subtle.deriveBits(126    {127      name: "PBKDF2",128      salt: salt.buffer as ArrayBuffer,129      iterations: 250000,130      hash: "SHA-256",131    },132    material,133    256134  );135  return new Uint8Array(bits);136}137138function normalizeImportedEntry(value: unknown): ImportableEntry | null {139  if (!value || typeof value !== "object") return null;140141  const candidate = value as Partial<VaultEntry> & {142    name?: string;143    note?: string;144    email?: string;145  };146147  const deletedAt =148    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;149150  if (candidate.type === "note") {151    const title = firstNonEmptyString(152      candidate.title,153      candidate.name,154      "Untitled"155    );156    if (!title) return null;157    return {158      type: "note",159      title,160      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,161      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,162      deletedAt,163    };164  }165166  if (candidate.type === "passkey") {167    if (168      typeof candidate.rpId !== "string" ||169      typeof candidate.rpName !== "string" ||170      typeof candidate.credentialId !== "string" ||171      typeof candidate.publicKeyCose !== "string" ||172      !candidate.privateKeyJwk ||173      typeof candidate.userHandle !== "string" ||174      typeof candidate.userName !== "string" ||175      typeof candidate.userDisplayName !== "string" ||176      (candidate.algorithm !== -7 && candidate.algorithm !== -8)177    ) {178      return null;179    }180181    return {182      type: "passkey",183      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),184      rpId: candidate.rpId,185      rpName: candidate.rpName,186      credentialId: candidate.credentialId,187      publicKeyCose: candidate.publicKeyCose,188      privateKeyJwk: candidate.privateKeyJwk,189      userHandle: candidate.userHandle,190      userName: candidate.userName,191      userDisplayName: candidate.userDisplayName,192      algorithm: candidate.algorithm,193      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,194      aaguid:195        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",196      backupEligible: candidate.backupEligible !== false,197      backupState: candidate.backupState === true,198      lastUsedAt:199        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,200      deletedAt,201    };202  }203204  const password =205    typeof candidate.password === "string" ? candidate.password : "";206  if (!password) return null;207208  const title = firstNonEmptyString(209    candidate.title,210    candidate.name,211    candidate.url,212    candidate.username,213    candidate.email,214    "Untitled"215  );216217  return {218    type: "login",219    title,220    username: firstNonEmptyString(candidate.username, candidate.email, ""),221    password,222    url: firstNonEmptyString(candidate.url, ""),223    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,224    totp: firstNonEmptyString(candidate.totp) || undefined,225    breach:226      typeof candidate.breach === "number" && candidate.breach > 0227        ? candidate.breach228        : undefined,229    canary: candidate.canary ?? undefined,230    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,231    deletedAt,232  };233}234235function firstNonEmptyString(236  ...values: Array<string | null | undefined>237): string {238  for (const value of values) {239    if (typeof value === "string" && value.trim()) {240      return value.trim();241    }242  }243  return "";244}245246function toHex(buf: Uint8Array): string {247  return Array.from(buf)248    .map((byte) => byte.toString(16).padStart(2, "0"))249    .join("");250}251252function fromHex(input: string): Uint8Array {253  const out = new Uint8Array(input.length / 2);254  for (let i = 0; i < input.length; i += 2) {255    out[i / 2] = parseInt(input.slice(i, i + 2), 16);256  }257  return out;258}