@beignet/core
Version:
Core framework primitives for Beignet
199 lines (184 loc) • 7.13 kB
text/typescript
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();
},
};
}