feat(FN-4790): complete Step 1 — implement secrets crypto primitive

Fusion-Task-Id: FN-4790
Fusion-Task-Lineage: 4daf3560-7b02-4fbf-9e0a-45e048b795a1
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 18:41:25 -07:00
committed by gsxdsm
parent 27dd927213
commit 63abf5cac4

View File

@@ -0,0 +1,159 @@
/**
* Secret crypto primitives for at-rest secret protection.
*
* Security contract:
* - Uses AES-256-GCM via Node's built-in crypto module.
* - Uses a fresh random 12-byte nonce for every encrypt call.
* - Appends the 16-byte GCM auth tag to the returned ciphertext buffer.
* - Never logs or throws plaintext, key material, or raw ciphertext bytes.
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
const NONCE_LENGTH_BYTES = 12;
const AUTH_TAG_LENGTH_BYTES = 16;
const REDACTED_PREFIX_LENGTH_HEX = 8;
/**
* Async provider that resolves a 32-byte AES-256 master key.
*/
export type MasterKeyProvider = () => Promise<Buffer>;
/**
* AES-GCM encrypted secret payload.
*
* `ciphertext` includes the auth tag appended as the trailing 16 bytes.
*/
export interface EncryptedSecret {
ciphertext: Buffer;
nonce: Buffer;
}
/**
* Non-sensitive error type for secret crypto failures.
*
* Messages are fixed, non-revealing strings and never include secret data.
*/
export class SecretCryptoError extends Error {
readonly code:
| "invalid-key-length"
| "invalid-nonce-length"
| "decryption-failed"
| "invalid-ciphertext";
constructor(params: {
code:
| "invalid-key-length"
| "invalid-nonce-length"
| "decryption-failed"
| "invalid-ciphertext";
message: string;
cause?: unknown;
}) {
super(params.message);
this.name = "SecretCryptoError";
this.code = params.code;
if (params.cause !== undefined) {
this.cause = params.cause;
}
}
}
/**
* Return a stable, non-reversible secret log reference.
*
* The output format is `[REDACTED:<sha256-first-8-hex>]`.
*/
export function redactForLog(value: string): string {
const digest = createHash("sha256").update(value, "utf8").digest("hex");
return `[REDACTED:${digest.slice(0, REDACTED_PREFIX_LENGTH_HEX)}]`;
}
function toValidatedKey(rawKey: Buffer): Buffer {
if (rawKey.length !== 32) {
throw new SecretCryptoError({
code: "invalid-key-length",
message: "secret key length is invalid",
});
}
return Buffer.from(rawKey);
}
function assertEncryptedSecretShape(encrypted: EncryptedSecret): void {
if (encrypted.nonce.length !== NONCE_LENGTH_BYTES) {
throw new SecretCryptoError({
code: "invalid-nonce-length",
message: "secret nonce length is invalid",
});
}
if (encrypted.ciphertext.length < AUTH_TAG_LENGTH_BYTES) {
throw new SecretCryptoError({
code: "invalid-ciphertext",
message: "secret ciphertext is invalid",
});
}
}
/**
* Create an AES-256-GCM secret cipher bound to a master-key provider.
*/
export function createSecretCipher(provider: MasterKeyProvider): {
encrypt(plaintext: string): Promise<EncryptedSecret>;
decrypt(encrypted: EncryptedSecret): Promise<string>;
} {
return {
async encrypt(plaintext: string): Promise<EncryptedSecret> {
if (typeof plaintext !== "string") {
throw new SecretCryptoError({
code: "invalid-ciphertext",
message: "secret plaintext is invalid",
});
}
const rawKey = await provider();
const key = toValidatedKey(rawKey);
try {
const nonce = randomBytes(NONCE_LENGTH_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
const payload = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
ciphertext: Buffer.concat([payload, authTag]),
nonce,
};
} finally {
key.fill(0);
}
},
async decrypt(encrypted: EncryptedSecret): Promise<string> {
assertEncryptedSecretShape(encrypted);
const rawKey = await provider();
const key = toValidatedKey(rawKey);
try {
const payloadEnd = encrypted.ciphertext.length - AUTH_TAG_LENGTH_BYTES;
const payload = encrypted.ciphertext.subarray(0, payloadEnd);
const authTag = encrypted.ciphertext.subarray(payloadEnd);
try {
const decipher = createDecipheriv("aes-256-gcm", key, encrypted.nonce);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
return plaintext.toString("utf8");
} catch {
throw new SecretCryptoError({
code: "decryption-failed",
message: "secret decryption failed",
});
}
} finally {
key.fill(0);
}
},
};
}