diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 699c2633..16cf7a85 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'; @@ -293,6 +293,7 @@ export async function fetchKasPublicKey( } } } + /** * * @param payload The TDF content to encode in HTML @@ -926,13 +927,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)) { + // Process all splits in parallel and take the first successful one + const splitPromises = 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)}`, @@ -940,97 +938,126 @@ async function unwrapKey({ ); } - // 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); - const url = `${keySplitInfo.url}/${isAppIdProvider ? '' : 'v2/'}rewrap`; + // Try all potential KAS sources for this split + const errors: Array = []; + const kasPromises = Object.values(potentials).map(async (keySplitInfo) => { + try { + const url = `${keySplitInfo.url}/${isAppIdProvider ? '' : 'v2/'}rewrap`; + const ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair( + await cryptoService.generateKeyPair() + ); + const clientPublicKey = ephemeralEncryptionKeys.publicKey; - const ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair( - await cryptoService.generateKeyPair() - ); - const clientPublicKey = ephemeralEncryptionKeys.publicKey; + const requestBodyStr = JSON.stringify({ + algorithm: 'RS256', + keyAccess: keySplitInfo, + policy: manifest.encryptionInformation.policy, + clientPublicKey, + }); + + const jwtPayload = { requestBody: requestBodyStr }; + const signedRequestToken = await reqSignature( + isAppIdProvider ? {} : jwtPayload, + dpopKeys.privateKey + ); + + let requestBody; + if (isAppIdProvider) { + requestBody = { + keyAccess: keySplitInfo, + policy: manifest.encryptionInformation.policy, + entity: { + ...entity, + publicKey: clientPublicKey, + }, + authToken: signedRequestToken, + }; + } else { + requestBody = { + signedRequestToken, + }; + } + + 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 + ); - const requestBodyStr = JSON.stringify({ - algorithm: 'RS256', - keyAccess: keySplitInfo, - policy: manifest.encryptionInformation.policy, - clientPublicKey, + return { + success: true, + key: new Uint8Array(decryptedKeyBinary.asByteArray()), + metadata, + }; + } catch (e) { + // Store the error but don't throw yet - we might have other valid sources + errors.push(e); + return { success: false, error: e }; + } }); - const jwtPayload = { requestBody: requestBodyStr }; - const signedRequestToken = await reqSignature( - isAppIdProvider ? {} : jwtPayload, - dpopKeys.privateKey - ); + // Wait for all KAS attempts for this split to complete + const results = await Promise.all(kasPromises); + const successfulResult = results.find((r) => r.success); - let requestBody; - if (isAppIdProvider) { - requestBody = { - keyAccess: keySplitInfo, - policy: manifest.encryptionInformation.policy, - entity: { - ...entity, - publicKey: clientPublicKey, - }, - authToken: signedRequestToken, - }; - } else { - requestBody = { - signedRequestToken, - }; + if (successfulResult) { + return successfulResult; } - // 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)); - - 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 - ); - 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 - ); - } 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); + // If we get here, all attempts failed for this split + // Throw the most relevant error + const lastError = errors[errors.length - 1] as import('axios').AxiosError; + if (axios.isAxiosError(lastError)) { + if (lastError.response?.status && lastError.response?.status >= 500) { + throw new ServiceError('rewrap failure', lastError); + } else if (lastError.response?.status === 403) { + throw new PermissionDeniedError('rewrap failure', lastError); + } else if (lastError.response?.status === 401) { + throw new UnauthenticatedError('rewrap auth failure', lastError); + } else if (lastError.response?.status === 400) { + throw new InvalidFileError( + 'rewrap bad request; could indicate an invalid policy binding or a configuration error', + lastError + ); + } else { + throw new NetworkError('rewrap server error', lastError); + } + } else { + const error = lastError as Error; + if (error.name === 'InvalidAccessError' || error.name === 'OperationError') { + throw new DecryptError('unable to unwrap key from kas', lastError); } throw new InvalidFileError( - `Unable to decrypt the response from KAS: [${e.name}: ${e.message}], response: [${e?.response?.body}]`, - e + `Unable to decrypt the response from KAS: [${error.name}: ${error.message}]`, + lastError ); } + }); + + // Race to get the first successful split result + const results = await Promise.allSettled(splitPromises); + const successfulResult = results.find( + (r): r is PromiseFulfilledResult<{ success: true; key: Uint8Array; metadata: never }> => + r.status === 'fulfilled' && r.value.success + ); + + if (!successfulResult) { + // If all splits failed, throw the last error + const lastRejection = results[results.length - 1] as PromiseRejectedResult; + throw lastRejection.reason; } - // Merge the unwrapped keys from each KAS - const reconstructedKey = keyMerge(rewrappedKeys); + const reconstructedKey = keyMerge([successfulResult.value.key]); const reconstructedKeyBinary = Binary.fromArrayBuffer(reconstructedKey); return { reconstructedKeyBinary, - metadata: responseMetadata, + metadata: successfulResult.value.metadata, }; } 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);