Initial Commit
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 36s

This commit is contained in:
Alan Bridgeman 2026-02-18 08:55:30 -06:00
commit e0747f5405
14 changed files with 4785 additions and 0 deletions

View file

@ -0,0 +1,109 @@
name: Publish to Private NPM Registry
on:
push:
branches:
- main
workflow_dispatch:
jobs:
publish:
runs-on: default
steps:
# Checkout the repository
- name: Checkout code
uses: actions/checkout@v3
# Set up NPM Auth Token
- name: Set up NPM Auth Token
run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV
# Set up Node.js
- name: Set up Node.js version
uses: actions/setup-node@v3
with:
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Version Spec of the version to use in SemVer notation.
# > It also admits such aliases as lts/*, latest, nightly and canary builds
# > Examples: 12.x, 10.15.1, >=10.15.0, lts/Hydrogen, 16-nightly, latest, node
node-version: '20.x'
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Optional registry to set up for auth. Will set the registry in a project level .npmrc and .yarnrc file,
# > and set up auth to read in from env.NODE_AUTH_TOKEN.
# > Default: ''
registry-url: 'https://npm.pkg.bridgemanaccessible.ca'
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Optional scope for authenticating against scoped registries.
# > Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/).
scope: '@BridgemanAccessible'
# Transpile/Build the package (TypeScript -> JavaScript)
- name: Transpile/Build the package (TypeScript -> JavaScript)
run: |
# Because Yarn is used locally better to install and use it than have to debug weird inconsistencies
npm install --global yarn
# Install needed dependencies
yarn install
# Run tests to ensure the package is working as expected before publishing
yarn test
# Build the package
yarn build
- name: Determine Version and Increment (if needed)
id: version_check
run: |
VERSION=$(node -p "require('./package.json').version")
echo "Version: $VERSION"
NAME=$(node -p "require('./package.json').name")
LATEST_VERSION=$(npm show $NAME version --registry https://npm.pkg.bridgemanaccessible.ca 2>/dev/null || echo "0.0.0")
echo "Latest version: $LATEST_VERSION"
if [ "$LATEST_VERSION" != "$VERSION" ]; then
echo "Manually updated version detected: $VERSION"
else
NEW_VERSION=$(npm version patch --no-git-tag-version)
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
echo "version_changed=true" >> $GITHUB_OUTPUT
fi
- name: Commit Version Change (if needed)
if: steps.version_check.outputs.version_changed == 'true'
run: |
# Update remote URL to use the GITHUB_TOKEN for authentication
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.bridgemanaccessible.ca/${{ github.repository }}.git
# Setup git user details for committing the version change
git config user.name "Forgejo Actions"
git config user.email "actions@git.bridgemanaccessible.ca"
# Commit the version change to the `package.json` file
git add package.json
git commit -m "[Forgejo Actions] Update version to ${{ env.new_version }}"
# Push the changes to the repository
git push origin HEAD:main
# Publish to private NPM registry
- name: Publish the package
run: |
# Copy over the files to the build output (`dist`) folder
cp package.json dist/package.json
cp README.md dist/README.md
cp LICENSE dist/LICENSE
# Change directory to the build output (`dist`) folder
cd dist
# Publish the package to the private NPM registry
npm publish --registry http://npm.pkg.bridgemanaccessible.ca/

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# Node modules
node_modules
# Yarn stuff
.yarn/
.yarnrc.yml
# Credentials/Secrets
.npmrc
# Build output
dist
# Automation Scripts
unpublish-publish.ps1
unpublish-publish.sh

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bridgeman Accessible
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

8
README.md Normal file
View file

@ -0,0 +1,8 @@
# Hashicorp Vault based Implementation of bridgeman Accessible Auth Keystore
This is the commercial secrets management product ([Hashicorp Vault](https://www.hashicorp.com/en/products/vault)) implementation of the Bridgeman Accessible Auth Keystore.
That is, this module/package/library stores and retrieves JSON Web Keys ("keys") from a Hashiccorp Vault instance. Which means that the security of this option is significantly. And particularly because Hashicorp Vault can be self-hosted this makes this a good choice for anyone that doesn't want to use a cloud based offering but wants a certain level of security.
For more details on the Keystore concept itself or "upstream effects" see the [Keystore Library README](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore/src/branch/main/README.md).
This module/package/library includes a testing suite to verify functionality. It can be run using the `yarn test` command in any terminal/shell that supports Yarn. This test suite is also run as part of the public CI/CD automation on push.

36
jest.config.cjs Normal file
View file

@ -0,0 +1,36 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
// Tell Jest where to look for source code and tests
roots: ['<rootDir>/src', '<rootDir>/tests'],
// Explicitly match files in the tests folder
testMatch: [
'<rootDir>/tests/**/*.test.ts',
'<rootDir>/tests/**/*.spec.ts'
],
// Tell Jest to treat .ts files as ESM modules
extensionsToTreatAsEsm: ['.ts'],
// Fixes the "Cannot find module" error
moduleNameMapper: {
// If the import starts with . or .. and ends with .js, strip the .js
'^(\\.{1,2}/.*)\\.js$': '$1',
// Map the library to mock file
// This tells Jest to load simple JS file instead of parsing the complex node_module
//'^@BridgemanAccessible/ba-auth$': '<rootDir>/tests/__mocks__/ba-auth.js'
},
// Configure the transformer to enable ESM support
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
}
]
}
};

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "@BridgemanAccessible/ba-auth_keystore_vault",
"version": "1.0.0",
"description": "A Hashicorp Vault based Keystore implementation for use in combination with the Bridgeman Accessible Auth Package",
"main": "index.js",
"types": "index.d.ts",
"repository": {
"url": "https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore_vault.git"
},
"author": "Bridgeman Accessible<info@bridgemanaccessible.ca>",
"type": "module",
"exports": {
".": {
"default": "./index.js",
"types": "./index.d.ts"
}
},
"license": "MIT",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.23",
"@types/node": "^25.2.3",
"@types/node-forge": "^1.3.14",
"@types/node-jose": "^1.1.13",
"cross-env": "^10.1.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3"
},
"dependencies": {
"@BridgemanAccessible/ba-auth_keystore": "^1.0.1",
"@BridgemanAccessible/ba-logging": "^1.0.1",
"node-jose": "^2.2.0",
"node-vault": "^0.10.9"
},
"packageManager": "yarn@1.22.22"
}

