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:
committed by
gsxdsm
parent
27dd927213
commit
63abf5cac4
159
packages/core/src/secrets-crypto.ts
Normal file
159
packages/core/src/secrets-crypto.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user