UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

134 lines 6.06 kB
import "../server-only.js"; /** Malformed, unauthenticated, or undecryptable ciphertext. Contains no input. */ export class EncryptionDecryptionError extends Error { /** Stable error name, independent of the decryption failure's cause. */ 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) { // Bound each spread so large values do not overflow the argument stack. const chunks = []; 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) { 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, label) { 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) { 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() { 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) { 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 = []; const getKey = (index) => { 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 = 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(); }, }; } //# sourceMappingURL=index.js.map