@beignet/core
Version:
Core framework primitives for Beignet
134 lines • 6.06 kB
JavaScript
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