UNPKG

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
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 };