/** * Shared server-side encryption utilities for rSpace at-rest encryption. * * Uses AES-256-GCM with keys derived from ENCRYPTION_SECRET via HMAC-SHA256. * File format: [4-byte magic "rSEN"][4-byte keyId length][keyId bytes][12-byte IV][ciphertext+tag] */ // Magic bytes to identify encrypted files: "rSEN" (rSpace ENcrypted) export const ENCRYPTED_MAGIC = new Uint8Array([0x72, 0x53, 0x45, 0x4e]); /** * Derive an AES-256-GCM key from a key identifier using HMAC-SHA256. * Uses ENCRYPTION_SECRET env var as the HMAC key. */ export async function deriveSpaceKey(keyId: string): Promise { const serverSecret = process.env.ENCRYPTION_SECRET; if (!serverSecret) { throw new Error("ENCRYPTION_SECRET environment variable is required"); } const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( "raw", encoder.encode(serverSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const derived = await crypto.subtle.sign( "HMAC", keyMaterial, encoder.encode(keyId), ); return crypto.subtle.importKey( "raw", derived, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } /** * Encrypt binary data using AES-256-GCM. * Returns: 12-byte IV + ciphertext + 16-byte auth tag (concatenated). */ export async function encryptBinary( data: Uint8Array, key: CryptoKey, ): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)); const plainBuf = new ArrayBuffer(data.byteLength); new Uint8Array(plainBuf).set(data); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, plainBuf, ); const result = new Uint8Array(12 + ciphertext.byteLength); result.set(iv, 0); result.set(new Uint8Array(ciphertext), 12); return result; } /** * Decrypt binary data encrypted with AES-256-GCM. * Expects: 12-byte IV + ciphertext + 16-byte auth tag. */ export async function decryptBinary( data: Uint8Array, key: CryptoKey, ): Promise { const iv = data.slice(0, 12); const ciphertext = data.slice(12); const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, ciphertext, ); return new Uint8Array(plaintext); } /** * Check if a byte array starts with the rSEN magic bytes. */ export function isEncryptedFile(bytes: Uint8Array): boolean { return ( bytes.length >= ENCRYPTED_MAGIC.length && bytes[0] === ENCRYPTED_MAGIC[0] && bytes[1] === ENCRYPTED_MAGIC[1] && bytes[2] === ENCRYPTED_MAGIC[2] && bytes[3] === ENCRYPTED_MAGIC[3] ); } /** * Pack an encrypted payload with the rSEN header. * Format: [4-byte magic][4-byte keyId length][keyId UTF-8 bytes][ciphertext] */ export function packEncrypted(keyId: string, ciphertext: Uint8Array): Uint8Array { const keyIdBytes = new TextEncoder().encode(keyId); const packed = new Uint8Array(8 + keyIdBytes.length + ciphertext.length); packed.set(ENCRYPTED_MAGIC, 0); new DataView(packed.buffer).setUint32(4, keyIdBytes.length); packed.set(keyIdBytes, 8); packed.set(ciphertext, 8 + keyIdBytes.length); return packed; } /** * Unpack an rSEN-encrypted file into keyId and ciphertext components. * Assumes caller already checked isEncryptedFile(). */ export function unpackEncrypted(data: Uint8Array): { keyId: string; ciphertext: Uint8Array; } { const keyIdLen = new DataView( data.buffer, data.byteOffset + 4, 4, ).getUint32(0); const keyId = new TextDecoder().decode(data.slice(8, 8 + keyIdLen)); const ciphertext = data.slice(8 + keyIdLen); return { keyId, ciphertext }; }