Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
# our minimum supported node version is 14 according to `npx ls-engines`, so we'd like to keep testing on it.
# If ci fails due to a needed new feature or we are forced to update the MSNV for any other reason, make sure
# to major version bump the library
version: [14, 16, 18, 20]
version: [20, 22]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
Expand Down Expand Up @@ -53,7 +50,7 @@ jobs:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 14
node-version: 22
- name: compilation check
run: |
yarn
Expand Down
12 changes: 6 additions & 6 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@

devShell = pkgs.mkShell {
buildInputs = with pkgs.nodePackages; [
pkgs.nodejs-18_x
pkgs.nodejs_22
pkgs.protobuf
(pkgs.yarn.override { nodejs = pkgs.nodejs-18_x; })
(pkgs.yarn.override { nodejs = pkgs.nodejs_22; })
(pkgs.google-cloud-sdk.withExtraComponents [ pkgs.google-cloud-sdk.components.gke-gcloud-auth-plugin ])
];
};
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
"description": "NodeJS client library for the IronCore Labs Tenant Security Proxy.",
"homepage": "https://ironcorelabs.com/docs",
"main": "src/index.js",
"repository": "git@github.com:IronCoreLabs/tenant-security-client-nodejs.git",
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/IronCoreLabs/tenant-security-client-nodejs.git"
},
"author": "IronCore Labs",
"license": "AGPL-3.0-only OR LicenseRef-ironcore-labs-commercial-license",
"types": "src/index.d.ts",
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"scripts": {
"build": "./build.js",
Expand All @@ -23,6 +26,7 @@
},
"dependencies": {
"futurejs": "2.2.0",
"joi": "^18.0.2",
"miscreant": "^0.3.2",
"node-fetch": "2.6.12",
"protobufjs": "^7.2.5"
Expand Down
32 changes: 28 additions & 4 deletions src/Util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Future from "futurejs";
import fetch, {Response} from "node-fetch";
import {ApiErrorResponse} from "./kms/KmsApi";
import {ApiErrorResponseSchema} from "./kms/KmsApi";
import {TenantSecurityErrorCode, TenantSecurityException} from "./TenantSecurityException";
import {TenantSecurityExceptionUtils} from "./TenantSecurityExceptionUtils";
import {TspServiceException} from "./TspServiceException";
Expand All @@ -9,14 +9,21 @@ import * as Crypto from "./kms/Crypto";
import * as DetCrypto from "./kms/DeterministicCrypto";
import * as http from "http";
import * as https from "https";
import * as Joi from "joi";

/**
* Try to JSON parse error responses from the TSP to extract error codes and messages.
*/
const parseErrorFromFailedResponse = (failureResponse: Response) =>
Future.tryP(() => failureResponse.json())
.errorMap(() => new TspServiceException(TenantSecurityErrorCode.UNKNOWN_ERROR, "Unknown response from Tenant Security Proxy", failureResponse.status))
.flatMap((errorResp: ApiErrorResponse) => Future.reject(TenantSecurityExceptionUtils.from(errorResp.code, errorResp.message, failureResponse.status)));
.flatMap((json: unknown) => {
const {value, error} = ApiErrorResponseSchema.validate(json, {convert: false});
if (error) {
return Future.reject(new TspServiceException(TenantSecurityErrorCode.UNKNOWN_ERROR, "Unknown response from Tenant Security Proxy", failureResponse.status));
}
return Future.reject(TenantSecurityExceptionUtils.from(value.code, value.message, failureResponse.status));
});

// The following is a workaround necessary for Node 19 and 20
// taken from https://github.com/node-fetch/node-fetch/issues/1735.
Expand All @@ -36,7 +43,13 @@ const agentSelector = function (_parsedURL: any) {
* Request the provided API endpoint with the provided POST data. All requests to the TSP today are in POST. On failure,
* attempt to parse the failed JSON to extract an error code and message.
*/
export const makeJsonRequest = <T,>(tspDomain: string, apiKey: string, route: string, postData: string): Future<TenantSecurityException, T> =>
export const makeJsonRequest = <T>(
tspDomain: string,
apiKey: string,
route: string,
postData: string,
schema: Joi.Schema<T>
): Future<TenantSecurityException, T> =>
Future.tryP(() =>
fetch(`${tspDomain}/api/1/${route}`, {
method: "POST",
Expand All @@ -51,7 +64,18 @@ export const makeJsonRequest = <T,>(tspDomain: string, apiKey: string, route: st
})
)
.errorMap((e) => new TspServiceException(TenantSecurityErrorCode.UNABLE_TO_MAKE_REQUEST, e.message))
.flatMap((response) => (response.ok ? Future.tryP(() => response.json()) : parseErrorFromFailedResponse(response)));
.flatMap((response) => validateJsonResponse(response, schema));

export const validateJsonResponse = <T>(response: Response, schema: Joi.Schema<T>): Future<TspServiceException, T> =>
response.ok
? Future.tryP<TspServiceException, T>(() => response.json()).flatMap((json) => {
const {value, error} = schema.validate(json, {convert: false});
if (error) {
return Future.reject(new TspServiceException(TenantSecurityErrorCode.UNKNOWN_ERROR, error.message));
}
return Future.of(value as T);
})
: parseErrorFromFailedResponse(response);

/**
* Helper to remove undefined. Any is used explicitly here because what we're doing is outputting any minus undefined.
Expand Down
88 changes: 64 additions & 24 deletions src/kms/KmsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,99 @@ import Future from "futurejs";
import {TenantSecurityException} from "../TenantSecurityException";
import {Base64String, makeJsonRequest} from "../Util";
import {DocumentMetadata} from "./Metadata";
import Joi = require("joi");

export interface ApiErrorResponse {
code: number;
message: string;
}
export const ApiErrorResponseSchema = Joi.object({
code: Joi.number().required(),
message: Joi.string().required(),
}).unknown(true);

interface BatchResponse<T> {
keys: Record<string, T>;
failures: Record<string, ApiErrorResponse>;
}

interface WrapKeyResponse {
export interface WrapKeyResponse {
dek: Base64String;
edek: Base64String;
}
export const WrapKeyResponseSchema = Joi.object({
dek: Joi.string().required(),
edek: Joi.string().required(),
}).unknown(true);

export type RekeyResponse = WrapKeyResponse;
export const RekeyResponseSchema = WrapKeyResponseSchema;

export type BatchWrapKeyResponse = BatchResponse<WrapKeyResponse>;
export const BatchWrapKeyResponseSchema = Joi.object({
keys: Joi.object().pattern(Joi.string(), WrapKeyResponseSchema).required(),
failures: Joi.object().pattern(Joi.string(), ApiErrorResponseSchema).required(),
}).unknown(true);

interface UnwrapKeyResponse {
export interface UnwrapKeyResponse {
dek: Base64String;
}
export const UnwrapKeyResponseSchema = Joi.object({
dek: Joi.string().required(),
}).unknown(true);

export type BatchUnwrapKeyResponse = BatchResponse<UnwrapKeyResponse>;
export const BatchUnwrapKeyResponseSchema = Joi.object({
keys: Joi.object().pattern(Joi.string(), UnwrapKeyResponseSchema).required(),
failures: Joi.object().pattern(Joi.string(), ApiErrorResponseSchema).required(),
}).unknown(true);

export type DerivationPath = string;
export type SecretPath = string;

export interface DerivedKey {
derivedKey: Base64String;
tenantSecretId: number;
current: boolean;
}
export const DerivedKeySchema = Joi.object({
derivedKey: Joi.string().required(),
tenantSecretId: Joi.number().required(),
current: Joi.boolean().required(),
}).unknown(true);

export type DerivedKeys = Record<DerivationPath, DerivedKey[]>;

export const DerivedKeysSchema = Joi.object().pattern(Joi.string(), Joi.array().items(DerivedKeySchema));

export interface DeriveKeyResponse {
hasPrimaryConfig: boolean;
derivedKeys: Record<SecretPath, DerivedKeys>;
}
export const DeriveKeyResponseSchema = Joi.object({
hasPrimaryConfig: Joi.boolean().required(),
derivedKeys: Joi.object().pattern(Joi.string(), DerivedKeysSchema).required(),
}).unknown(true);

export const getDerivedKeys = (deriveKeyResponse: DeriveKeyResponse, secretPath: SecretPath, derivationPath: DerivationPath): DerivedKey[] | undefined =>
deriveKeyResponse.derivedKeys[secretPath] === undefined ? undefined : deriveKeyResponse.derivedKeys[secretPath][derivationPath];

export const deterministicCollectionToPathMap = (
fields: Record<string, {secretPath: SecretPath; derivationPath: DerivationPath}>
): Record<SecretPath, DerivationPath[]> => {
return Object.values(fields).reduce((currentMap, {derivationPath, secretPath}) => {
if (currentMap[secretPath] === undefined) {
currentMap[secretPath] = [derivationPath];
} else {
currentMap[secretPath].push(derivationPath);
}
return currentMap;
}, {} as Record<SecretPath, DerivationPath[]>);
return Object.values(fields).reduce(
(currentMap, {derivationPath, secretPath}) => {
if (currentMap[secretPath] === undefined) {
currentMap[secretPath] = [derivationPath];
} else {
currentMap[secretPath].push(derivationPath);
}
return currentMap;
},
{} as Record<SecretPath, DerivationPath[]>
);
};

export type DerivedKeys = Record<DerivationPath, DerivedKey[]>;

export interface DerivedKey {
derivedKey: Base64String;
tenantSecretId: number;
current: boolean;
}

enum DerivationType {
Argon2 = "argon2",
Sha256 = "sha256",
Expand All @@ -82,7 +117,7 @@ const DERIVE_ENDPOINT = "key/derive-with-secret-path";
* Generate and wrap a new key via the tenant's KMS.
*/
export const wrapKey = (tspDomain: string, apiKey: string, metadata: DocumentMetadata): Future<TenantSecurityException, WrapKeyResponse> =>
makeJsonRequest(tspDomain, apiKey, WRAP_ENDPOINT, JSON.stringify(metadata.toJsonStructure()));
makeJsonRequest(tspDomain, apiKey, WRAP_ENDPOINT, JSON.stringify(metadata.toJsonStructure()), WrapKeyResponseSchema);

/**
* Generate and wrap a collection of new KMS keys.
Expand All @@ -100,7 +135,8 @@ export const batchWrapKeys = (
JSON.stringify({
...metadata.toJsonStructure(),
documentIds,
})
}),
BatchWrapKeyResponseSchema
);

/**
Expand All @@ -119,7 +155,8 @@ export const unwrapKey = (
JSON.stringify({
...metadata.toJsonStructure(),
encryptedDocumentKey: edek,
})
}),
UnwrapKeyResponseSchema
);

/**
Expand All @@ -138,7 +175,8 @@ export const batchUnwrapKey = (
JSON.stringify({
...metadata.toJsonStructure(),
edeks,
})
}),
BatchUnwrapKeyResponseSchema
);

/**
Expand All @@ -160,7 +198,8 @@ export const rekeyKey = (
...metadata.toJsonStructure(),
encryptedDocumentKey: edek,
newTenantId,
})
}),
RekeyResponseSchema
);

/**
Expand All @@ -182,6 +221,7 @@ export const deriveKey = (
paths,
derivationType: DerivationType.Sha512,
secretType: SecretType.Deterministic,
})
}),
DeriveKeyResponseSchema
);
};
Loading