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"]