From 4b054716233555d6190a558ffa32b05bd21a7739 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 7 Nov 2024 12:17:11 -0500 Subject: [PATCH 1/6] 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. --- lib/tdf3/src/tdf.ts | 2 +- lib/tests/mocha/unwrapKey.spec.ts | 90 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 lib/tests/mocha/unwrapKey.spec.ts diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 699c2633..e1a71427 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -2,7 +2,7 @@ import axios 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'; diff --git a/lib/tests/mocha/unwrapKey.spec.ts b/lib/tests/mocha/unwrapKey.spec.ts new file mode 100644 index 00000000..c5781893 --- /dev/null +++ b/lib/tests/mocha/unwrapKey.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as TDF from '../../tdf3/src/tdf.js'; +import { ConfigurationError, InvalidFileError, NetworkError, PermissionDeniedError, ServiceError, UnauthenticatedError, UnsafeUrlError, DecryptError } from '../../src/errors.js'; +import { OriginAllowList } from '../../src/access.js'; +import type { AuthProvider, AppIdAuthProvider } from '../../src/auth/auth.js'; +import { CryptoService, CryptoKeyPair, EntityObject } from '../../tdf3/index.js'; + +describe('unwrapKey', () => { + let manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService; + + beforeEach(() => { + manifest = { encryptionInformation: { keyAccess: [], policy: {}, integrityInformation: {} } }; + allowedKases = new OriginAllowList(['https://kas1']); + authProvider = sinon.createStubInstance(AuthProvider); + dpopKeys = { privateKey: 'privateKey' } as CryptoKeyPair; + entity = {} as EntityObject; + cryptoService = sinon.createStubInstance(CryptoService); + }); + + it('throws ConfigurationError if authProvider is undefined', async () => { + authProvider = undefined; + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(ConfigurationError, 'upsert requires auth provider; must be configured in client constructor'); + }); + + it('throws UnsafeUrlError if no valid KAS found for split', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas2' }]; + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(UnsafeUrlError, 'Unreconstructable key - no valid KAS found for split "split1"'); + }); + + it('throws ServiceError on rewrap failure with status >= 500', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.rejects({ response: { status: 500 } }); + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(ServiceError, 'rewrap failure'); + }); + + it('throws PermissionDeniedError on rewrap failure with status 403', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.rejects({ response: { status: 403 } }); + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(PermissionDeniedError, 'rewrap failure'); + }); + + it('throws UnauthenticatedError on rewrap failure with status 401', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.rejects({ response: { status: 401 } }); + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(UnauthenticatedError, 'rewrap auth failure'); + }); + + it('throws InvalidFileError on rewrap failure with status 400', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.rejects({ response: { status: 400 } }); + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(InvalidFileError, 'rewrap bad request; could indicate an invalid policy binding or a configuration error'); + }); + + it('throws NetworkError on rewrap request failure', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.rejects({ request: {} }); + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(NetworkError, 'rewrap request failure'); + }); + + it('throws DecryptError on rewrap failure with InvalidAccessError or OperationError', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.rejects({ name: 'InvalidAccessError' }); + await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) + .to.be.rejectedWith(DecryptError, 'unable to unwrap key from kas'); + }); + + it('returns reconstructedKeyBinary and metadata on successful unwrap', async () => { + manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; + authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); + axios.post.resolves({ data: { entityWrappedKey: 'wrappedKey', metadata: 'metadata' } }); + cryptoService.decryptWithPrivateKey.resolves(new Uint8Array([1, 2, 3])); + const result = await TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService }); + expect(result.reconstructedKeyBinary).to.deep.equal(new Uint8Array([1, 2, 3])); + expect(result.metadata).to.equal('metadata'); + }); +}); From d30e9986d76a9320afee050e479a5b06be860a0b Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Tue, 12 Nov 2024 09:16:06 -0500 Subject: [PATCH 2/6] 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. --- lib/tdf3/src/tdf.ts | 200 +++++++++++++++++++++++++------------------- 1 file changed, 113 insertions(+), 87 deletions(-) diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index e1a71427..fa5f0a19 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -1,4 +1,4 @@ -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'; @@ -905,13 +905,13 @@ export function splitLookupTableFactory( } async function unwrapKey({ - manifest, - allowedKases, - authProvider, - dpopKeys, - entity, - cryptoService, -}: { + manifest, + allowedKases, + authProvider, + dpopKeys, + entity, + cryptoService, + }: { manifest: Manifest; allowedKases: OriginAllowList; authProvider: AuthProvider | AppIdAuthProvider; @@ -926,13 +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)) { + // 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 +937,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 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 + ); + + 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, }; } From 51ed1c3a33d8b741dfb9d9bf44b6a27d4be8eb95 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Tue, 12 Nov 2024 09:24:26 -0500 Subject: [PATCH 3/6] 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. --- lib/tdf3/src/tdf.ts | 15 +++--- lib/tests/mocha/unwrapKey.spec.ts | 90 ------------------------------- 2 files changed, 8 insertions(+), 97 deletions(-) delete mode 100644 lib/tests/mocha/unwrapKey.spec.ts diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index fa5f0a19..16cf7a85 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -293,6 +293,7 @@ export async function fetchKasPublicKey( } } } + /** * * @param payload The TDF content to encode in HTML @@ -905,13 +906,13 @@ export function splitLookupTableFactory( } async function unwrapKey({ - manifest, - allowedKases, - authProvider, - dpopKeys, - entity, - cryptoService, - }: { + manifest, + allowedKases, + authProvider, + dpopKeys, + entity, + cryptoService, +}: { manifest: Manifest; allowedKases: OriginAllowList; authProvider: AuthProvider | AppIdAuthProvider; diff --git a/lib/tests/mocha/unwrapKey.spec.ts b/lib/tests/mocha/unwrapKey.spec.ts deleted file mode 100644 index c5781893..00000000 --- a/lib/tests/mocha/unwrapKey.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; -import * as TDF from '../../tdf3/src/tdf.js'; -import { ConfigurationError, InvalidFileError, NetworkError, PermissionDeniedError, ServiceError, UnauthenticatedError, UnsafeUrlError, DecryptError } from '../../src/errors.js'; -import { OriginAllowList } from '../../src/access.js'; -import type { AuthProvider, AppIdAuthProvider } from '../../src/auth/auth.js'; -import { CryptoService, CryptoKeyPair, EntityObject } from '../../tdf3/index.js'; - -describe('unwrapKey', () => { - let manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService; - - beforeEach(() => { - manifest = { encryptionInformation: { keyAccess: [], policy: {}, integrityInformation: {} } }; - allowedKases = new OriginAllowList(['https://kas1']); - authProvider = sinon.createStubInstance(AuthProvider); - dpopKeys = { privateKey: 'privateKey' } as CryptoKeyPair; - entity = {} as EntityObject; - cryptoService = sinon.createStubInstance(CryptoService); - }); - - it('throws ConfigurationError if authProvider is undefined', async () => { - authProvider = undefined; - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(ConfigurationError, 'upsert requires auth provider; must be configured in client constructor'); - }); - - it('throws UnsafeUrlError if no valid KAS found for split', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas2' }]; - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(UnsafeUrlError, 'Unreconstructable key - no valid KAS found for split "split1"'); - }); - - it('throws ServiceError on rewrap failure with status >= 500', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.rejects({ response: { status: 500 } }); - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(ServiceError, 'rewrap failure'); - }); - - it('throws PermissionDeniedError on rewrap failure with status 403', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.rejects({ response: { status: 403 } }); - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(PermissionDeniedError, 'rewrap failure'); - }); - - it('throws UnauthenticatedError on rewrap failure with status 401', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.rejects({ response: { status: 401 } }); - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(UnauthenticatedError, 'rewrap auth failure'); - }); - - it('throws InvalidFileError on rewrap failure with status 400', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.rejects({ response: { status: 400 } }); - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(InvalidFileError, 'rewrap bad request; could indicate an invalid policy binding or a configuration error'); - }); - - it('throws NetworkError on rewrap request failure', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.rejects({ request: {} }); - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(NetworkError, 'rewrap request failure'); - }); - - it('throws DecryptError on rewrap failure with InvalidAccessError or OperationError', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.rejects({ name: 'InvalidAccessError' }); - await expect(TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService })) - .to.be.rejectedWith(DecryptError, 'unable to unwrap key from kas'); - }); - - it('returns reconstructedKeyBinary and metadata on successful unwrap', async () => { - manifest.encryptionInformation.keyAccess = [{ sid: 'split1', url: 'https://kas1' }]; - authProvider.withCreds.resolves({ url: 'https://kas1/rewrap', body: {}, headers: {} }); - axios.post.resolves({ data: { entityWrappedKey: 'wrappedKey', metadata: 'metadata' } }); - cryptoService.decryptWithPrivateKey.resolves(new Uint8Array([1, 2, 3])); - const result = await TDF.unwrapKey({ manifest, allowedKases, authProvider, dpopKeys, entity, cryptoService }); - expect(result.reconstructedKeyBinary).to.deep.equal(new Uint8Array([1, 2, 3])); - expect(result.metadata).to.equal('metadata'); - }); -}); From ee0a50b848405fc314c94c511b38be280302ffa0 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Tue, 12 Nov 2024 12:45:51 -0500 Subject: [PATCH 4/6] 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. --- lib/tests/mocha/encrypt-decrypt.spec.ts | 273 +++++++++++++++++++++++- lib/tests/server.ts | 41 +++- 2 files changed, 302 insertions(+), 12 deletions(-) diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index a2094aea..a853662f 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -2,21 +2,284 @@ 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 = { // eslint-disable-next-line @typescript-eslint/no-empty-function - updateClientPublicKey: async () => {}, + updateClientPublicKey: async () => { + }, 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..ba970f41 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' ); 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); From 1b37459a2d0ca95e73afcf22a4a97041c96e940b Mon Sep 17 00:00:00 2001 From: pflynn-virtru Date: Tue, 12 Nov 2024 17:46:58 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tests/mocha/encrypt-decrypt.spec.ts | 100 ++++++++++++++---------- lib/tests/server.ts | 6 +- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index a853662f..22c2cde9 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -19,12 +19,11 @@ const Mocks = getMocks(); const authProvider = { // eslint-disable-next-line @typescript-eslint/no-empty-function - updateClientPublicKey: async () => { - }, + updateClientPublicKey: async () => {}, withCreds: async (httpReq: HttpRequest) => httpReq, }; -describe('rewrap error cases', function() { +describe('rewrap error cases', function () { const kasUrl = 'http://localhost:3000'; const expectedVal = 'test data'; let client: Client.Client; @@ -32,11 +31,10 @@ describe('rewrap error cases', function() { let encryptionInformation: SplitKey; let key1: KeyInfo; - beforeEach(async function() { + beforeEach(async function () { // Setup base auth provider that will be modified per test const baseAuthProvider = { - updateClientPublicKey: async () => { - }, + updateClientPublicKey: async () => {}, withCreds: async (httpReq: HttpRequest) => httpReq, }; @@ -52,35 +50,43 @@ describe('rewrap error cases', function() { 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 + 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 }); @@ -112,7 +118,7 @@ describe('rewrap error cases', function() { }); } - it('should handle 401 Unauthorized error', async function() { + it('should handle 401 Unauthorized error', async function () { const authProvider = { updateClientPublicKey: async () => {}, withCreds: async (httpReq: HttpRequest) => ({ @@ -139,7 +145,7 @@ describe('rewrap error cases', function() { } }); - it('should handle 403 Forbidden error', async function() { + it('should handle 403 Forbidden error', async function () { const authProvider = { updateClientPublicKey: async () => {}, withCreds: async (httpReq: HttpRequest) => ({ @@ -166,13 +172,17 @@ describe('rewrap error cases', function() { } }); - it('should handle 400 Bad Request error', async function() { + 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' }, + headers: { + ...httpReq.headers, + 'x-test-response': '400', + 'x-test-response-message': 'IntegrityError', + }, }), }; @@ -194,7 +204,7 @@ describe('rewrap error cases', function() { } }); - it('should handle 500 Server error', async function() { + it('should handle 500 Server error', async function () { const authProvider = { updateClientPublicKey: async () => {}, withCreds: async (httpReq: HttpRequest) => ({ @@ -221,7 +231,7 @@ describe('rewrap error cases', function() { } }); - it('should handle network failures', async function() { + it('should handle network failures', async function () { try { // Point to a non-existent server client = new Client.Client({ @@ -251,13 +261,17 @@ describe('rewrap error cases', function() { } }); - it('should handle decrypt errors with invalid keys', async function() { + 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' }, + headers: { + ...httpReq.headers, + 'x-test-response': '400', + 'x-test-response-message': 'DecryptError', + }, }), }; diff --git a/lib/tests/server.ts b/lib/tests/server.ts index ba970f41..fde8b2a6 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -33,8 +33,8 @@ type RewrapBody = { policy: string; clientPublicKey: string; // testing only - invalidKey: string - invalidField: string + invalidKey: string; + invalidField: string; }; function concat(b: ArrayBufferView[]) { @@ -138,7 +138,7 @@ const kas: RequestListener = async (req, res) => { } console.log('[INFO]: rewrap request meta: ', req.method, req.url, req.headers); // NOTE: Real KAS will validate authorization and dpop here. simple Invalid check - if (req.headers['authorization'] == "Invalid") { + if (req.headers['authorization'] == 'Invalid') { res.writeHead(401); res.end(JSON.stringify({ error: 'Unauthorized' })); return; From 627bd942cd4dcdf70e6f072433d37d1c48181c72 Mon Sep 17 00:00:00 2001 From: Paul Flynn <43211074+pflynn-virtru@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:06:52 -0500 Subject: [PATCH 6/6] x-test-response-message --- lib/tests/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tests/server.ts b/lib/tests/server.ts index fde8b2a6..811c1565 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -67,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, x-test-response' + '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