From 0569c51742240c9c32636883d1dada07d89a53f5 Mon Sep 17 00:00:00 2001 From: Craig Colegrove Date: Fri, 13 Feb 2026 09:44:48 -0800 Subject: [PATCH 1/3] Add validation to TSP responses --- flake.lock | 12 +- flake.nix | 4 +- package.json | 1 + src/Util.ts | 32 +- src/kms/KmsApi.ts | 88 ++++-- src/kms/tests/KmsApiValidation.test.ts | 402 +++++++++++++++++++++++++ src/logdriver/SecurityEventApi.ts | 3 +- yarn.lock | 52 ++++ 8 files changed, 557 insertions(+), 37 deletions(-) create mode 100644 src/kms/tests/KmsApiValidation.test.ts diff --git a/flake.lock b/flake.lock index 97af5eb..067b846 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1685518550, - "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1686582075, - "narHash": "sha256-vtflsfKkHtF8IduxDNtbme4cojiqvlvjp5QNYhvoHXc=", + "lastModified": 1769527094, + "narHash": "sha256-xV20Alb7ZGN7qujnsi5lG1NckSUmpIb05H2Xar73TDc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7e63eed145566cca98158613f3700515b4009ce3", + "rev": "afce96367b2e37fc29afb5543573cd49db3357b7", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 6fa8246..92cfead 100644 --- a/flake.nix +++ b/flake.nix @@ -27,9 +27,9 @@ devShell = pkgs.mkShell { buildInputs = with pkgs.nodePackages; [ - pkgs.nodejs-18_x + pkgs.nodejs_24 pkgs.protobuf - (pkgs.yarn.override { nodejs = pkgs.nodejs-18_x; }) + (pkgs.yarn.override { nodejs = pkgs.nodejs_24; }) (pkgs.google-cloud-sdk.withExtraComponents [ pkgs.google-cloud-sdk.components.gke-gcloud-auth-plugin ]) ]; }; diff --git a/package.json b/package.json index 210d7c9..ffe25f4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "futurejs": "2.2.0", + "joi": "^18.0.2", "miscreant": "^0.3.2", "node-fetch": "2.6.12", "protobufjs": "^7.2.5" diff --git a/src/Util.ts b/src/Util.ts index 49fc247..3358f14 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -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"; @@ -9,6 +9,7 @@ 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. @@ -16,7 +17,13 @@ import * as https from "https"; 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. @@ -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 = (tspDomain: string, apiKey: string, route: string, postData: string): Future => +export const makeJsonRequest = ( + tspDomain: string, + apiKey: string, + route: string, + postData: string, + schema: Joi.Schema +): Future => Future.tryP(() => fetch(`${tspDomain}/api/1/${route}`, { method: "POST", @@ -51,7 +64,18 @@ export const makeJsonRequest = (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 = (response: Response, schema: Joi.Schema): Future => + response.ok + ? Future.tryP(() => 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. diff --git a/src/kms/KmsApi.ts b/src/kms/KmsApi.ts index 0d6051c..19e3e62 100644 --- a/src/kms/KmsApi.ts +++ b/src/kms/KmsApi.ts @@ -2,39 +2,79 @@ 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 { keys: Record; failures: Record; } -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; +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; +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; + +export const DerivedKeysSchema = Joi.object().pattern(Joi.string(), Joi.array().items(DerivedKeySchema)); + export interface DeriveKeyResponse { hasPrimaryConfig: boolean; derivedKeys: Record; } +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]; @@ -42,24 +82,19 @@ export const getDerivedKeys = (deriveKeyResponse: DeriveKeyResponse, secretPath: export const deterministicCollectionToPathMap = ( fields: Record ): Record => { - return Object.values(fields).reduce((currentMap, {derivationPath, secretPath}) => { - if (currentMap[secretPath] === undefined) { - currentMap[secretPath] = [derivationPath]; - } else { - currentMap[secretPath].push(derivationPath); - } - return currentMap; - }, {} as Record); + return Object.values(fields).reduce( + (currentMap, {derivationPath, secretPath}) => { + if (currentMap[secretPath] === undefined) { + currentMap[secretPath] = [derivationPath]; + } else { + currentMap[secretPath].push(derivationPath); + } + return currentMap; + }, + {} as Record + ); }; -export type DerivedKeys = Record; - -export interface DerivedKey { - derivedKey: Base64String; - tenantSecretId: number; - current: boolean; -} - enum DerivationType { Argon2 = "argon2", Sha256 = "sha256", @@ -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 => - 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. @@ -100,7 +135,8 @@ export const batchWrapKeys = ( JSON.stringify({ ...metadata.toJsonStructure(), documentIds, - }) + }), + BatchWrapKeyResponseSchema ); /** @@ -119,7 +155,8 @@ export const unwrapKey = ( JSON.stringify({ ...metadata.toJsonStructure(), encryptedDocumentKey: edek, - }) + }), + UnwrapKeyResponseSchema ); /** @@ -138,7 +175,8 @@ export const batchUnwrapKey = ( JSON.stringify({ ...metadata.toJsonStructure(), edeks, - }) + }), + BatchUnwrapKeyResponseSchema ); /** @@ -160,7 +198,8 @@ export const rekeyKey = ( ...metadata.toJsonStructure(), encryptedDocumentKey: edek, newTenantId, - }) + }), + RekeyResponseSchema ); /** @@ -182,6 +221,7 @@ export const deriveKey = ( paths, derivationType: DerivationType.Sha512, secretType: SecretType.Deterministic, - }) + }), + DeriveKeyResponseSchema ); }; diff --git a/src/kms/tests/KmsApiValidation.test.ts b/src/kms/tests/KmsApiValidation.test.ts new file mode 100644 index 0000000..214cf71 --- /dev/null +++ b/src/kms/tests/KmsApiValidation.test.ts @@ -0,0 +1,402 @@ +import {Response} from "node-fetch"; +import {validateJsonResponse} from "../../Util"; +import {TspServiceException} from "../../TspServiceException"; +import { + ApiErrorResponseSchema, + WrapKeyResponseSchema, + UnwrapKeyResponseSchema, + RekeyResponseSchema, + BatchWrapKeyResponseSchema, + BatchUnwrapKeyResponseSchema, + DeriveKeyResponseSchema, + DerivedKeySchema, + DerivedKeysSchema, +} from "../KmsApi"; + +// Helper to create a mock Response with JSON body +const mockResponse = (body: unknown, ok = true): Response => { + return { + ok, + json: () => Promise.resolve(body), + } as Response; +}; + +// Helper to extract result from Future +const toPromise = (future: {toPromise: () => Promise}): Promise => future.toPromise(); + +// Helper to extract error from rejected Future +const toError = async (future: {toPromise: () => Promise}): Promise => { + try { + await future.toPromise(); + throw new Error("Expected Future to reject"); + } catch (e) { + return e as TspServiceException; + } +}; + +describe("UNIT KmsApiValidation", () => { + describe("ApiErrorResponseSchema", () => { + test("accepts valid error with code and message", async () => { + const validError = {code: 100, message: "Test error"}; + const result = await toPromise(validateJsonResponse(mockResponse(validError), ApiErrorResponseSchema)); + expect(result).toEqual(validError); + }); + + test("rejects missing code", async () => { + const invalidError = {message: "Test error"}; + const error = await toError(validateJsonResponse(mockResponse(invalidError), ApiErrorResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"code" is required'); + }); + + test("rejects missing message", async () => { + const invalidError = {code: 100}; + const error = await toError(validateJsonResponse(mockResponse(invalidError), ApiErrorResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"message" is required'); + }); + + test("rejects non-number code", async () => { + const invalidError = {code: "not-a-number", message: "Test error"}; + const error = await toError(validateJsonResponse(mockResponse(invalidError), ApiErrorResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"code" must be a number'); + }); + }); + + describe("DerivedKeySchema", () => { + test("accepts valid derived key", async () => { + const validKey = {derivedKey: "base64string", tenantSecretId: 123, current: true}; + const result = await toPromise(validateJsonResponse(mockResponse(validKey), DerivedKeySchema)); + expect(result).toEqual(validKey); + }); + + test("rejects missing derivedKey", async () => { + const invalidKey = {tenantSecretId: 123, current: true}; + const error = await toError(validateJsonResponse(mockResponse(invalidKey), DerivedKeySchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"derivedKey" is required'); + }); + + test("rejects missing tenantSecretId", async () => { + const invalidKey = {derivedKey: "base64string", current: true}; + const error = await toError(validateJsonResponse(mockResponse(invalidKey), DerivedKeySchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"tenantSecretId" is required'); + }); + + test("rejects missing current", async () => { + const invalidKey = {derivedKey: "base64string", tenantSecretId: 123}; + const error = await toError(validateJsonResponse(mockResponse(invalidKey), DerivedKeySchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"current" is required'); + }); + + test("rejects wrong types", async () => { + const invalidKey = {derivedKey: 123, tenantSecretId: "not-a-number", current: "not-a-boolean"}; + const error = await toError(validateJsonResponse(mockResponse(invalidKey), DerivedKeySchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + }); + + describe("DerivedKeysSchema", () => { + test("accepts valid derived keys structure", async () => { + const validKeys = { + path1: [{derivedKey: "key1", tenantSecretId: 1, current: true}], + path2: [ + {derivedKey: "key2", tenantSecretId: 2, current: true}, + {derivedKey: "key3", tenantSecretId: 3, current: false}, + ], + }; + const result = await toPromise(validateJsonResponse(mockResponse(validKeys), DerivedKeysSchema)); + expect(result).toEqual(validKeys); + }); + + test("accepts empty object", async () => { + const result = await toPromise(validateJsonResponse(mockResponse({}), DerivedKeysSchema)); + expect(result).toEqual({}); + }); + + test("rejects invalid nested DerivedKey", async () => { + const invalidKeys = { + path1: [{derivedKey: "key1", tenantSecretId: "not-a-number", current: true}], + }; + const error = await toError(validateJsonResponse(mockResponse(invalidKeys), DerivedKeysSchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + }); + + describe("WrapKeyResponseSchema", () => { + test("accepts valid response with dek and edek", async () => { + const validResponse = {dek: "dekValue", edek: "edekValue"}; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), WrapKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("rejects missing dek", async () => { + const invalidResponse = {edek: "edekValue"}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), WrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"dek" is required'); + }); + + test("rejects missing edek", async () => { + const invalidResponse = {dek: "dekValue"}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), WrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"edek" is required'); + }); + + test("rejects non-string values", async () => { + const invalidResponse = {dek: 123, edek: "edekValue"}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), WrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"dek" must be a string'); + }); + }); + + describe("UnwrapKeyResponseSchema", () => { + test("accepts valid response with dek", async () => { + const validResponse = {dek: "dekValue"}; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), UnwrapKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("rejects missing dek", async () => { + const invalidResponse = {}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), UnwrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"dek" is required'); + }); + }); + + describe("RekeyResponseSchema", () => { + test("accepts valid response (same as WrapKeyResponse)", async () => { + const validResponse = {dek: "dekValue", edek: "edekValue"}; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), RekeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("rejects missing dek", async () => { + const invalidResponse = {edek: "edekValue"}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), RekeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"dek" is required'); + }); + + test("rejects missing edek", async () => { + const invalidResponse = {dek: "dekValue"}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), RekeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"edek" is required'); + }); + }); + + describe("BatchWrapKeyResponseSchema", () => { + test("accepts valid response with keys and failures", async () => { + const validResponse = { + keys: { + doc1: {dek: "dek1", edek: "edek1"}, + doc2: {dek: "dek2", edek: "edek2"}, + }, + failures: { + doc3: {code: 100, message: "Wrap failed"}, + }, + }; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), BatchWrapKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("validates nested WrapKeyResponse in keys", async () => { + const invalidResponse = { + keys: { + doc1: {dek: "dek1"}, // missing edek + }, + failures: {}, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchWrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain("edek"); + expect(error.message).toContain("is required"); + }); + + test("validates nested ApiErrorResponse in failures", async () => { + const invalidResponse = { + keys: {}, + failures: { + doc1: {code: "not-a-number", message: "Error"}, // code should be number + }, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchWrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain("code"); + expect(error.message).toContain("must be a number"); + }); + + test("accepts empty keys/failures objects", async () => { + const validResponse = {keys: {}, failures: {}}; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), BatchWrapKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("rejects invalid nested structures", async () => { + const invalidResponse = { + keys: { + doc1: {invalidKey: "value"}, // wrong structure + }, + failures: {}, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchWrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + + test("rejects missing keys", async () => { + const invalidResponse = {failures: {}}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchWrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"keys" is required'); + }); + + test("rejects missing failures", async () => { + const invalidResponse = {keys: {}}; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchWrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"failures" is required'); + }); + }); + + describe("BatchUnwrapKeyResponseSchema", () => { + test("accepts valid response with keys and failures", async () => { + const validResponse = { + keys: { + doc1: {dek: "dek1"}, + doc2: {dek: "dek2"}, + }, + failures: { + doc3: {code: 100, message: "Unwrap failed"}, + }, + }; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), BatchUnwrapKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("validates nested UnwrapKeyResponse in keys", async () => { + const invalidResponse = { + keys: { + doc1: {notDek: "value"}, // wrong field name + }, + failures: {}, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchUnwrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + + test("validates nested ApiErrorResponse in failures", async () => { + const invalidResponse = { + keys: {}, + failures: { + doc1: {message: "Error"}, // missing code + }, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), BatchUnwrapKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain("code"); + expect(error.message).toContain("is required"); + }); + + test("accepts empty keys/failures objects", async () => { + const validResponse = {keys: {}, failures: {}}; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), BatchUnwrapKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + }); + + describe("DeriveKeyResponseSchema", () => { + test("accepts valid response with hasPrimaryConfig and derivedKeys", async () => { + const validResponse = { + hasPrimaryConfig: true, + derivedKeys: { + secretPath1: { + derivationPath1: [{derivedKey: "key1", tenantSecretId: 1, current: true}], + }, + }, + }; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), DeriveKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + + test("validates nested DerivedKeys structure", async () => { + const invalidResponse = { + hasPrimaryConfig: true, + derivedKeys: { + secretPath1: { + derivationPath1: [{derivedKey: "key1", tenantSecretId: "not-a-number", current: true}], + }, + }, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), DeriveKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + + test("validates DerivedKey array elements", async () => { + const invalidResponse = { + hasPrimaryConfig: true, + derivedKeys: { + secretPath1: { + derivationPath1: [{derivedKey: "key1"}], // missing tenantSecretId and current + }, + }, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), DeriveKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + + test("rejects invalid nested structures", async () => { + const invalidResponse = { + hasPrimaryConfig: true, + derivedKeys: { + secretPath1: "not-an-object", + }, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), DeriveKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + }); + + test("rejects missing hasPrimaryConfig", async () => { + const invalidResponse = { + derivedKeys: {}, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), DeriveKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"hasPrimaryConfig" is required'); + }); + + test("rejects missing derivedKeys", async () => { + const invalidResponse = { + hasPrimaryConfig: true, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), DeriveKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"derivedKeys" is required'); + }); + + test("rejects non-boolean hasPrimaryConfig", async () => { + const invalidResponse = { + hasPrimaryConfig: "not-a-boolean", + derivedKeys: {}, + }; + const error = await toError(validateJsonResponse(mockResponse(invalidResponse), DeriveKeyResponseSchema)); + expect(error).toBeInstanceOf(TspServiceException); + expect(error.message).toContain('"hasPrimaryConfig" must be a boolean'); + }); + + test("accepts empty derivedKeys object", async () => { + const validResponse = { + hasPrimaryConfig: false, + derivedKeys: {}, + }; + const result = await toPromise(validateJsonResponse(mockResponse(validResponse), DeriveKeyResponseSchema)); + expect(result).toEqual(validResponse); + }); + }); +}); diff --git a/src/logdriver/SecurityEventApi.ts b/src/logdriver/SecurityEventApi.ts index e9449f7..7f9ce2e 100644 --- a/src/logdriver/SecurityEventApi.ts +++ b/src/logdriver/SecurityEventApi.ts @@ -3,6 +3,7 @@ import {TenantSecurityException} from "../TenantSecurityException"; import {makeJsonRequest} from "../Util"; import {EventMetadata} from "./EventMetadata"; import {SecurityEvent} from "./SecurityEvent"; +import Joi = require("joi"); const SECURITY_EVENT_ENDPOINT = "event/security-event"; @@ -14,7 +15,7 @@ const SECURITY_EVENT_ENDPOINT = "event/security-event"; * @return Void on success. Failures come back as exceptions. */ export const logSecurityEvent = (tspDomain: string, apiKey: string, event: SecurityEvent, metadata: EventMetadata): Future => - makeJsonRequest(tspDomain, apiKey, SECURITY_EVENT_ENDPOINT, JSON.stringify(combinePostableEventAndMetadata(event, metadata))); + makeJsonRequest(tspDomain, apiKey, SECURITY_EVENT_ENDPOINT, JSON.stringify(combinePostableEventAndMetadata(event, metadata)), Joi.any()); const combinePostableEventAndMetadata = (event: SecurityEvent, metadata: EventMetadata) => { const postData = metadata.toJsonStructure(); diff --git a/yarn.lock b/yarn.lock index cfb91d2..a8456ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -349,6 +349,40 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@hapi/address@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-5.1.1.tgz#e9925fc1b65f5cc3fbea821f2b980e4652e84cb6" + integrity sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/formula@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-3.0.2.tgz#81b538060ee079481c906f599906d163c4badeaf" + integrity sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw== + +"@hapi/hoek@^11.0.2", "@hapi/hoek@^11.0.7": + version "11.0.7" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.7.tgz#56a920793e0a42d10e530da9a64cc0d3919c4002" + integrity sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ== + +"@hapi/pinpoint@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.1.tgz#32077e715655fc00ab8df74b6b416114287d6513" + integrity sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q== + +"@hapi/tlds@^1.1.1": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@hapi/tlds/-/tlds-1.1.4.tgz#df4a7b59082b54ba4f3b7b38f781e2ac3cbc359a" + integrity sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA== + +"@hapi/topo@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-6.0.2.tgz#f219c1c60da8430228af4c1f2e40c32a0d84bbb4" + integrity sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg== + dependencies: + "@hapi/hoek" "^11.0.2" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz" @@ -687,6 +721,11 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -3066,6 +3105,19 @@ jest@^26.4.1: import-local "^3.0.2" jest-cli "^26.4.1" +joi@^18.0.2: + version "18.0.2" + resolved "https://registry.yarnpkg.com/joi/-/joi-18.0.2.tgz#30ced6aed00a7848cc11f92859515258301dc3a4" + integrity sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA== + dependencies: + "@hapi/address" "^5.1.1" + "@hapi/formula" "^3.0.2" + "@hapi/hoek" "^11.0.7" + "@hapi/pinpoint" "^2.0.1" + "@hapi/tlds" "^1.1.1" + "@hapi/topo" "^6.0.2" + "@standard-schema/spec" "^1.0.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" From 2ae1faff21b1637eaaedb2d6df28a8a7f7a0ae87 Mon Sep 17 00:00:00 2001 From: Craig Colegrove Date: Fri, 13 Feb 2026 09:57:27 -0800 Subject: [PATCH 2/3] Update CI, minimum node version --- .github/workflows/ci.yml | 7 ++----- package.json | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4722f16..cbd97b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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, 24] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -53,7 +50,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-node@v6 with: - node-version: 14 + node-version: 24 - name: compilation check run: | yarn diff --git a/package.json b/package.json index ffe25f4..3dc4d48 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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", From 3bd2640377d1f1d011fe626188405007c2a25fb5 Mon Sep 17 00:00:00 2001 From: Craig Colegrove Date: Fri, 13 Feb 2026 10:03:53 -0800 Subject: [PATCH 3/3] Exclude 24 for now --- .github/workflows/ci.yml | 4 ++-- flake.nix | 4 ++-- package.json | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbd97b8..0687170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - version: [20, 22, 24] + version: [20, 22] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 22 - name: compilation check run: | yarn diff --git a/flake.nix b/flake.nix index 92cfead..9558422 100644 --- a/flake.nix +++ b/flake.nix @@ -27,9 +27,9 @@ devShell = pkgs.mkShell { buildInputs = with pkgs.nodePackages; [ - pkgs.nodejs_24 + pkgs.nodejs_22 pkgs.protobuf - (pkgs.yarn.override { nodejs = pkgs.nodejs_24; }) + (pkgs.yarn.override { nodejs = pkgs.nodejs_22; }) (pkgs.google-cloud-sdk.withExtraComponents [ pkgs.google-cloud-sdk.components.gke-gcloud-auth-plugin ]) ]; }; diff --git a/package.json b/package.json index 3dc4d48..755a735 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "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",