import type { EncryptedSyncEntry } from "./sync-types"; import type { VaultEntry } from "./types"; const NONCE_BYTES = 12; const GCM_TAG_BYTES = 16; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder("utf-8", { fatal: true }); export async function importSyncEntryKey(rawKey: Uint8Array): Promise { if (rawKey.length !== 32) { throw new Error("sync entry key must be 32 bytes"); } return crypto.subtle.importKey("raw", asArrayBuffer(rawKey), "AES-GCM", false, [ "encrypt", "decrypt", ]); } export async function encryptSyncEntry( entry: VaultEntry, key: CryptoKey ): Promise { const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES)); const syncEntry = stripNonPortableSecrets(entry); const encrypted = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, tagLength: GCM_TAG_BYTES * 8 }, key, textEncoder.encode(JSON.stringify(syncEntry)) ) ); const tagOffset = encrypted.length - GCM_TAG_BYTES; if (tagOffset < 0) throw new Error("invalid encrypted sync entry"); return { id: syncEntry.id, modifiedAt: syncEntry.modifiedAt, ...(syncEntry.deletedAt !== undefined ? { deletedAt: syncEntry.deletedAt } : {}), ciphertext: base64urlEncode(encrypted.slice(0, tagOffset)), nonce: base64urlEncode(nonce), tag: base64urlEncode(encrypted.slice(tagOffset)), }; } export async function decryptSyncEntry( payload: EncryptedSyncEntry, key: CryptoKey ): Promise { const ciphertext = base64urlDecode(payload.ciphertext); const tag = base64urlDecode(payload.tag); const combined = new Uint8Array(ciphertext.length + tag.length); combined.set(ciphertext); combined.set(tag, ciphertext.length); const plain = textDecoder.decode( await crypto.subtle.decrypt( { name: "AES-GCM", iv: asArrayBuffer(base64urlDecode(payload.nonce)), tagLength: GCM_TAG_BYTES * 8, }, key, combined ) ); const entry = JSON.parse(plain) as unknown; if (!isVaultEntryEnvelope(entry, payload)) { throw new Error("invalid sync entry payload"); } return entry; } export function stripNonPortableSecrets(entry: VaultEntry): VaultEntry { const clone = structuredClone(entry); if (clone.passkey) { delete clone.passkey.privateKeyJwk; } delete clone.privateKeyJwk; return clone; } function isVaultEntryEnvelope(value: unknown, envelope: EncryptedSyncEntry): value is VaultEntry { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const entry = value as Partial; return ( entry.id === envelope.id && entry.modifiedAt === envelope.modifiedAt && entry.deletedAt === envelope.deletedAt && typeof entry.title === "string" && typeof entry.createdAt === "number" ); } function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function base64urlDecode(value: string): Uint8Array { if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { throw new Error("invalid base64url"); } const padded = value .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(padded); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); return out; } function asArrayBuffer(bytes: Uint8Array): ArrayBuffer { const copy = new Uint8Array(bytes.length); copy.set(bytes); return copy.buffer; }