UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

199 lines (184 loc) 7.13 kB
import "../server-only.js"; /** Non-secret, authenticated metadata. Supply the same expected context on reads. */ export type EncryptionContext = Readonly<Record<string, string>>; /** Options shared by string encryption and decryption. */ export interface EncryptionValueOptions { /** Plaintext for encrypt; the complete encrypted value for decrypt. */ value: string; /** Bind the value to its purpose, tenant, or record. This is not authorization. */ context?: EncryptionContext; } /** Server-side authenticated string encryption. */ export interface EncryptionPort { /** Encrypt a string; persist the complete returned value. */ encrypt(options: EncryptionValueOptions): Promise<string>; /** Authenticate and decrypt a value with the same expected context. */ decrypt(options: EncryptionValueOptions): Promise<string>; } /** Configuration for the built-in AES-256-GCM implementation. */ export interface CreateEncryptionOptions { /** A base64: prefixed, canonical Base64 encoding of 32 random bytes. */ key: string; /** Decryption-only keys in the same format. New writes always use key. */ previousKeys?: readonly string[]; } /** Malformed, unauthenticated, or undecryptable ciphertext. Contains no input. */ export class EncryptionDecryptionError extends Error { /** Stable error name, independent of the decryption failure's cause. */ readonly name = "EncryptionDecryptionError"; constructor() { super("Unable to decrypt encrypted value."); } } const envelopePrefix = "beignet:enc:v1:"; const ivLength = 12; const tagLength = 16; const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: true }); function encodeBase64(bytes: Uint8Array): string { // Bound each spread so large values do not overflow the argument stack. const chunks: string[] = []; for (let offset = 0; offset < bytes.length; offset += 8192) { chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192))); } return btoa(chunks.join("")); } function decodeBase64(value: string): Uint8Array<ArrayBuffer> { if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) { throw new Error("Invalid Base64 encoding."); } const bytes = Uint8Array.from(atob(value), (character) => character.charCodeAt(0), ); if (encodeBase64(bytes) !== value) throw new Error("Noncanonical Base64 encoding."); return bytes; } function decodeKey(value: string, label: string): Uint8Array<ArrayBuffer> { try { if ( typeof value !== "string" || value.length !== 51 || !value.startsWith("base64:") ) throw new Error(); const bytes = decodeBase64(value.slice(7)); if (bytes.length !== 32) throw new Error(); return bytes; } catch { throw new TypeError( `${label} must be base64: followed by the Base64 encoding of 32 bytes. Use generateEncryptionKey().`, ); } } function authenticatedData( context: EncryptionContext | undefined, ): Uint8Array<ArrayBuffer> { if ( context !== undefined && (context === null || typeof context !== "object" || (Object.getPrototypeOf(context) !== Object.prototype && Object.getPrototypeOf(context) !== null)) ) { throw new TypeError( "Encryption context must be a plain record of strings.", ); } const entries = Object.entries(context ?? {}).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0, ); if (entries.some(([, value]) => typeof value !== "string")) { throw new TypeError("Encryption context values must be strings."); } return encoder.encode(JSON.stringify([envelopePrefix, entries])); } /** Generate a new 256-bit key. Store it in a server secret store; never log it. */ export function generateEncryptionKey(): string { return `base64:${encodeBase64(crypto.getRandomValues(new Uint8Array(32)))}`; } /** * Create authenticated string encryption using Web Crypto AES-256-GCM. * Configuration is validated synchronously. Each write uses a random 96-bit IV * and a 128-bit authentication tag. Previous keys are used only for reads. * No environment variables are read and no plaintext, keys, or context are logged. */ export function createEncryption( options: CreateEncryptionOptions, ): EncryptionPort { const current = decodeKey(options?.key, "Encryption key"); if ( options.previousKeys !== undefined && !Array.isArray(options.previousKeys) ) { throw new TypeError("Encryption previousKeys must be an array of keys."); } const rawKeys = [ current, ...Array.from(options.previousKeys ?? [], (key, index) => decodeKey(key, `Encryption previousKeys[${index}]`), ), ]; const importedKeys: Array<Promise<CryptoKey> | undefined> = []; const getKey = (index: number): Promise<CryptoKey> => { const existing = importedKeys[index]; if (existing) return existing; const imported = crypto.subtle.importKey( "raw", rawKeys[index], "AES-GCM", false, ["encrypt", "decrypt"], ); importedKeys[index] = imported; return imported; }; return { async encrypt({ value, context }) { if (typeof value !== "string") throw new TypeError("Encryption value must be a string."); const additionalData = authenticatedData(context); const iv = crypto.getRandomValues(new Uint8Array(ivLength)); const ciphertext = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 }, await getKey(0), // JSON preserves all JavaScript strings, including lone UTF-16 surrogates. encoder.encode(JSON.stringify(value)), ), ); const envelope = new Uint8Array(iv.length + ciphertext.length); envelope.set(iv); envelope.set(ciphertext, iv.length); return `${envelopePrefix}${encodeBase64(envelope)}`; }, async decrypt(options) { try { const { value, context } = options; if (typeof value !== "string" || !value.startsWith(envelopePrefix)) throw new Error(); const envelope = decodeBase64(value.slice(envelopePrefix.length)); if (envelope.length < ivLength + tagLength + 2) throw new Error(); const additionalData = authenticatedData(context); const iv = envelope.slice(0, ivLength); const ciphertext = envelope.slice(ivLength); for (let index = 0; index < rawKeys.length; index++) { try { const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 }, await getKey(index), ciphertext, ); const parsed: unknown = JSON.parse(decoder.decode(plaintext)); if (typeof parsed === "string") return parsed; } catch { // Try only the configured key ring; never return unauthenticated data. } } } catch { // All decryption failures share one non-sensitive public error. } throw new EncryptionDecryptionError(); }, }; }