sync-entry-payload.ts

3.6 KB · raw

1import type { EncryptedSyncEntry } from "./sync-types";2import type { VaultEntry } from "./types";34const NONCE_BYTES = 12;5const GCM_TAG_BYTES = 16;6const textEncoder = new TextEncoder();7const textDecoder = new TextDecoder("utf-8", { fatal: true });89export async function importSyncEntryKey(rawKey: Uint8Array): Promise<CryptoKey> {10  if (rawKey.length !== 32) {11    throw new Error("sync entry key must be 32 bytes");12  }13  return crypto.subtle.importKey("raw", asArrayBuffer(rawKey), "AES-GCM", false, [14    "encrypt",15    "decrypt",16  ]);17}1819export async function encryptSyncEntry(20  entry: VaultEntry,21  key: CryptoKey22): Promise<EncryptedSyncEntry> {23  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));24  const syncEntry = stripNonPortableSecrets(entry);25  const encrypted = new Uint8Array(26    await crypto.subtle.encrypt(27      { name: "AES-GCM", iv: nonce, tagLength: GCM_TAG_BYTES * 8 },28      key,29      textEncoder.encode(JSON.stringify(syncEntry))30    )31  );32  const tagOffset = encrypted.length - GCM_TAG_BYTES;33  if (tagOffset < 0) throw new Error("invalid encrypted sync entry");3435  return {36    id: syncEntry.id,37    modifiedAt: syncEntry.modifiedAt,38    ...(syncEntry.deletedAt !== undefined ? { deletedAt: syncEntry.deletedAt } : {}),39    ciphertext: base64urlEncode(encrypted.slice(0, tagOffset)),40    nonce: base64urlEncode(nonce),41    tag: base64urlEncode(encrypted.slice(tagOffset)),42  };43}4445export async function decryptSyncEntry(46  payload: EncryptedSyncEntry,47  key: CryptoKey48): Promise<VaultEntry> {49  const ciphertext = base64urlDecode(payload.ciphertext);50  const tag = base64urlDecode(payload.tag);51  const combined = new Uint8Array(ciphertext.length + tag.length);52  combined.set(ciphertext);53  combined.set(tag, ciphertext.length);5455  const plain = textDecoder.decode(56    await crypto.subtle.decrypt(57      {58        name: "AES-GCM",59        iv: asArrayBuffer(base64urlDecode(payload.nonce)),60        tagLength: GCM_TAG_BYTES * 8,61      },62      key,63      combined64    )65  );66  const entry = JSON.parse(plain) as unknown;67  if (!isVaultEntryEnvelope(entry, payload)) {68    throw new Error("invalid sync entry payload");69  }70  return entry;71}7273export function stripNonPortableSecrets(entry: VaultEntry): VaultEntry {74  const clone = structuredClone(entry);75  if (clone.passkey) {76    delete clone.passkey.privateKeyJwk;77  }78  delete clone.privateKeyJwk;79  return clone;80}8182function isVaultEntryEnvelope(value: unknown, envelope: EncryptedSyncEntry): value is VaultEntry {83  if (!value || typeof value !== "object" || Array.isArray(value)) return false;84  const entry = value as Partial<VaultEntry>;85  return (86    entry.id === envelope.id &&87    entry.modifiedAt === envelope.modifiedAt &&88    entry.deletedAt === envelope.deletedAt &&89    typeof entry.title === "string" &&90    typeof entry.createdAt === "number"91  );92}9394function base64urlEncode(bytes: Uint8Array): string {95  let binary = "";96  for (const byte of bytes) binary += String.fromCharCode(byte);97  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");98}99100function base64urlDecode(value: string): Uint8Array {101  if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {102    throw new Error("invalid base64url");103  }104  const padded = value105    .replace(/-/g, "+")106    .replace(/_/g, "/")107    .padEnd(Math.ceil(value.length / 4) * 4, "=");108  const binary = atob(padded);109  const out = new Uint8Array(binary.length);110  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);111  return out;112}113114function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {115  const copy = new Uint8Array(bytes.length);116  copy.set(bytes);117  return copy.buffer;118}