@giancarl021/cli-core-vault-extension
Version:
Plain and secure storage extension for the @giancarl021/cli-core npm package
150 lines (147 loc) • 5.93 kB
JavaScript
import { mkdir, writeFile, rename } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { createDecipheriv, randomBytes, createCipheriv, scryptSync } from 'node:crypto';
import { userInfo } from 'node:os';
import { dirname } from 'node:path';
import hash from '../util/hash.js';
import constants from '../util/constants.js';
/**
* Build a SecretEntryFactory that creates SecretEntry instances using filesystem storage.
* @param filePath The path to the file where secrets will be stored.
* @param encryptionKey The encryption key used to encrypt/decrypt the secrets.
* @returns A SecretEntryFactory function.
*/
function FSEntryFactoryBuilder(filePath, encryptionKey) {
const buffer = _init();
/**
* Derive encryption key and user ID from the provided salt.
* @param salt The salt used for key derivation.
* @returns An object containing the derived key and user ID.
*/
function _getCredentials(salt) {
const key = scryptSync(encryptionKey, salt, constants.filesystemSecretStorage.keyLength);
const userId = Buffer.from(userInfo().uid.toString(), 'utf-8');
return { key, userId };
}
/**
* Encrypt the given data using the encryption key and a random IV and salt.
* @param data The data to encrypt.
* @returns The encrypted data as a Buffer.
*/
function _encrypt(data) {
const bytes = Buffer.from(JSON.stringify(data), 'utf-8');
const iv = randomBytes(constants.filesystemSecretStorage.ivLength);
const salt = randomBytes(constants.filesystemSecretStorage.sizeLength);
const { key, userId } = _getCredentials(salt);
const cipher = createCipheriv(constants.filesystemSecretStorage.algorithm, key, iv, {
authTagLength: constants.filesystemSecretStorage.tagLength
});
cipher.setAAD(userId);
const encrypted = Buffer.concat([cipher.update(bytes), cipher.final()]);
const tag = cipher.getAuthTag();
// Combine salt, iv, tag, and encrypted data into a single Buffer
return Buffer.concat([salt, iv, tag, encrypted]);
}
/**
* Decrypt the given data using the encryption key.
* @param data The data to decrypt.
* @returns The decrypted data as an object.
*/
function _decrypt(data) {
const saltLength = constants.filesystemSecretStorage.sizeLength;
const ivLength = constants.filesystemSecretStorage.ivLength;
const tagLength = constants.filesystemSecretStorage.tagLength;
// Extract salt, iv, tag, and encrypted data from the Buffer
const salt = data.subarray(0, saltLength);
const iv = data.subarray(saltLength, saltLength + ivLength);
const tag = data.subarray(saltLength + ivLength, saltLength + ivLength + tagLength);
const encrypted = data.subarray(saltLength + ivLength + tagLength);
const { key, userId } = _getCredentials(salt);
const decipher = createDecipheriv(constants.filesystemSecretStorage.algorithm, key, iv, {
authTagLength: tagLength
});
// Verify and set the authentication tag and additional authenticated data
decipher.setAuthTag(tag);
decipher.setAAD(userId);
// Decrypt the data
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
// Parse and return the decrypted data as an object
return JSON.parse(decrypted.toString('utf-8'));
}
/**
* Read and decrypt the secrets file.
* @returns The decrypted secrets as an object.
*/
function _readFile() {
if (!existsSync(filePath))
return {};
const bytes = readFileSync(filePath);
try {
return _decrypt(bytes);
}
catch {
throw new Error('Failed to decrypt the secrets file. Ensure you are accessing the secrets from the same user and using the correct encryption key');
}
}
/**
* Write and encrypt the secrets file atomically.
* @param data The secrets data to write.
*/
async function _writeFile(data) {
const bytes = _encrypt(data);
if (!existsSync(dirname(filePath))) {
await mkdir(dirname(filePath), { recursive: true });
}
await writeFile(`${filePath}.tmp`, bytes, {
mode: 0o600
});
await rename(`${filePath}.tmp`, filePath);
}
/**
* Create a unique key for the given appName and key by hashing them.
* @param appName The name of the application.
* @param key The key of the secret.
* @returns The hashed key.
*/
function _getKey(appName, key) {
return hash(`${appName}.${key}`);
}
/**
* Initialize the storage by reading the existing file or creating a new one.
* @returns The initial secrets data.
*/
function _init() {
return _readFile();
}
/**
* Factory function to create a SecretEntry for the given appName and key.
* @param appName The name of the application.
* @param key The key of the secret.
* @returns An object with methods to get, set, and delete the secret.
*/
return (appName, key) => {
const _key = _getKey(appName, key);
return {
async getPassword() {
await Promise.resolve();
return buffer[_key] ?? null;
},
async setPassword(value) {
buffer[_key] = value;
await _writeFile(buffer);
},
async deletePassword() {
const had = _key in buffer;
if (!had)
return false;
delete buffer[_key];
await _writeFile(buffer);
return true;
}
};
};
}
export { FSEntryFactoryBuilder as default };