node-credstasher
Version:
A TypeScript implementation of credstash for storing and retrieving secrets using AWS KMS and DynamoDB.
253 lines (252 loc) • 10.1 kB
JavaScript
import { CreateTableCommand, DescribeTableCommand, DynamoDBClient, ResourceNotFoundException } from "@aws-sdk/client-dynamodb";
import { DecryptCommand, GenerateDataKeyCommand, KMSClient } from "@aws-sdk/client-kms";
import { DeleteCommand, DynamoDBDocumentClient, PutCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import aes_js from "aes-js";
import crypto_0 from "crypto";
const DEFAULT_DIGEST = "sha256";
const decryptItem = async (dbItem, kmsClient, options = {})=>{
const item = {
contents: dbItem.contents,
hmac: Buffer.from(dbItem.hmac).toString("utf-8"),
key: dbItem.key,
digest: dbItem.digest ?? DEFAULT_DIGEST
};
const encryptionContext = options.context ? {
EncryptionContext: {
...options.context
}
} : {};
const decryptedItem = await kmsClient.send(new DecryptCommand({
CiphertextBlob: Buffer.from(item.key, "base64"),
...encryptionContext
}));
if (!decryptedItem.Plaintext) throw new Error("Failed to decrypt item key");
const key = decryptedItem.Plaintext.slice(0, 32);
const hmacKey = decryptedItem.Plaintext.slice(32);
const contents = Buffer.from(item.contents, "base64");
const hmac = crypto_0.createHmac(item.digest, hmacKey);
hmac.update(contents);
const hmacHex = hmac.digest("hex");
if (hmacHex !== item.hmac) throw new Error("HMAC verification failed");
const decrypt = new aes_js.ModeOfOperation.ctr(key);
const decryptedBytes = decrypt.decrypt(contents);
const plaintext = aes_js.utils.utf8.fromBytes(decryptedBytes);
return plaintext;
};
const encryptItem = async (secret, kmsKeyId, kmsClient, options = {})=>{
const encryptionContext = options.context ? {
EncryptionContext: options.context
} : {};
const dataKeyResponse = await kmsClient.send(new GenerateDataKeyCommand({
KeyId: kmsKeyId,
NumberOfBytes: 64,
...encryptionContext
}));
if (!dataKeyResponse.Plaintext || !dataKeyResponse.CiphertextBlob) throw new Error("Failed to generate data key");
const key = dataKeyResponse.Plaintext.slice(0, 32);
const hmacKey = dataKeyResponse.Plaintext.slice(32);
const encryptedKey = dataKeyResponse.CiphertextBlob;
const encrypt = new aes_js.ModeOfOperation.ctr(key);
const valueBytes = aes_js.utils.utf8.toBytes(secret);
const encryptedValue = encrypt.encrypt(valueBytes);
const hmac = crypto_0.createHmac(DEFAULT_DIGEST, hmacKey);
hmac.update(encryptedValue);
const hmacHex = hmac.digest("hex");
return {
contents: Buffer.from(encryptedValue).toString("base64"),
hmac: hmacHex,
key: Buffer.from(encryptedKey).toString("base64"),
digest: DEFAULT_DIGEST
};
};
const DEFAULT_REGION = "us-east-1";
const DEFAULT_TABLE = "credential-store";
const DEFAULT_KMS_KEY_ID = "alias/credstash";
const DEFAULT_KMS_REGION = "us-east-1";
const DEFAULT_PROFILE = "default";
class CredstashClient {
config;
dynamoClient;
docClient;
kmsClient;
constructor(config = {}){
const region = config.region || process.env.AWS_REGION || DEFAULT_REGION;
const kmsRegion = config.kmsRegion || process.env.KMS_REGION || config.region || DEFAULT_KMS_REGION;
this.config = {
region,
kmsRegion,
table: config.table || process.env.CREDSTASH_TABLE || DEFAULT_TABLE,
kmsKeyId: config.kmsKeyId || process.env.CREDSTASH_KMS_KEY_ID || DEFAULT_KMS_KEY_ID,
profile: config.profile || process.env.AWS_PROFILE || DEFAULT_PROFILE,
dynamodbEndpoint: config.dynamodbEndpoint || process.env.DYNAMODB_ENDPOINT || "",
kmsEndpoint: config.kmsEndpoint || process.env.KMS_ENDPOINT || ""
};
this.dynamoClient = new DynamoDBClient({
region: this.config.region,
endpoint: this.config.dynamodbEndpoint || void 0
});
this.kmsClient = new KMSClient({
region: this.config.kmsRegion,
endpoint: this.config.kmsEndpoint || void 0
});
this.docClient = DynamoDBDocumentClient.from(this.dynamoClient);
}
async checkForTable() {
try {
await this.dynamoClient.send(new DescribeTableCommand({
TableName: this.config.table
}));
} catch (error) {
if (error instanceof ResourceNotFoundException) throw new Error(`Credstash table '${this.config.table}' not found. Please create it first. You can run 'credstasher setup' to create it.`);
throw error;
}
}
async ensureTableExists() {
try {
await this.dynamoClient.send(new DescribeTableCommand({
TableName: this.config.table
}));
} catch (error) {
if (error instanceof ResourceNotFoundException) await this.createTable();
else throw error;
}
}
async createTable() {
const createTableCommand = new CreateTableCommand({
TableName: this.config.table,
KeySchema: [
{
AttributeName: "name",
KeyType: "HASH"
},
{
AttributeName: "version",
KeyType: "RANGE"
}
],
AttributeDefinitions: [
{
AttributeName: "name",
AttributeType: "S"
},
{
AttributeName: "version",
AttributeType: "S"
}
],
BillingMode: "PAY_PER_REQUEST"
});
await this.dynamoClient.send(createTableCommand);
}
async putSecret(name, secret, options = {}) {
const version = options.version || await this.getNextVersion(name);
const kmsKeyId = options.kmsKeyId || this.config.kmsKeyId;
const encryptedItem = await encryptItem(secret, kmsKeyId, this.kmsClient, options);
const itemRow = {
name,
version,
...encryptedItem
};
await this.docClient.send(new PutCommand({
TableName: this.config.table,
Item: itemRow
}));
}
async getSecret(name, options = {}) {
const response = await this.docClient.send(new QueryCommand({
TableName: this.config.table,
ConsistentRead: true,
ScanIndexForward: false,
KeyConditionExpression: "#name = :name",
ExpressionAttributeNames: {
"#name": "name"
},
ExpressionAttributeValues: {
":name": name
}
}));
if (!response.Items || 0 === response.Items.length) throw new Error(`Secret '${name}' not found`);
let item;
if (void 0 !== options.version) {
item = response.Items.find((item)=>item.version === options.version);
if (!item) throw new Error(`Version '${options.version}' of secret '${name}' not found`);
} else item = response.Items[0];
return await decryptItem(item, this.kmsClient, options);
}
async deleteSecret(name, options = {}) {
if (options.all) {
const versions = await this.listVersions(name);
for (const version of versions)await this.docClient.send(new DeleteCommand({
TableName: this.config.table,
Key: {
name,
version: version.version
}
}));
} else {
const version = options.version || await this.getLatestVersion(name);
await this.docClient.send(new DeleteCommand({
TableName: this.config.table,
Key: {
name,
version
}
}));
}
}
async listSecrets() {
const items = [];
let lastEvaluatedKey;
do {
const response = await this.docClient.send(new ScanCommand({
TableName: this.config.table,
ProjectionExpression: "#name, version, #comment",
ExpressionAttributeNames: {
"#name": "name",
"#comment": "comment"
},
...lastEvaluatedKey && {
ExclusiveStartKey: lastEvaluatedKey
}
}));
if (response.Items) items.push(...response.Items);
lastEvaluatedKey = response.LastEvaluatedKey;
}while (lastEvaluatedKey);
return items.map((item)=>({
name: item.name,
version: item.version
}));
}
async listVersions(name) {
const response = await this.docClient.send(new QueryCommand({
TableName: this.config.table,
KeyConditionExpression: "#name = :name",
ExpressionAttributeNames: {
"#name": "name"
},
ExpressionAttributeValues: {
":name": name
},
ProjectionExpression: "#name, version"
}));
return (response.Items || []).map((item)=>({
name: item.name,
version: item.version
}));
}
async getLatestVersion(name) {
const versions = await this.listVersions(name);
if (0 === versions.length) throw new Error(`Secret '${name}' not found`);
const numericVersions = versions.map((v)=>Number.parseInt(v.version, 10)).filter((v)=>!Number.isNaN(v)).sort((a, b)=>b - a);
return numericVersions.length > 0 ? numericVersions[0]?.toString() ?? "1" : "1";
}
async getNextVersion(name) {
try {
const latestVersion = await this.getLatestVersion(name);
return (Number.parseInt(latestVersion, 10) + 1).toString();
} catch (_error) {
return "1";
}
}
}
export { CredstashClient, CredstashClient as default };