Initial Commit
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 36s
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 36s
This commit is contained in:
commit
e0747f5405
14 changed files with 4785 additions and 0 deletions
109
.forgejo/workflows/publish.yml
Normal file
109
.forgejo/workflows/publish.yml
Normal 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
16
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
8
README.md
Normal 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
36
jest.config.cjs
Normal 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
41
package.json
Normal 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
36
src/VaultCredentials.ts
Normal 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
514
src/VaultKeys.ts
Normal 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
71
src/VaultKeystore.ts
Normal 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
5
src/index.ts
Normal 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
261
tests/VaultKeyst.test.ts
Normal 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
20
tsconfig.build.json
Normal 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
50
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue