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}