36
src/VaultCredentials.ts Normal file
View file

@ -0,0 +1,36 @@
import vault from 'node-vault';
import { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging';
export class VaultCredentials {
private readonly vaultClient: vault.client;
private constructor(client: vault.client) {
this.vaultClient = client;
}
getVaultClient(): vault.client {
return this.vaultClient;
}
static async init(vaultName: string, appRoleId: string, appSecretId: string, vaultPort: number = 8200) {
const vaultClient = vault({
apiVersion: 'v1',
endpoint: `http://${vaultName}:${vaultPort.toString()}`,
debug: (...args: any[]) => logMessage(`[VAULT] ${args.map(arg => JSON.stringify(arg)).join(' ')}`, LogLevel.DEBUG)
});
const result = await vaultClient.approleLogin({
role_id: appRoleId,
secret_id: appSecretId
});
logMessage(`App Role Login Results: ${JSON.stringify(result)}`, LogLevel.DEBUG);
vaultClient.token = result.auth.client_token;
logMessage(`Vault Client token (now): ${vaultClient.token}`, LogLevel.DEBUG);
return new VaultCredentials(vaultClient);
}
}

514
src/VaultKeys.ts Normal file
View file

@ -0,0 +1,514 @@
// import { clone } from 'lodash';
import type { ApiResponseError } from 'node-vault';
import NodeJose from 'node-jose';
import type { JWK as JWKTypes } from 'node-jose';
// @ts-ignore
import { registry } from 'node-jose/lib/jwk/keystore.js';
// @ts-ignore
import merge from 'node-jose/lib/util/merge.js';
import type { Bytes } from 'node-forge';
import { pki } from 'node-forge';
import { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging';
import { VaultCredentials } from './VaultCredentials.js';
const { JWK } = NodeJose;
type JWKRsaFactory = { generate: (size: number) => Promise<pki.rsa.PrivateKey> };
type JWKOctetFactory = { generate: (size: number) => Promise<Bytes> };
type JWKEcFactory = { generate: (size: 'P-256' | 'P-384' | 'P-521') => Promise<{ crv: 'P-256' | 'P-384' | 'P-521', x: Buffer<ArrayBuffer>, y: Buffer<ArrayBuffer>, d: Buffer<ArrayBuffer>}> };
/** Hashicorp Vault implementation of the JWK.KeyStore interface */
export class VaultKeys implements JWKTypes.KeyStore {
/** Hashicorp Vault credentials object (including a client object) */
private readonly VAULT_CRED: VaultCredentials;
/**
* The list of keys in the KeyStore
*
* This is necessary because `node-jose`'s definition of the `JWK.KeyStore` interface (which we're implementing for practical reasons) forces methods like `all` and `get` to be synchronous.
* This is in direct conflict with the asynchronous nature of the `node-vault` library for interacting with the Hashicorp Vault.
* To get around this we initially asynchronously load the keys from the Hashicorp Vault and store them in this local class variable.
* And then if someone adds a key we BOTH save it to the Hashicorp Vault AND add it to the local class variable.
* This way when we call `all` or `get` which are synchronous, we can just return this variable (or subsets of it).
*
* While this does mean there could be some inconsistency between this variable and the Hashicorp Vault,
* particularly if the keys in the Hashicorp Vault are modified directly and the container/process running this library isn't restarted,
* this is a trade-off we almost need to make because the complexity of making the `node-vault` library work synchronously
* (or the `node-jose` library work asynchronously) is too high.
*/
private keys: JWKTypes.Key[];
/**
* Create a new VaultKeys (Hashicorp Vault specific implementation of the JWK.KeyStore) object
*
* @param vaultCred The Hashicorp Vault credentials to use for the key store
*/
private constructor(vaultCred: VaultCredentials) {
this.VAULT_CRED = vaultCred;
this.keys = [];
}
/** Type-guard to check if the error is an ApiResponseError */
private isApiResponseError(error: any): error is ApiResponseError {
return true;
}
/**
* Get a key from the KeyStore based on the key ID (kid) or options provided
*
* Note: This method uses the local class variable `keys` as the set of keys to find the desired key from.
* This is because the `node-jose` library's definition of the `JWK.KeyStore` interface requires this method (`get`) to be synchronous.
* This means there is virtually no way to get the key from the Hashicorp Vault from within the method.
*
* @param kid The key ID (kid) of the key to get
* @param filter The filter to apply to the key
* @return The key from the KeyStore that matches the key ID and filter provided
* @throws Error if the key is not found in the KeyStore
*/
get(kid: string, filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key;
get(options: JWKTypes.KeyStoreGetOptions): JWKTypes.Key;
get(kidOrOptions: string | JWKTypes.KeyStoreGetOptions, filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key {
let kid: string;
if(typeof kidOrOptions === 'string') {
kid = kidOrOptions;
}
else {
const options = kidOrOptions;
kid = options.kid;
}
/*logMessage(`Getting key ${kid} from the Hashicorp Vault...`, 'debug');
let syncLock = false;
let nodeJoseKey: JWK.Key;
this.VAULT_CRED.getVaultClient().read(`secret/data/keys/${kid}`)
.then(
async (keyData) => {
logMessage(`Key data from Hashicorp Vault: ${JSON.stringify(keyData)}`, 'debug');
const key = keyData.data.data;
nodeJoseKey = await JWK.asKey(key)
}
)
.catch(error => logMessage(`Error getting key ${kid} from the Hashicorp Vault: ${error}`, 'error'))
.finally(() => {
logMessage(`Completed getting key ${kid} from the Hashicorp Vault`, 'debug');
syncLock = true;
});
// TODO: Find an alternative non-blocking way to wait for the promise to resolve
//
// Unfortunately, because there is a fundamental mismatch between the async nature of the Azure Key Vault SDK and the synchronous nature of the node-jose library, we need to block the thread until the promise resolves.
// Despite ChatGPT gas-lighting me on if the `node-jose` library is synchronous or not, the fact remains that the `JWK.KeyStore.get` function has a synchronous (non-Promise) return type that this way I achieve.
while(!syncLock) {}*/
// Get the key from the local class variable
const nodeJoseKey = this.keys.find((key) => key.kid === kid);
logMessage(`Returned key: ${JSON.stringify(nodeJoseKey)}`, LogLevel.DEBUG);
if(typeof nodeJoseKey === 'undefined') {
logMessage(`Key ${kid} not found in the Hashicorp Vault`, LogLevel.ERROR);
throw new Error(`Key ${kid} not found`);
}
return nodeJoseKey;
}
/**
* Add a key to the KeyStore
*
* @param key The key to add to the KeyStore
*/
async add(key: JWKTypes.RawKey): Promise<JWKTypes.Key>;
async add(key: string | object | JWKTypes.Key | Buffer, form?: "json" | "private" | "pkcs8" | "public" | "spki" | "pkix" | "x509" | "pem", extras?: Record<string, any>): Promise<JWKTypes.Key>;
async add(key: string | object | JWKTypes.Key | Buffer | JWKTypes.RawKey, form?: "json" | "private" | "pkcs8" | "public" | "spki" | "pkix" | "x509" | "pem", extras?: Record<string, any>): Promise<JWKTypes.Key> {
// Convert the key to a JWK.Key object
const jwk = await JWK.asKey(key, form, extras);
logMessage(`Adding key ${JSON.stringify(jwk)} to the Hashicorp Vault...`, LogLevel.DEBUG);
try {
// Add the key to the Hashicorp Vault
//
// Note: we're saving the key as a secret
// This is because the [Key Management Secrets Engine](https://developer.hashicorp.com/vault/docs/secrets/key-management)
// requires Vault Enterprise and it isn't clear it would actually fit this use case.
await this.VAULT_CRED.getVaultClient().write(`secret/data/keys/${jwk.kid}`, { data: jwk.toJSON(true) });
// Add the key to the local class variable
this.keys.push(jwk);
}
catch(error) {
if(this.isApiResponseError(error)) {
logMessage(`Error Message: ${error.message}`, LogLevel.ERROR);
logMessage(`Error Cause: ${JSON.stringify(error.cause)}`, LogLevel.ERROR);
logMessage(`Response Body: ${JSON.stringify(error.response.body)}`, LogLevel.ERROR);
}
else {
logMessage(`Error: ${error}`, LogLevel.ERROR);
}
}
return jwk;
}
/**
* Remove a key from the KeyStore
*
* Note: Because of the natural conflict in how the libraries this class depends on work.
* This method **SHOULD NOT be used**.
* This is because there is no practical way to remove the key from the Hashicorp Vault within this synchronous method.
* And this method is required to be synchronous because of the `node-jose` library's definition of the `JWK.KeyStore` interface.
*
* @param key The key to remove from the KeyStore
* @throws Error if the method is called
*/
remove(key: JWKTypes.Key): void {
throw new Error('Method not implemented.');
}
/**
* Get all keys in the KeyStore or those that match the filter provided
*
* Note: This method uses the local class variable `keys` as the set of keys to return
* This is because the `node-jose` library's definition of the `JWK.KeyStore` interface requires this method (`all`) to be synchronous.
* This means there is virtually no way to get the keys from the Hashicorp Vault from within the method.
*
* @param filter Filter to apply to the list of keys
* @returns The list of keys available in the KeyStore that match the filter (if provided) or all keys if no filter is provided
*/
all(filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key[] {
logMessage('Getting all keys from the Hashicorp Vault...', LogLevel.DEBUG);
/*let syncLock = false;
let startTime = Date.now();
let keys: JWK.Key[] = [];
new Promise<void>(
async (resolve, reject) => {
try {
logMessage('Starting get all keys process...', 'debug');
// Get the keys from the Azure Key Vault
const keyList = await this.VAULT_CRED.getVaultClient().list('secret/data/keys')
logMessage(`Key list from Hashicorp Vault: ${JSON.stringify(keyList)}`, 'debug');
// Loop over the keys in the Hashicorp Vault
for (const keyListKey of keyList.data.keys) {
// For each key name we find in the Hashicorp Vault, get the key and add it to the list of keys
const key = this.get(keyListKey);
logMessage(`Adding key ${JSON.stringify(key)} to list`, 'debug');
keys.push(key);
}
resolve();
}
catch(err) {
logMessage(`Error getting all keys from the Hashicorp Vault: ${err}`, 'error');
reject(err);
}
finally {
logMessage('Completed get all keys process', 'debug');
// Regardless of the outcome, set the sync lock to true to indicate that the process has completed
syncLock = true;
}
}
);
logMessage(`Waiting for get all keys process to complete (started at ${new Date(startTime).toLocaleString()})...`, 'debug');
let minsWaited = 0;
let timedOut = false;
// We need to block the thread until the promise resolves
while(!syncLock) {
const timeWaited = Date.now() - startTime;
const newMinsWaited = Math.floor(timeWaited / 60000);
// If the process takes too long, log a warning
if(newMinsWaited > minsWaited) {
minsWaited = newMinsWaited;
logMessage(`Waiting for get all keys process to complete... (waited ${minsWaited} minute(s) so far)`, 'warn');
}
if(minsWaited > 10) {
timedOut = true;
syncLock = true;
}
}
if(timedOut) {
logMessage('The get all keys process has timed out', 'error');
return [];
}*/
const keys = this.keys;
logMessage(`Returned keys: ${JSON.stringify(keys)}`, LogLevel.DEBUG);
if(typeof filter === 'undefined') {
logMessage('No filter provided, returning all keys', LogLevel.DEBUG);
return keys;
}
logMessage(`Starting key filtering (${JSON.stringify(filter)})...`, LogLevel.DEBUG);
// Filter the keys based on the filter object provided
const filteredKeys = keys.filter((key) => {
logMessage(`[all - filtering keys] Key: ${JSON.stringify(key)}`, LogLevel.DEBUG);
// Check if the `alg` (algorithm) filtering is set and if it matches the current key
if(typeof filter.alg !== 'undefined' && key.alg !== filter.alg) {
logMessage(`Key ${key.kid} does not match the algorithm filter ${filter.alg}`, LogLevel.DEBUG);
return false;
}
// Check if the `kty` (key type) filtering is set and if it matches the current key
if(typeof filter.kty !== 'undefined' && key.kty !== filter.kty) {
logMessage(`Key ${key.kid} does not match the key type filter ${filter.kty}`, LogLevel.DEBUG);
return false;
}
// Check if the `use` filtering is set and if it matches the current key
if(typeof filter.use !== 'undefined' && key.use !== filter.use) {
logMessage(`Key ${key.kid} does not match the use filter ${filter.use}`, LogLevel.DEBUG);
return false;
}
return true;
});
logMessage(`Filtered Keys: ${JSON.stringify(filteredKeys)}`, LogLevel.DEBUG);
return filteredKeys;
}
/**
* sReturn the KeyStore as a JSON object
*
* @param exportPrivate Whether to export the private key information
* @returns The KeyStore as a JSON object
*/
toJSON(exportPrivate?: boolean): object {
var keys: object[] = [];
this.all().forEach((key) => {
keys.push(key.toJSON(exportPrivate));
});
logMessage(`KeyStore JSON: ${JSON.stringify({ keys })}`, LogLevel.DEBUG);
return { keys: keys };
}
generate(kty: 'RSA' | 'oct' | 'EC', size?: 'P-256' | 'P-384' |'P-521' | number, props?: { kty?: string, [key: string]: any }): Promise<JWKTypes.Key> {
// Set the default key size if not specified
if(typeof size === 'undefined' && kty !== 'EC') {
size = 2048;
}
else if(typeof size === 'undefined' && kty === 'EC') {
size = 'P-256';
}
// Throw an error if the key type (kty) is not supported with the specified size
if(kty !== 'EC' && typeof size === 'string') {
logMessage(`Key size specified is invalid: ${size}`, LogLevel.ERROR);
throw new Error('size must be an integer for RSA and oct key types');
}
logMessage(`Generating a new ${kty} key with size ${size}...`, LogLevel.DEBUG);
logMessage(`Key properties: ${JSON.stringify(props)}`, LogLevel.DEBUG);
// Get the key "factory" from the registry based on the key type (kty)
//
// This essentially gets the proper instance of:
// - JWKRsaFactory (node-jose/lib/jwk/rsakey),
// - JWKOctetFactory (node-jose/lib/jwk/octkey)
// - or JWKEcFactory (node-jose/lib/jwk/eckey)
//
// Use the key "factory" to generate a new key of the specified size
let promise: Promise<pki.rsa.PrivateKey | Bytes | { crv: 'P-256' | 'P-384' | 'P-521', x: Buffer<ArrayBuffer>, y: Buffer<ArrayBuffer>, d: Buffer<ArrayBuffer> }>;
switch(kty) {
case 'RSA':
try {
const rsaFactory = registry.get(kty) as JWKRsaFactory;
promise = rsaFactory.generate(size as number);
}
catch(error) {
logMessage(`Error generating RSA key: ${error}`, LogLevel.ERROR);
promise = Promise.reject(error);
}
break;
case 'oct':
try {
const octFactory = registry.get(kty) as JWKOctetFactory;
promise = octFactory.generate(size as number);
}
catch(error) {
logMessage(`Error generating oct key: ${error}`, LogLevel.ERROR);
promise = Promise.reject(error);
}
break;
case 'EC':
try {
const ecFactory = registry.get(kty) as JWKEcFactory;
promise = ecFactory.generate(size as 'P-256' | 'P-384' | 'P-521');
}
catch(error) {
logMessage(`Error generating EC key: ${error}`, LogLevel.ERROR);
promise = Promise.reject(error);
}
break;
default:
return Promise.reject(new Error("unsupported key type"));
}
var self = this
return promise.then((generatedKey: pki.rsa.PrivateKey | Bytes | { crv: 'P-256' | 'P-384' | 'P-521', x: Buffer<ArrayBuffer>, y: Buffer<ArrayBuffer>, d: Buffer<ArrayBuffer> }) => {
logMessage(`Generated key: ${JSON.stringify(generatedKey)}`, LogLevel.DEBUG);
// merge props and the key type (kty) into the JWK object
const jwk = merge(props, generatedKey, { kty: kty }) as string | object | JWKTypes.Key | Buffer | JWKTypes.RawKey;
logMessage(`Generated (raw) JWK: ${JSON.stringify(jwk)}`, LogLevel.DEBUG);
// Add the key to the KeyStore
return self.add(jwk);
});
}
/**
* Attempt to load any keys from the Hashicorp Vault into the keystore (local class variable)
*
* This is necessary because `node-jose`'s definition of the `JWK.KeyStore` interface (which we're implementing for practical reasons) forces methods like `all` and `get` to be synchronous.
* This is in direct conflict with the asynchronous nature of the `node-vault` library for interacting with the Hashicorp Vault.
* To get around this we initially asynchronously load the keys (this method) and store them in a local class variable.
* And then if someone adds a key we BOTH save it to the Hashicorp Vault AND add it to the local class variable.
* This way when we call `all` or `get` which are synchronous, we can just return the local class variable (or subset of it).
*
* While this does mean there could be some inconsistency between the local class variable and the Hashicorp Vault,
* particularly if the keys in the Hashicorp Vault are modified directly and the container running this library isn't restarted,
* this is a trade-off we almost need to make because the complexity of making the `node-vault` library work synchronously
* (or the `node-jose` library work asynchronously) is too high.
*
* @returns The list of keys loaded from the Hashicorp Vault
*/
async loadKeys() {
// List the keys in the Hashicorp Vault
let keyList;
try {
keyList = await this.VAULT_CRED.getVaultClient().list('secret/data/keys');
}
catch(error) {
if(this.isApiResponseError(error)) {
if(error.response.statusCode === 404) {
logMessage('No keys found in the Hashicorp Vault', LogLevel.DEBUG);
return [];
}
}
logMessage(`Error listing keys in the Hashicorp Vault: ${error}`, LogLevel.ERROR);
return [];
}
logMessage(`Key list from Hashicorp Vault: ${JSON.stringify(keyList)}`, LogLevel.DEBUG);
// Loop over the keys in the Hashicorp Vault
for (const keyId of keyList.data.keys) {
// For each key name we find in the Hashicorp Vault, get the key and add it to the list of keys
const keyData = await this.VAULT_CRED.getVaultClient().read(`secret/data/keys/${keyId}`);
const key = keyData.data.data;
logMessage(`Adding key ${JSON.stringify(key)} to list`, LogLevel.DEBUG);
this.keys.push(await JWK.asKey(key));
}
return this.keys;
}
/**
* Setup some initial keys for the keystore
*
* This sets up two new RSA key pairs. One for signing and the other for encryption.
*/
async setupInitialKeys() {
// Generate a new RSA key pair for signing
const signingKey = await this.generate('RSA', 2048, { alg: 'RS256', use: 'sig' });
// Generate a new RSA key pair for encryption
const encryptionKey = await this.generate('RSA', 2048, { /*alg: 'RS-OAEP', enc: 'A128CBC-HS256',*/ use: 'enc' });
}
/**
* Static factory method to create a new VaultKeys (Hashicorp Vault specific implementation of the `JWK.KeyStore` interface) object
*
* Note, either the values need to be explicitly provided via the parameter OR the following environment variables must be set:
*
* | Variable Name | Description |
* | --------------- | -------------------------------------------------------------------- |
* | VAULT_NAME | The Vault name of the Hashicorp Vault to use |
* | VAULT_ROLE_ID | The Role ID of the application that can access the Hashicorp Vault |
* | VAULT_SECRET_ID | The Secret ID of the application that can access the Hashicorp Vault |
* | VAULT_PORT | The port of the Hashicorp Vault to use |
*/
static async init(
envVars: {
VAULT_NAME: string,
VAULT_ROLE_ID: string,
VAULT_SECRET_ID: string,
VAULT_PORT: number
} = {
VAULT_NAME: process.env.VAULT_NAME as string,
VAULT_ROLE_ID: process.env.VAULT_ROLE_ID as string,
VAULT_SECRET_ID: process.env.VAULT_SECRET_ID as string,
VAULT_PORT: typeof process.env.VAULT_PORT !== 'undefined' ? parseInt(process.env.VAULT_PORT) : Number.NaN
}
) {
// Verify that the VAULT_NAME environment variable is set
if(typeof envVars.VAULT_NAME === 'undefined' || envVars.VAULT_NAME.trim().length === 0) {
logMessage('The VAULT_NAME variable must be set to use a Hashicorp Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The VAULT_NAME variable must be set to use a Hashicorp Vault as the JWK.KeyStore');
}
const vaultName = envVars.VAULT_NAME;
// Verify that the VAULT_ROLE_ID environment variable is set
if(typeof envVars.VAULT_ROLE_ID === 'undefined' || envVars.VAULT_ROLE_ID.trim().length === 0) {
logMessage('The VAULT_ROLE_ID variable must be set to use a Hashicorp Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The VAULT_ROLE_ID variable must be set to use a Hashicorp Vault as the JWK.KeyStore');
}
const appRoleId = envVars.VAULT_ROLE_ID;
// Verify that the VAULT_SECRET_ID environment variable is set
if(typeof envVars.VAULT_SECRET_ID === 'undefined' || envVars.VAULT_SECRET_ID.trim().length === 0) {
logMessage('The VAULT_SECRET_ID variable must be set to use a Hashicorp Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The VAULT_SECRET_ID variable must be set to use a Hashicorp Vault as the JWK.KeyStore');
}
const appSecretId = envVars.VAULT_SECRET_ID;
// Verify that the VAULT_PORT environment variable is set
if(typeof envVars.VAULT_PORT === 'undefined' || isNaN(envVars.VAULT_PORT)) {
logMessage('The VAULT_PORT variable must be set to use a Hashicorp Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The VAULT_PORT variable must be set to use a Hashicorp Vault as the JWK.KeyStore');
}
const port = envVars.VAULT_PORT;
// Initialize the Hashicorp Vault credentials object
const vaultCred = await VaultCredentials.init(vaultName, appRoleId, appSecretId, port);
// Return a new VaultKeys object with the credentials specified
return new VaultKeys(vaultCred);
}
}

71
src/VaultKeystore.ts Normal file
View file

@ -0,0 +1,71 @@
import { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging';
import { BaseKeystore, Keystore } from '@BridgemanAccessible/ba-auth_keystore';
import { VaultKeys } from './VaultKeys.js';
/**
* Hashicorp Vault implementation of the BaseKeystore class
* That is, it uses the VaultKeys class as the underlying KeyStore implementation
*/
export class VaultKeystore extends BaseKeystore {
/** The type name of the keystore (used as the key for registration) */
public static readonly TYPE_NAME = 'hashicorp';
private constructor(vaultKeys: VaultKeys) {
super();
// We want to use our custom KeyStore implementation
this.keystore = vaultKeys;
}
/**
* Static factory method to create a new VaultKeystore (Hashicorp Vault specific implementation of the `BaseKeystore` class) object
*
* Note, either the values need to be explicitly provided via the parameter
* OR the following environment variables must be set:
*
* | Variable Name | Description |
* | --------------- | -------------------------------------------------------------------- |
* | VAULT_NAME | The Vault name of the Hashicorp Vault to use |
* | VAULT_ROLE_ID | The Role ID of the application that can access the Hashicorp Vault |
* | VAULT_SECRET_ID | The Secret ID of the application that can access the Hashicorp Vault |
* | VAULT_PORT | The port of the Hashicorp Vault to use |
*
* @param envVars The environment variables to use for the Hashicorp Vault credentials (optional, can also be set via actual environment variables)
*/
static async init(envVars: {
VAULT_NAME: string,
VAULT_ROLE_ID: string,
VAULT_SECRET_ID: string,
VAULT_PORT: number
} = {
VAULT_NAME: process.env.VAULT_NAME as string,
VAULT_ROLE_ID: process.env.VAULT_ROLE_ID as string,
VAULT_SECRET_ID: process.env.VAULT_SECRET_ID as string,
VAULT_PORT: typeof process.env.VAULT_PORT !== 'undefined' ? parseInt(process.env.VAULT_PORT) : Number.NaN
}
) {
// Create a new VaultKeys object (Hashicorp Vault specific implementation of the `JWK.KeyStore` interface)
// This is what actually does the work of managing the keys
const vaultKeystore = await VaultKeys.init(envVars);
// Load any keys from the Hashicorp Vault
// See `loadKeys` method documentation for more information of why this is necessary
const loadedKeys = await vaultKeystore.loadKeys();
// If no keys were loaded, setup some initial keys
if(loadedKeys.length === 0) {
logMessage('No keys found in the Hashicorp Vault, setting up initial keys...', LogLevel.DEBUG);
// Setup the initial keys
await vaultKeystore.setupInitialKeys();
}
// Setup the Keystore (uses Hashicorp Vault backed storage)
return new VaultKeystore(vaultKeystore);
}
}
// Add the VaultKeystore as a valid keystore type in the Keystore registry
Keystore.addKeystoreType(VaultKeystore.TYPE_NAME, VaultKeystore.init);

5
src/index.ts Normal file
View file

@ -0,0 +1,5 @@
import { VaultKeystore } from './VaultKeystore.js';
const TYPE_NAME = VaultKeystore.TYPE_NAME;
export { VaultKeystore, TYPE_NAME };

261
tests/VaultKeyst.test.ts Normal file
View file

@ -0,0 +1,261 @@
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import type * as NodeJose from 'node-jose';
import type vault from 'node-vault';
import type { VaultCredentials as VaultCredentialsType } from '../src/VaultCredentials.js';
import type { VaultKeys as VaultKeysType } from '../src/VaultKeys.js';
import type { VaultKeystore as VaultKeystoreType } from '../src/VaultKeystore.js';
type JestVaultClient = Partial<vault.client> & {
approleLogin: jest.Mock<(options?: vault.Option | undefined) => Promise<any>>,
write: jest.Mock<(path: string, data: any, requestOptions?: vault.Option | undefined) => Promise<any>>,
read: jest.Mock<(path: string, requestOptions?: vault.Option | undefined) => Promise<any>>,
list: jest.Mock<(path: string, requestOptions?: vault.Option | undefined) => Promise<any>>,
token: string
};
// Mocks
jest.unstable_mockModule('node-vault', () => ({
'default': jest.fn<(options?: vault.VaultOptions | undefined) => JestVaultClient>().mockReturnValue({
approleLogin: jest.fn<(options?: vault.Option | undefined) => Promise<any>>().mockResolvedValue({ auth: { client_token: 'fake-token' } }),
write: jest.fn<(path: string, data: any, requestOptions?: vault.Option | undefined) => Promise<any>>().mockResolvedValue({}),
read: jest.fn<(path: string, requestOptions?: vault.Option | undefined) => Promise<any>>(),
list: jest.fn<(path: string, requestOptions?: vault.Option | undefined) => Promise<any>>(),
token: ''
})
}));
jest.unstable_mockModule('@BridgemanAccessible/ba-logging', () => ({
logMessage: jest.fn<(message: string, level: 'debug' | 'error' | 'warn') => void>(),
LogLevel: { DEBUG: 'debug', ERROR: 'error', WARN: 'warn' },
}));
// Mock the BaseKeystore dependency to avoid importing logic we aren't testing
jest.unstable_mockModule('@BridgemanAccessible/ba-auth_keystore', () => ({
BaseKeystore: class BaseKeystore { keystore: any; },
Keystore: { addKeystoreType: jest.fn() }
}));
jest.unstable_mockModule('node-forge', () => ({
pki: {
rsa: {
generateKeyPair: jest.fn<(bits: number, callback: (err: any, keypair: { publicKey: any, privateKey: any }) => void) => void>().mockImplementation((bits, callback) => {
// Simulate async key generation
setTimeout(() => {
callback(null, {
publicKey: { n: 'modulus', e: 'exponent' },
privateKey: { d: 'privateExponent' }
});
}, 10);
})
}
}
}))
describe('Vault Integration Tests', () => {
// Mock Vault Client Instance
let nodeVault: jest.Mock<(options?: vault.VaultOptions | undefined) => JestVaultClient>;
let mockVaultClient: JestVaultClient;
let JWK: typeof NodeJose.JWK;
let VaultKeys: typeof VaultKeysType;
let VaultCredentials: typeof VaultCredentialsType;
let VaultKeystore: typeof VaultKeystoreType;
// Standard Test Env Vars
const envVars = {
VAULT_NAME: 'test-vault',
VAULT_ROLE_ID: 'role-id',
VAULT_SECRET_ID: 'secret-id',
VAULT_PORT: 8200,
};
beforeEach(async () => {
// Reset mocks
jest.clearAllMocks();
// Setup the Node-Vault mock return values
const nodeVaultModule = await import('node-vault');
nodeVault = nodeVaultModule.default as jest.Mock<(options?: vault.VaultOptions | undefined) => JestVaultClient>;
mockVaultClient = nodeVault();
const nodeJoseModule = await import('node-jose');
JWK = nodeJoseModule.default.JWK;
// Setup the VaultCredentials and VaultKeys imports (after mocks)
const vaultCredentialsModule = await import('../src/VaultCredentials.js');
VaultCredentials = vaultCredentialsModule.VaultCredentials;
const vaultKeysModule = await import('../src/VaultKeys.js');
VaultKeys = vaultKeysModule.VaultKeys;
const vaultKeystoreModule = await import('../src/VaultKeystore.js');
VaultKeystore = vaultKeystoreModule.VaultKeystore;
});
// --- 1. Testing VaultCredentials ---
describe('VaultCredentials', () => {
it('should initialize and perform appRole login', async () => {
// Call the init method
const creds = await VaultCredentials.init('localhost', 'role', 'secret', 8200);
// Check the vault client was created correctly
expect(nodeVault).toHaveBeenCalledWith(expect.objectContaining({ endpoint: 'http://localhost:8200' }));
expect(mockVaultClient.approleLogin).toHaveBeenCalledWith({ role_id: 'role', secret_id: 'secret' });
expect(creds.getVaultClient().token).toBe('fake-token');
});
});
// --- 2. Testing VaultKeys ---
describe('VaultKeys', () => {
it('should throw error if env vars are missing', async () => {
await expect(VaultKeys.init({ ...envVars, VAULT_NAME: '' })).rejects.toThrow('The VAULT_NAME variable must be set');
});
it('should initialize with empty local keys', async () => {
const vaultKeys = await VaultKeys.init(envVars);
// "all" is synchronous and reads from the local private array
expect(vaultKeys.all()).toEqual([]);
});
describe('loadKeys (Hydration)', () => {
it('should load keys from Vault and populate local state', async () => {
// 1. Setup a real JWK to return from the mock
const realKey = await JWK.createKey('oct', 256, { kid: 'test-key-1' });
const keyJson = realKey.toJSON(true);
// 2. Mock list() to return one key ID
mockVaultClient.list.mockResolvedValue({ data: { keys: ['test-key-1'] } });
// 3. Mock read() to return the key data
mockVaultClient.read.mockResolvedValue({ data: { data: keyJson } });
const vaultKeys = await VaultKeys.init(envVars);
await vaultKeys.loadKeys();
// 4. Verify list was called
expect(mockVaultClient.list).toHaveBeenCalledWith('secret/data/keys');
// 5. Verify read was called
expect(mockVaultClient.read).toHaveBeenCalledWith('secret/data/keys/test-key-1');
// 6. Verify local state is updated (The synchronous get/all should work now)
const keys = vaultKeys.all();
expect(keys.length).toBe(1);
expect(keys[0]?.kid).toBe('test-key-1');
});
it('should handle empty vault gracefully (404 on list)', async () => {
// Simulate a 404 from Vault (standard behavior when path doesn't exist yet)
const err: any = new Error('Not Found');
err.response = { statusCode: 404 };
mockVaultClient.list.mockRejectedValue(err);
const vaultKeys = await VaultKeys.init(envVars);
const keys = await vaultKeys.loadKeys();
expect(keys).toEqual([]);
expect(vaultKeys.all()).toEqual([]);
});
});
describe('add', () => {
it('should write to Vault AND update local state', async () => {
const vaultKeys = await VaultKeys.init(envVars);
// Input: Raw Key Data
const rawKey = { kty: 'oct', k: '1234', kid: 'new-key' };
// Call add
await vaultKeys.add(rawKey as any);
// 1. Check Vault Write
expect(mockVaultClient.write).toHaveBeenCalledWith(
'secret/data/keys/new-key',
expect.objectContaining({
data: expect.objectContaining({
kid: 'new-key'
})
})
);
// 2. Check Local State
const storedKey = vaultKeys.get('new-key');
expect(storedKey).toBeDefined();
expect(storedKey.kid).toBe('new-key');
});
});
describe('generate', () => {
it('should generate a key, write it to vault, and store it locally', async () => {
const vaultKeys = await VaultKeys.init(envVars);
// This uses the real node-jose generator logic
const generatedKey = await vaultKeys.generate('RSA', 2048, { use: 'sig' });
// Verify Vault interaction
expect(mockVaultClient.write).toHaveBeenCalled();
const writeCall = mockVaultClient.write.mock.calls[0];
// Ensure the path contains the generated KID
expect((writeCall as [path: string, data: any, requestOptions?: vault.Option | undefined])[0]).toContain(`secret/data/keys/${generatedKey.kid}`);
// Verify Local interaction
expect(vaultKeys.get(generatedKey.kid)).toBeDefined();
expect(generatedKey.kty).toBe('RSA');
});
});
describe('get (Synchronous)', () => {
it('should retrieve a key by kid from local state', async () => {
const vaultKeys = await VaultKeys.init(envVars);
// Manually inject a key via add() first to populate state
const addedKey = await vaultKeys.add(await JWK.createKey('oct', 256, {}));
// Retrieve
const retrieved = vaultKeys.get(addedKey.kid);
expect(retrieved).toBeDefined();
expect(retrieved.kid).toBe(addedKey.kid);
});
it('should throw if key is missing locally', async () => {
const vaultKeys = await VaultKeys.init(envVars);
expect(() => vaultKeys.get('non-existent')).toThrow('Key non-existent not found');
});
});
});
// --- 3. Testing VaultKeystore (Wrapper) ---
describe('VaultKeystore', () => {
it('should initialize keys and setup initial keys if vault is empty', async () => {
// Mock empty vault
mockVaultClient.list.mockRejectedValue({ response: { statusCode: 404 } });
const keystore = await VaultKeystore.init(envVars);
// It should have tried to list keys
expect(mockVaultClient.list).toHaveBeenCalled();
// Since list failed (empty), it calls setupInitialKeys -> generate -> write
// We expect at least 2 writes (signing key and encryption key)
expect(mockVaultClient.write).toHaveBeenCalledTimes(2);
});
it('should load existing keys and NOT generate new ones', async () => {
// Mock existing keys
mockVaultClient.list.mockResolvedValue({ data: { keys: ['existing-key'] } });
const realKey = await JWK.createKey('RSA', 2048, { kid: 'existing-key' });
mockVaultClient.read.mockResolvedValue({ data: { data: realKey.toJSON(true) } });
await VaultKeystore.init(envVars);
// Should read the key
expect(mockVaultClient.read).toHaveBeenCalled();
// Should NOT write new keys (generate)
expect(mockVaultClient.write).not.toHaveBeenCalled();
});
});
});

20
tsconfig.build.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "./src",
"outDir": "./dist",
"types": []
},
"exclude": [
"node_modules",
"dist",
"tests"
],
"include": [
"./src/**/*.ts",
"./src/**/*.tsx"
]
}

50
tsconfig.json Normal file
View file

@ -0,0 +1,50 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": ["node", "jest"],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
},
"exclude": ["node_modules", "dist"],
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./tests/**/*.ts"
]
}

3597
yarn.lock Normal file

File diff suppressed because it is too large Load diff