backup.ts

6.0 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<VaultEntry, "id" | "createdAt" | "modifiedAt">;1516export async function exportEntriesToPeachJson(17  entries: VaultEntry[],18  password: string19): Promise<PeachExportEnvelope> {20  const salt = crypto.getRandomValues(new Uint8Array(16));21  const key = await derivePeachExportKey(password, salt);22  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(entries));2324  return {25    version: 2,26    type: "peach-export",27    kdf: "PBKDF2-SHA256",28    salt: toHex(salt),29    nonce,30    ciphertext,31    tag,32  };33}3435export async function importEntriesFromPeachJson(36  raw: string,37  password: string38): Promise<ImportableEntry[]> {39  let envelope: Partial<PeachExportEnvelope>;40  try {41    envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;42  } catch {43    throw new Error("INVALID_PEACH_EXPORT");44  }4546  if (47    envelope.type !== "peach-export" ||48    envelope.kdf !== "PBKDF2-SHA256" ||49    envelope.version !== 2 ||50    !envelope.salt ||51    !envelope.nonce ||52    !envelope.ciphertext ||53    !envelope.tag54  ) {55    throw new Error("INVALID_PEACH_EXPORT");56  }5758  let plaintext: string;59  try {60    const key = await derivePeachExportKey(password, fromHex(envelope.salt));61    plaintext = await decrypt(62      key,63      envelope.nonce,64      envelope.ciphertext,65      envelope.tag66    );67  } catch {68    throw new Error("INVALID_EXPORT_PASSWORD");69  }7071  let parsed: unknown;72  try {73    parsed = JSON.parse(plaintext);74  } catch {75    throw new Error("INVALID_PEACH_EXPORT");76  }7778  if (!Array.isArray(parsed)) {79    throw new Error("INVALID_PEACH_EXPORT");80  }8182  return parsed83    .map(normalizeImportedEntry)84    .filter((entry): entry is ImportableEntry => entry !== null);85}8687export async function derivePeachExportKey(88  password: string,89  salt: Uint8Array90): Promise<Uint8Array> {91  const material = await crypto.subtle.importKey(92    "raw",93    new TextEncoder().encode(password),94    "PBKDF2",95    false,96    ["deriveBits"]97  );98  const bits = await crypto.subtle.deriveBits(99    {100      name: "PBKDF2",101      salt: salt.buffer as ArrayBuffer,102      iterations: 250000,103      hash: "SHA-256",104    },105    material,106    256107  );108  return new Uint8Array(bits);109}110111function normalizeImportedEntry(value: unknown): ImportableEntry | null {112  if (!value || typeof value !== "object") return null;113114  const candidate = value as Partial<VaultEntry> & {115    name?: string;116    note?: string;117    email?: string;118  };119120  const deletedAt =121    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;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      deletedAt,136    };137  }138139  if (candidate.type === "passkey") {140    if (141      typeof candidate.rpId !== "string" ||142      typeof candidate.rpName !== "string" ||143      typeof candidate.credentialId !== "string" ||144      typeof candidate.publicKeyCose !== "string" ||145      !candidate.privateKeyJwk ||146      typeof candidate.userHandle !== "string" ||147      typeof candidate.userName !== "string" ||148      typeof candidate.userDisplayName !== "string" ||149      (candidate.algorithm !== -7 && candidate.algorithm !== -8)150    ) {151      return null;152    }153154    return {155      type: "passkey",156      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),157      rpId: candidate.rpId,158      rpName: candidate.rpName,159      credentialId: candidate.credentialId,160      publicKeyCose: candidate.publicKeyCose,161      privateKeyJwk: candidate.privateKeyJwk,162      userHandle: candidate.userHandle,163      userName: candidate.userName,164      userDisplayName: candidate.userDisplayName,165      algorithm: candidate.algorithm,166      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,167      aaguid:168        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",169      backupEligible: candidate.backupEligible !== false,170      backupState: candidate.backupState === true,171      lastUsedAt:172        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,173      deletedAt,174    };175  }176177  const password =178    typeof candidate.password === "string" ? candidate.password : "";179  if (!password) return null;180181  const title = firstNonEmptyString(182    candidate.title,183    candidate.name,184    candidate.url,185    candidate.username,186    candidate.email,187    "Untitled"188  );189190  return {191    type: "login",192    title,193    username: firstNonEmptyString(candidate.username, candidate.email, ""),194    password,195    url: firstNonEmptyString(candidate.url, ""),196    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,197    totp: firstNonEmptyString(candidate.totp) || undefined,198    breach:199      typeof candidate.breach === "number" && candidate.breach > 0200        ? candidate.breach201        : undefined,202    canary: candidate.canary ?? undefined,203    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,204    deletedAt,205  };206}207208function firstNonEmptyString(209  ...values: Array<string | null | undefined>210): string {211  for (const value of values) {212    if (typeof value === "string" && value.trim()) {213      return value.trim();214    }215  }216  return "";217}218219function toHex(buf: Uint8Array): string {220  return Array.from(buf)221    .map((byte) => byte.toString(16).padStart(2, "0"))222    .join("");223}224225function fromHex(input: string): Uint8Array {226  const out = new Uint8Array(input.length / 2);227  for (let i = 0; i < input.length; i += 2) {228    out[i / 2] = parseInt(input.slice(i, i + 2), 16);229  }230  return out;231}