backup.ts

5.9 KB · raw

1import { decrypt, encrypt } from "../background/aes";2import type { 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<15  VaultEntry,16  "id" | "createdAt" | "modifiedAt" | "deletedAt"17>;1819export async function exportEntriesToPeachJson(20  entries: VaultEntry[],21  password: string22): Promise<PeachExportEnvelope> {23  const salt = crypto.getRandomValues(new Uint8Array(16));24  const key = await derivePeachExportKey(password, salt);25  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(entries));2627  return {28    version: 2,29    type: "peach-export",30    kdf: "PBKDF2-SHA256",31    salt: toHex(salt),32    nonce,33    ciphertext,34    tag,35  };36}3738export async function importEntriesFromPeachJson(39  raw: string,40  password: string41): Promise<ImportableEntry[]> {42  let envelope: Partial<PeachExportEnvelope>;43  try {44    envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;45  } catch {46    throw new Error("INVALID_PEACH_EXPORT");47  }4849  if (50    envelope.type !== "peach-export" ||51    envelope.kdf !== "PBKDF2-SHA256" ||52    envelope.version !== 2 ||53    !envelope.salt ||54    !envelope.nonce ||55    !envelope.ciphertext ||56    !envelope.tag57  ) {58    throw new Error("INVALID_PEACH_EXPORT");59  }6061  let plaintext: string;62  try {63    const key = await derivePeachExportKey(password, fromHex(envelope.salt));64    plaintext = await decrypt(65      key,66      envelope.nonce,67      envelope.ciphertext,68      envelope.tag69    );70  } catch {71    throw new Error("INVALID_EXPORT_PASSWORD");72  }7374  let parsed: unknown;75  try {76    parsed = JSON.parse(plaintext);77  } catch {78    throw new Error("INVALID_PEACH_EXPORT");79  }8081  if (!Array.isArray(parsed)) {82    throw new Error("INVALID_PEACH_EXPORT");83  }8485  return parsed86    .map(normalizeImportedEntry)87    .filter((entry): entry is ImportableEntry => entry !== null);88}8990export async function derivePeachExportKey(91  password: string,92  salt: Uint8Array93): Promise<Uint8Array> {94  const material = await crypto.subtle.importKey(95    "raw",96    new TextEncoder().encode(password),97    "PBKDF2",98    false,99    ["deriveBits"]100  );101  const bits = await crypto.subtle.deriveBits(102    {103      name: "PBKDF2",104      salt: salt.buffer as ArrayBuffer,105      iterations: 250000,106      hash: "SHA-256",107    },108    material,109    256110  );111  return new Uint8Array(bits);112}113114function normalizeImportedEntry(value: unknown): ImportableEntry | null {115  if (!value || typeof value !== "object") return null;116117  const candidate = value as Partial<VaultEntry> & {118    name?: string;119    note?: string;120    email?: string;121  };122123  if (candidate.type === "note") {124    const title = firstNonEmptyString(125      candidate.title,126      candidate.name,127      "Untitled"128    );129    if (!title) return null;130    return {131      type: "note",132      title,133      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,134      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,135    };136  }137138  if (candidate.type === "passkey") {139    if (140      typeof candidate.rpId !== "string" ||141      typeof candidate.rpName !== "string" ||142      typeof candidate.credentialId !== "string" ||143      typeof candidate.publicKeyCose !== "string" ||144      !candidate.privateKeyJwk ||145      typeof candidate.userHandle !== "string" ||146      typeof candidate.userName !== "string" ||147      typeof candidate.userDisplayName !== "string" ||148      (candidate.algorithm !== -7 && candidate.algorithm !== -8)149    ) {150      return null;151    }152153    return {154      type: "passkey",155      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),156      rpId: candidate.rpId,157      rpName: candidate.rpName,158      credentialId: candidate.credentialId,159      publicKeyCose: candidate.publicKeyCose,160      privateKeyJwk: candidate.privateKeyJwk,161      userHandle: candidate.userHandle,162      userName: candidate.userName,163      userDisplayName: candidate.userDisplayName,164      algorithm: candidate.algorithm,165      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,166      aaguid:167        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",168      backupEligible: candidate.backupEligible !== false,169      backupState: candidate.backupState === true,170      lastUsedAt:171        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,172    };173  }174175  const password =176    typeof candidate.password === "string" ? candidate.password : "";177  if (!password) return null;178179  const title = firstNonEmptyString(180    candidate.title,181    candidate.name,182    candidate.url,183    candidate.username,184    candidate.email,185    "Untitled"186  );187188  return {189    type: "login",190    title,191    username: firstNonEmptyString(candidate.username, candidate.email, ""),192    password,193    url: firstNonEmptyString(candidate.url, ""),194    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,195    totp: firstNonEmptyString(candidate.totp) || undefined,196    breach:197      typeof candidate.breach === "number" && candidate.breach > 0198        ? candidate.breach199        : undefined,200    canary: candidate.canary ?? undefined,201    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,202  };203}204205function firstNonEmptyString(206  ...values: Array<string | null | undefined>207): string {208  for (const value of values) {209    if (typeof value === "string" && value.trim()) {210      return value.trim();211    }212  }213  return "";214}215216function toHex(buf: Uint8Array): string {217  return Array.from(buf)218    .map((byte) => byte.toString(16).padStart(2, "0"))219    .join("");220}221222function fromHex(input: string): Uint8Array {223  const out = new Uint8Array(input.length / 2);224  for (let i = 0; i < input.length; i += 2) {225    out[i / 2] = parseInt(input.slice(i, i + 2), 16);226  }227  return out;228}