From c6cdbefc5c2cffda0c8a70972ee115e60112bfa4 Mon Sep 17 00:00:00 2001 From: Paul Flynn <43211074+pflynn-virtru@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:40:40 -0500 Subject: [PATCH] fix: keySplitInfo Promise.any Promise.all (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix EntityObject import path and add unwrapKey tests Updated the import path for EntityObject to ensure correct module resolution. Added a comprehensive set of tests for the unwrapKey function to validate error handling and successful key unwrapping scenarios. * Refactor unwrapKey to parallelize KAS attempts Parallelize the KAS key rewrap attempts within unwrapKey for improved efficiency. All potential KAS sources are now processed concurrently, and the method uses the first successful result, enhancing performance and robustness. Errors are collected and the most relevant one is thrown if all attempts fail. * Delete unwrapKey test suite Removed the entire unwrapKey.spec.ts test suite as it is no longer necessary. Minor formatting adjustments were made in the tdf.ts file. * Add detailed error handling tests for encryption-decryption Introduced comprehensive error handling tests for various HTTP status codes in encryption-decryption processes, including 400, 401, 403, and 500 HTTP errors. Adjusted the mock server to validate error conditions based on custom headers and handle network failures. * 🤖 🎨 Autoformat * x-test-response-message * Refactor KAS source error handling with Promise.any Simplify the KAS handling logic by using `Promise.any` to stop at the first successful response rather than waiting for all promises to complete. This ensures more efficient error handling and improves overall performance by immediately processing the first successful KAS response, while adequately handling AggregateErrors for failed attempts. * Update TypeScript config to ES2021 standards Updated the TypeScript configuration in both "lib" and "cli" projects to use ES2021 language features and modules. This change improves consistency and supports newer JavaScript features. * 🤖 🎨 Autoformat * lastError * reformat * AND OR --------- Co-authored-by: pflynn-virtru --- cli/tsconfig.json | 2 +- lib/tdf3/src/tdf.ts | 171 +++++++++----- lib/tests/mocha/encrypt-decrypt.spec.ts | 285 +++++++++++++++++++++++- lib/tests/server.ts | 41 +++- lib/tsconfig.json | 6 +- 5 files changed, 429 insertions(+), 76 deletions(-) diff --git a/cli/tsconfig.json b/cli/tsconfig.json index f4459b0f..2ab9dc45 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../lib/tsconfig.json", "compilerOptions": { - "lib": ["es2020", "ES2022.Error"], + "lib": ["es2021", "ES2022.Error"], "module": "Node16", "outDir": "dist" }, diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 699c2633..c99a370d 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -1,8 +1,8 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { unsigned } from './utils/buffer-crc32.js'; import { exportSPKI, importX509 } from 'jose'; import { DecoratedReadableStream } from './client/DecoratedReadableStream.js'; -import { EntityObject } from '../../src/tdf/EntityObject.js'; +import { EntityObject } from '../../src/tdf/index.js'; import { pemToCryptoPublicKey, validateSecureUrl } from '../../src/utils.js'; import { DecryptParams } from './client/builders.js'; import { AssertionConfig, AssertionKey, AssertionVerificationKeys } from './assertions.js'; @@ -926,25 +926,10 @@ async function unwrapKey({ } const { keyAccess } = manifest.encryptionInformation; const splitPotentials = splitLookupTableFactory(keyAccess, allowedKases); - - let responseMetadata; const isAppIdProvider = authProvider && isAppIdProviderCheck(authProvider); - // Get key access information to know the KAS URLS - const rewrappedKeys: Uint8Array[] = []; - - for (const [splitId, potentials] of Object.entries(splitPotentials)) { - if (!potentials || !Object.keys(potentials).length) { - throw new UnsafeUrlError( - `Unreconstructable key - no valid KAS found for split ${JSON.stringify(splitId)}`, - '' - ); - } - // If we have multiple ways of getting a value, try the 'best' way - // or maybe retry across all potential ways? Currently, just tries them all - const [keySplitInfo] = Object.values(potentials); + async function tryKasRewrap(keySplitInfo: KeyAccessObject) { const url = `${keySplitInfo.url}/${isAppIdProvider ? '' : 'v2/'}rewrap`; - const ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair( await cryptoService.generateKeyPair() ); @@ -980,58 +965,122 @@ async function unwrapKey({ }; } - // Create a PoP token by signing the body so KAS knows we actually have a private key - // Expires in 60 seconds const httpReq = await authProvider.withCreds(buildRequest('POST', url, requestBody)); + const { + data: { entityWrappedKey, metadata }, + } = await axios.post(httpReq.url, httpReq.body, { headers: httpReq.headers }); + + const key = Binary.fromString(base64.decode(entityWrappedKey)); + const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( + key, + ephemeralEncryptionKeys.privateKey + ); + + return { + key: new Uint8Array(decryptedKeyBinary.asByteArray()), + metadata, + }; + } + + // Get unique split IDs to determine if we have an OR or AND condition + const splitIds = new Set(Object.keys(splitPotentials)); + + // If we have only one split ID, it's an OR condition + if (splitIds.size === 1) { + const [splitId] = splitIds; + const potentials = splitPotentials[splitId]; try { - // The response from KAS on a rewrap - const { - data: { entityWrappedKey, metadata }, - } = await axios.post(httpReq.url, httpReq.body, { headers: httpReq.headers }); - responseMetadata = metadata; - const key = Binary.fromString(base64.decode(entityWrappedKey)); - const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( - key, - ephemeralEncryptionKeys.privateKey + // OR condition: Try all KAS servers for this split, take first success + const result = await Promise.any( + Object.values(potentials).map(async (keySplitInfo) => { + try { + return await tryKasRewrap(keySplitInfo); + } catch (e) { + // Rethrow with more context + throw handleRewrapError(e as Error | AxiosError); + } + }) ); - rewrappedKeys.push(new Uint8Array(decryptedKeyBinary.asByteArray())); - } catch (e) { - if (e.response) { - if (e.response.status >= 500) { - throw new ServiceError('rewrap failure', e); - } else if (e.response.status === 403) { - throw new PermissionDeniedError('rewrap failure', e); - } else if (e.response.status === 401) { - throw new UnauthenticatedError('rewrap auth failure', e); - } else if (e.response.status === 400) { - throw new InvalidFileError( - 'rewrap bad request; could indicate an invalid policy binding or a configuration error', - e + + const reconstructedKey = keyMerge([result.key]); + return { + reconstructedKeyBinary: Binary.fromArrayBuffer(reconstructedKey), + metadata: result.metadata, + }; + } catch (error) { + if (error instanceof AggregateError) { + // All KAS servers failed + throw error.errors[0]; // Throw the first error since we've already wrapped them + } + throw error; + } + } else { + // AND condition: We need successful results from all different splits + const splitResults = await Promise.all( + Object.entries(splitPotentials).map(async ([splitId, potentials]) => { + if (!potentials || !Object.keys(potentials).length) { + throw new UnsafeUrlError( + `Unreconstructable key - no valid KAS found for split ${JSON.stringify(splitId)}`, + '' ); - } else { - throw new NetworkError('rewrap server error', e); } - } else if (e.request) { - throw new NetworkError('rewrap request failure', e); - } else if (e.name == 'InvalidAccessError' || e.name == 'OperationError') { - throw new DecryptError('unable to unwrap key from kas', e); - } - throw new InvalidFileError( - `Unable to decrypt the response from KAS: [${e.name}: ${e.message}], response: [${e?.response?.body}]`, - e + + try { + // For each split, try all potential KAS servers until one succeeds + return await Promise.any( + Object.values(potentials).map(async (keySplitInfo) => { + try { + return await tryKasRewrap(keySplitInfo); + } catch (e) { + throw handleRewrapError(e as Error | AxiosError); + } + }) + ); + } catch (error) { + if (error instanceof AggregateError) { + // All KAS servers for this split failed + throw error.errors[0]; // Throw the first error since we've already wrapped them + } + throw error; + } + }) + ); + + // Merge all the split keys + const reconstructedKey = keyMerge(splitResults.map((r) => r.key)); + return { + reconstructedKeyBinary: Binary.fromArrayBuffer(reconstructedKey), + metadata: splitResults[0].metadata, // Use metadata from first split + }; + } +} + +function handleRewrapError(error: Error | AxiosError) { + if (axios.isAxiosError(error)) { + if (error.response?.status && error.response?.status >= 500) { + return new ServiceError('rewrap failure', error); + } else if (error.response?.status === 403) { + return new PermissionDeniedError('rewrap failure', error); + } else if (error.response?.status === 401) { + return new UnauthenticatedError('rewrap auth failure', error); + } else if (error.response?.status === 400) { + return new InvalidFileError( + 'rewrap bad request; could indicate an invalid policy binding or a configuration error', + error ); + } else { + return new NetworkError('rewrap server error', error); } + } else { + if (error.name === 'InvalidAccessError' || error.name === 'OperationError') { + return new DecryptError('unable to unwrap key from kas', error); + } + return new InvalidFileError( + `Unable to decrypt the response from KAS: [${error.name}: ${error.message}]`, + error + ); } - - // Merge the unwrapped keys from each KAS - const reconstructedKey = keyMerge(rewrappedKeys); - const reconstructedKeyBinary = Binary.fromArrayBuffer(reconstructedKey); - - return { - reconstructedKeyBinary, - metadata: responseMetadata, - }; } async function decryptChunk( diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index a2094aea..22c2cde9 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -2,13 +2,19 @@ import { assert } from 'chai'; import { getMocks } from '../mocks/index.js'; -import { HttpRequest } from '../../src/auth/auth.js'; -import { WebCryptoService } from '../../tdf3/index.js'; +import { HttpMethod, HttpRequest } from '../../src/auth/auth.js'; +import { AesGcmCipher, KeyInfo, SplitKey, WebCryptoService } from '../../tdf3/index.js'; import { Client } from '../../tdf3/src/index.js'; -import { SplitKey } from '../../tdf3/src/models/encryption-information.js'; -import { AesGcmCipher } from '../../tdf3/src/ciphers/aes-gcm-cipher.js'; import { AssertionConfig, AssertionVerificationKeys } from '../../tdf3/src/assertions.js'; import { Scope } from '../../tdf3/src/client/builders.js'; +import { + InvalidFileError, + NetworkError, + PermissionDeniedError, + ServiceError, + UnauthenticatedError, +} from '../../src/errors.js'; + const Mocks = getMocks(); const authProvider = { @@ -17,6 +23,277 @@ const authProvider = { withCreds: async (httpReq: HttpRequest) => httpReq, }; +describe('rewrap error cases', function () { + const kasUrl = 'http://localhost:3000'; + const expectedVal = 'test data'; + let client: Client.Client; + let cipher: AesGcmCipher; + let encryptionInformation: SplitKey; + let key1: KeyInfo; + + beforeEach(async function () { + // Setup base auth provider that will be modified per test + const baseAuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => httpReq, + }; + + client = new Client.Client({ + kasEndpoint: kasUrl, + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider: baseAuthProvider, + }); + + cipher = new AesGcmCipher(WebCryptoService); + encryptionInformation = new SplitKey(cipher); + key1 = await encryptionInformation.generateKey(); + }); + + async function encryptTestData({ + customAuthProvider, + }: { + customAuthProvider?: + | { + updateClientPublicKey: () => Promise; + withCreds: + | ((httpReq: HttpRequest) => Promise<{ + headers: { authorization: string }; + method: HttpMethod; + params?: object | undefined; + url: string; + body?: unknown; + }>) + | ((httpReq: HttpRequest) => Promise<{ + headers: { 'x-test-response': string }; + method: HttpMethod; + params?: object | undefined; + url: string; + body?: unknown; + }>) + | ((httpReq: HttpRequest) => Promise<{ + body: { invalidField: string }; + headers: Record; + method: HttpMethod; + params?: object | undefined; + url: string; + }>) + | ((httpReq: HttpRequest) => Promise<{ + body: { invalidKey: boolean }; + headers: Record; + method: HttpMethod; + params?: object | undefined; + url: string; + }>); + } + | undefined; + }) { + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + if (customAuthProvider) { + client = new Client.Client({ + kasEndpoint: kasUrl, + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider: customAuthProvider, + }); + } + + const eo = await Mocks.getEntityObject(); + return client.encrypt({ + eo, + metadata: Mocks.getMetadataObject(), + offline: true, + scope: { + dissem: ['user@domain.com'], + attributes: [], + }, + keyMiddleware, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + } + + it('should handle 401 Unauthorized error', async function () { + const authProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => ({ + ...httpReq, + headers: { ...httpReq.headers, authorization: 'Invalid' }, + }), + }; + + const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); + + const eo = await Mocks.getEntityObject(); + try { + await client.decrypt({ + eo, + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + assert.fail('Expected UnauthenticatedError'); + } catch (error) { + assert.instanceOf(error, UnauthenticatedError); + assert.include(error.message, 'rewrap auth failure'); + } + }); + + it('should handle 403 Forbidden error', async function () { + const authProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => ({ + ...httpReq, + headers: { ...httpReq.headers, 'x-test-response': '403' }, + }), + }; + + const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); + + const eo = await Mocks.getEntityObject(); + try { + await client.decrypt({ + eo, + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + assert.fail('Expected PermissionDeniedError'); + } catch (error) { + assert.instanceOf(error, PermissionDeniedError); + assert.include(error.message, 'rewrap failure'); + } + }); + + it('should handle 400 Bad Request error', async function () { + // Modify the mock server to return 400 for invalid body + const authProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => ({ + ...httpReq, + headers: { + ...httpReq.headers, + 'x-test-response': '400', + 'x-test-response-message': 'IntegrityError', + }, + }), + }; + + const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); + + const eo = await Mocks.getEntityObject(); + try { + await client.decrypt({ + eo, + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + assert.fail('Expected InvalidFileError'); + } catch (error) { + assert.instanceOf(error, InvalidFileError); + assert.include(error.message, 'rewrap bad request'); + } + }); + + it('should handle 500 Server error', async function () { + const authProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => ({ + ...httpReq, + headers: { ...httpReq.headers, 'x-test-response': '500' }, + }), + }; + + const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); + + const eo = await Mocks.getEntityObject(); + try { + await client.decrypt({ + eo, + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + assert.fail('Expected ServiceError'); + } catch (error) { + assert.instanceOf(error, ServiceError); + assert.include(error.message, 'rewrap failure'); + } + }); + + it('should handle network failures', async function () { + try { + // Point to a non-existent server + client = new Client.Client({ + kasEndpoint: 'http://localhost:9999', + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider: { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => httpReq, + }, + }); + + const encryptedStream = await encryptTestData({}); + + const eo = await Mocks.getEntityObject(); + + await client.decrypt({ + eo, + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + assert.fail('Expected NetworkError'); + } catch (error) { + assert.instanceOf(error, NetworkError); + } + }); + + it('should handle decrypt errors with invalid keys', async function () { + const authProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (httpReq: HttpRequest) => ({ + ...httpReq, + body: { invalidKey: true }, + headers: { + ...httpReq.headers, + 'x-test-response': '400', + 'x-test-response-message': 'DecryptError', + }, + }), + }; + + const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); + + const eo = await Mocks.getEntityObject(); + try { + await client.decrypt({ + eo, + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + assert.fail('Expected InvalidFileError'); + } catch (error) { + assert.instanceOf(error, InvalidFileError); + assert.include(error.message, 'rewrap bad request'); + } + }); +}); + describe('encrypt decrypt test', async function () { const expectedVal = 'hello world'; const kasUrl = `http://localhost:3000`; diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 9041db3f..811c1565 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,18 +1,17 @@ import './mocha/setup.js'; import * as jose from 'jose'; -import { IncomingMessage, RequestListener, createServer } from 'node:http'; +import { createServer, IncomingMessage, RequestListener } from 'node:http'; import { base64 } from '../src/encodings/index.js'; import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; import { getMocks } from './mocks/index.js'; -import { Header, getHkdfSalt } from '../src/nanotdf/index.js'; -import { keyAgreement } from '../src/nanotdf-crypto/keyAgreement.js'; +import { getHkdfSalt, Header } from '../src/nanotdf/index.js'; +import { keyAgreement, pemPublicToCrypto } from '../src/nanotdf-crypto/index.js'; import generateRandomNumber from '../src/nanotdf-crypto/generateRandomNumber.js'; -import { pemPublicToCrypto } from '../src/nanotdf-crypto/pemPublicToCrypto.js'; import { removePemFormatting } from '../tdf3/src/crypto/crypto-utils.js'; import { Binary } from '../tdf3/index.js'; -import { type KeyAccessObject } from '../tdf3/src/models/key-access.js'; +import { type KeyAccessObject } from '../tdf3/src/models/index.js'; import { valueFor } from './web/policy/mock-attrs.js'; import { AttributeAndValue } from '../src/policy/attributes.js'; @@ -33,6 +32,9 @@ type RewrapBody = { }; policy: string; clientPublicKey: string; + // testing only + invalidKey: string; + invalidField: string; }; function concat(b: ArrayBufferView[]) { @@ -65,7 +67,7 @@ const kas: RequestListener = async (req, res) => { console.log('[INFO]: server request: ', req.method, req.url, req.headers); res.setHeader( 'Access-Control-Allow-Headers', - 'authorization, content-type, dpop, range, virtru-ntdf-version' + 'authorization, content-type, dpop, range, virtru-ntdf-version, x-test-response, x-test-response-message' ); res.setHeader('Access-Control-Allow-Origin', '*'); // GET should be allowed for everything except rewrap, POST only for rewrap but IDC @@ -108,6 +110,26 @@ const kas: RequestListener = async (req, res) => { const kid = 'ec:secp256r1' == algorithm ? 'e1' : 'r1'; res.end(JSON.stringify(v == '2' ? { kid, publicKey } : publicKey)); } else if (url.pathname === '/v2/rewrap') { + // For testing error conditions x-test-response, immediate error returns, control by individual test + if (req.headers['x-test-response']) { + const statusCode = parseInt(req.headers['x-test-response'] as string); + res.writeHead(statusCode); + switch (statusCode) { + case 400: + const statusMessage = parseInt(req.headers['x-test-response-message'] as string); + res.end(JSON.stringify({ error: statusMessage })); + return; + case 401: + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + case 403: + res.end(JSON.stringify({ error: 'Forbidden' })); + return; + case 500: + res.end(JSON.stringify({ error: 'Internal server error' })); + return; + } + } if (req.method !== 'POST') { console.error(`[ERROR] /v2/rewrap only accepts POST verbs, received [${req.method}]`); res.writeHead(405); @@ -115,7 +137,12 @@ const kas: RequestListener = async (req, res) => { return; } console.log('[INFO]: rewrap request meta: ', req.method, req.url, req.headers); - // NOTE: Real KAS will validate authorization and dpop here. + // NOTE: Real KAS will validate authorization and dpop here. simple Invalid check + if (req.headers['authorization'] == 'Invalid') { + res.writeHead(401); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } const body = await getBody(req); const bodyText = new TextDecoder().decode(body); const { signedRequestToken } = JSON.parse(bodyText); diff --git a/lib/tsconfig.json b/lib/tsconfig.json index a4e3476b..0c66393e 100644 --- a/lib/tsconfig.json +++ b/lib/tsconfig.json @@ -10,8 +10,8 @@ "forceConsistentCasingInFileNames": true, "inlineSourceMap": true, "isolatedModules": true, - "lib": ["dom", "dom.iterable", "es2020", "ES2022.Error"], - "module": "es2020", + "lib": ["dom", "dom.iterable", "es2021", "ES2022.Error"], + "module": "es2022", "moduleResolution": "node16", "noImplicitAny": true, "noImplicitOverride": true, @@ -25,7 +25,7 @@ "strictFunctionTypes": true, "strictNullChecks": true, "strictPropertyInitialization": false, - "target": "es2020", + "target": "es2021", }, "include": ["src", "tests", "tdf3"], "exclude": ["**/node_modules"]