src/shared/utils/encryption/providers/aws-secrets-manager-encryption-key.provider.ts
AWS Secrets Manager encryption key provider. Fetches the encryption key from AWS Secrets Manager at startup.
Security benefits:
Required environment variables:
Optional (if not using IAM roles):
Secret format in AWS Secrets Manager:
Properties |
|
Methods |
|
constructor(configService: ConfigService)
|
||||||
|
Parameters :
|
| Private fingerprint | ||||||
fingerprint(key: Buffer)
|
||||||
|
Generate a safe fingerprint of the key for logging (first 8 chars of SHA-256 hash).
Parameters :
Returns :
string
|
| Async getKey |
getKey()
|
|
Returns :
Promise<Buffer>
|
| Private Readonly client |
Type : SecretsManagerClient
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(
AwsSecretsManagerEncryptionKeyProvider.name,
)
|
| Readonly name |
Type : string
|
Default value : "aws"
|
| Private Readonly secretKey |
Type : string
|
| Private Readonly secretName |
Type : string
|
import { createHash } from "node:crypto";
import {
GetSecretValueCommand,
SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EncryptionKeyProvider } from "./encryption-key-provider.interface";
/**
* AWS Secrets Manager encryption key provider.
* Fetches the encryption key from AWS Secrets Manager at startup.
*
* Security benefits:
* - Key is fetched at runtime, only exists in RAM
* - Not exposed via environment variables
* - AWS provides IAM-based access control, audit logging via CloudTrail
* - Automatic key rotation support via AWS Secrets Manager rotation
* - Works seamlessly with IAM roles for EKS/ECS (no credentials in env vars)
*
* Required environment variables:
* - AWS_REGION: AWS region (e.g., us-east-1)
* - AWS_ENCRYPTION_SECRET_NAME: Secret name in Secrets Manager
*
* Optional (if not using IAM roles):
* - AWS_ACCESS_KEY_ID: AWS access key
* - AWS_SECRET_ACCESS_KEY: AWS secret key
*
* Secret format in AWS Secrets Manager:
* - Plain text: 32-byte key encoded as base64 or hex
* - JSON: { "key": "<base64-or-hex-encoded-32-byte-key>" }
*/
@Injectable()
export class AwsSecretsManagerEncryptionKeyProvider
implements EncryptionKeyProvider
{
readonly name = "aws";
private readonly logger = new Logger(
AwsSecretsManagerEncryptionKeyProvider.name,
);
private readonly client: SecretsManagerClient;
private readonly secretName: string;
private readonly secretKey: string;
constructor(private readonly configService: ConfigService) {
const region = this.configService.get<string>("AWS_REGION");
if (!region) {
throw new Error(
"AWS_REGION is required when using AWS Secrets Manager encryption key source",
);
}
this.secretName = this.configService.get<string>(
"AWS_ENCRYPTION_SECRET_NAME",
) as string;
if (!this.secretName) {
throw new Error(
"AWS_ENCRYPTION_SECRET_NAME is required when using AWS Secrets Manager encryption key source",
);
}
// Optional: key name within JSON secret (defaults to "key")
this.secretKey =
this.configService.get<string>("AWS_ENCRYPTION_SECRET_KEY") ||
"key";
this.client = new SecretsManagerClient({
region,
// AWS SDK automatically uses IAM roles when running in AWS
// Or falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
});
}
async getKey(): Promise<Buffer> {
this.logger.log(
`Fetching encryption key from AWS Secrets Manager: ${this.secretName}`,
);
try {
const command = new GetSecretValueCommand({
SecretId: this.secretName,
});
const response = await this.client.send(command);
let keyData: string;
if (response.SecretString) {
// Try to parse as JSON first
try {
const jsonSecret = JSON.parse(response.SecretString);
keyData = jsonSecret[this.secretKey];
if (!keyData) {
throw new Error(
`Secret JSON does not contain key "${this.secretKey}"`,
);
}
} catch {
// Not JSON, treat as plain text key
keyData = response.SecretString;
}
} else if (response.SecretBinary) {
// Binary secret - assume it's the raw key bytes
const binaryKey =
response.SecretBinary instanceof Uint8Array
? Buffer.from(response.SecretBinary)
: Buffer.from(
response.SecretBinary as string,
"base64",
);
if (binaryKey.length === 32) {
this.logger.log(
`Encryption key loaded from AWS (fingerprint: ${this.fingerprint(binaryKey)})`,
);
return binaryKey;
}
throw new Error(
`Invalid binary secret length: expected 32 bytes, got ${binaryKey.length}`,
);
} else {
throw new Error("Secret has no value");
}
// Decode the key (support base64 and hex)
let keyBuffer: Buffer;
if (keyData.length === 64 && /^[0-9a-fA-F]+$/.test(keyData)) {
// Hex-encoded 32 bytes = 64 characters
keyBuffer = Buffer.from(keyData, "hex");
} else {
// Assume base64
keyBuffer = Buffer.from(keyData, "base64");
}
if (keyBuffer.length !== 32) {
throw new Error(
`Invalid encryption key length: expected 32 bytes, got ${keyBuffer.length}. ` +
`Provide a 256-bit key encoded as base64 (44 chars) or hex (64 chars).`,
);
}
this.logger.log(
`Encryption key loaded from AWS (fingerprint: ${this.fingerprint(keyBuffer)})`,
);
return keyBuffer;
} catch (error: any) {
if (error.name === "ResourceNotFoundException") {
throw new Error(
`Encryption key secret not found: ${this.secretName}. ` +
`Create it with: aws secretsmanager create-secret --name ${this.secretName} --secret-string $(openssl rand -base64 32)`,
);
}
if (error.name === "AccessDeniedException") {
throw new Error(
`Access denied to secret: ${this.secretName}. ` +
`Ensure the IAM role/user has secretsmanager:GetSecretValue permission.`,
);
}
throw new Error(
`Failed to fetch encryption key from AWS Secrets Manager: ${error.message}`,
);
}
}
/**
* Generate a safe fingerprint of the key for logging (first 8 chars of SHA-256 hash).
*/
private fingerprint(key: Buffer): string {
return createHash("sha256").update(key).digest("hex").substring(0, 8);
}
}