From ae18fc2a4ea3254b713acae989fc08793b98ec31 Mon Sep 17 00:00:00 2001 From: David Mihalcik Date: Mon, 25 Nov 2024 11:36:19 -0500 Subject: [PATCH] chore!(sdk): remove v1 support - Removes axios and XHR support - all requests now use fetch - Removes `v1` path support for rewrap and public key - Removes `entityObject` - Removes AppIdAuthProvider - Removes `upsert` --- .github/workflows/build.yaml | 9 - .github/workflows/roundtrip/package.json | 2 +- .github/workflows/roundtrip/wait-and-test.sh | 10 +- lib/package-lock.json | 53 +-- lib/package.json | 2 - lib/src/access.ts | 116 +++--- lib/src/auth/Eas.ts | 79 ---- lib/src/auth/auth.ts | 32 +- lib/src/tdf/EntityObject.ts | 18 - lib/src/tdf/index.ts | 1 - lib/src/utils.ts | 10 +- lib/tdf3/index.ts | 9 +- .../src/client/DecoratedReadableStream.ts | 3 +- lib/tdf3/src/client/builders.ts | 4 +- lib/tdf3/src/client/index.ts | 93 +---- lib/tdf3/src/models/index.ts | 1 - lib/tdf3/src/models/upsert-response.ts | 17 - lib/tdf3/src/tdf.ts | 344 ++---------------- lib/tdf3/src/utils/chunkers.ts | 75 ++-- lib/tdf3/src/utils/index.ts | 10 +- lib/tests/mocha/encrypt-decrypt.spec.ts | 84 +---- lib/tests/mocha/unit/chunkers.spec.ts | 12 +- lib/tests/mocks/index.ts | 33 -- lib/tests/web/utils.test.ts | 5 +- 24 files changed, 186 insertions(+), 836 deletions(-) delete mode 100644 lib/src/auth/Eas.ts delete mode 100644 lib/src/tdf/EntityObject.ts delete mode 100644 lib/tdf3/src/models/upsert-response.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 519cc40e..3ee20c3e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -231,15 +231,6 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: echo "- [Client Library](https://github.com/opentdf/web-sdk/pkgs/npm/client)">>$GITHUB_STEP_SUMMARY - run: echo "- [Command Line Tool](https://github.com/opentdf/web-sdk/pkgs/npm/cli)">>$GITHUB_STEP_SUMMARY - - name: trigger xtest - run: >- - curl -XPOST -u "virtru-cloudnative:${{secrets.PERSONAL_ACCESS_TOKEN}}" - -H "Accept: application/vnd.github.everest-preview+json" - -H "Content-Type: application/json" - "https://api.github.com/repos/opentdf/backend/dispatches" - --data '{"event_type":"xtest","client_payload":{"version":"'${FULL_VERSION%%+*}'"}}' - env: - FULL_VERSION: ${{ steps.guess-build-metadata.outputs.FULL_VERSION }} - name: Publish documentation to gh-pages uses: JamesIves/github-pages-deploy-action@v4.6.0 with: diff --git a/.github/workflows/roundtrip/package.json b/.github/workflows/roundtrip/package.json index e51758ea..cd50baa7 100644 --- a/.github/workflows/roundtrip/package.json +++ b/.github/workflows/roundtrip/package.json @@ -1,7 +1,7 @@ { "name": "web-sdk-roundtrip", "version": "0.0.1", - "description": "Simple example to encrypt and decrypt files with quickstart backend.", + "description": "Simple example to encrypt and decrypt files.", "scripts": {}, "dependencies": { "@opentdf/ctl": "file:../../../cli/opentdf-ctl-0.1.0.tgz" diff --git a/.github/workflows/roundtrip/wait-and-test.sh b/.github/workflows/roundtrip/wait-and-test.sh index 3ee41c0d..b7da46ad 100755 --- a/.github/workflows/roundtrip/wait-and-test.sh +++ b/.github/workflows/roundtrip/wait-and-test.sh @@ -23,13 +23,9 @@ _configure_app() { return 0 } -if [ $1 = backend ]; then - VITE_PROXY='{"/api":{"target":"http://localhost:5432","xfwd":true},"/auth":{"target":"http://localhost:5432","xfwd":true}}' - VITE_TDF_CFG='{"oidc":{"host":"http://localhost:65432/auth/realms/tdf","clientId":"browsertest"},"kas":"http://localhost:65432/api/kas","reader":"https://secure.virtru.com/start?htmlProtocol=1"}' -else # if [ $1 = platform ]; then - VITE_PROXY='{"/kas":{"target":"http://localhost:8080","xfwd":true},"/auth":{"target":"http://localhost:8888","xfwd":true}}' - VITE_TDF_CFG='{"oidc":{"host":"http://localhost:65432/auth/realms/opentdf","clientId":"browsertest"},"kas":"http://localhost:65432/kas","reader":"https://secure.virtru.com/start?htmlProtocol=1"}' -fi +VITE_PROXY='{"/kas":{"target":"http://localhost:8080","xfwd":true},"/auth":{"target":"http://localhost:8888","xfwd":true}}' +VITE_TDF_CFG='{"oidc":{"host":"http://localhost:65432/auth/realms/opentdf","clientId":"browsertest"},"kas":"http://localhost:65432/kas","reader":"https://secure.virtru.com/start?htmlProtocol=1"}' + export VITE_PROXY export VITE_TDF_CFG diff --git a/lib/package-lock.json b/lib/package-lock.json index e879ad19..7d2b94de 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -9,8 +9,6 @@ "version": "0.1.0", "license": "BSD-3-Clause-Clear", "dependencies": { - "axios": "^1.6.1", - "axios-retry": "^3.9.0", "base64-js": "^1.5.1", "browser-fs-access": "^0.34.1", "buffer-crc32": "^0.2.13", @@ -307,16 +305,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.21.0", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -3095,6 +3083,7 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "dev": true, "license": "MIT" }, "node_modules/audit-ci": { @@ -3118,25 +3107,6 @@ "node": ">=12.9.0" } }, - "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios-retry": { - "version": "3.9.0", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.15.4", - "is-retry-allowed": "^2.2.0" - } - }, "node_modules/b4a": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -3855,6 +3825,7 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4235,6 +4206,7 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5210,6 +5182,7 @@ }, "node_modules/follow-redirects": { "version": "1.15.6", + "dev": true, "funding": [ { "type": "individual", @@ -5256,6 +5229,7 @@ }, "node_modules/form-data": { "version": "4.0.0", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -6127,16 +6101,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-stream": { "version": "2.0.1", "dev": true, @@ -7671,6 +7635,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7678,6 +7643,7 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8720,6 +8686,7 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", + "dev": true, "license": "MIT" }, "node_modules/pump": { @@ -8971,10 +8938,6 @@ "node": ">= 10.13.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "license": "MIT" - }, "node_modules/release-zalgo": { "version": "1.0.0", "dev": true, diff --git a/lib/package.json b/lib/package.json index d1659d0c..a1419610 100644 --- a/lib/package.json +++ b/lib/package.json @@ -67,8 +67,6 @@ "watch": "(trap 'kill 0' SIGINT; npm run build && (npm run build:watch & npm run test -- --watch))" }, "dependencies": { - "axios": "^1.6.1", - "axios-retry": "^3.9.0", "base64-js": "^1.5.1", "browser-fs-access": "^0.34.1", "buffer-crc32": "^0.2.13", diff --git a/lib/src/access.ts b/lib/src/access.ts index 3648839a..ecebccab 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -1,5 +1,6 @@ import { type AuthProvider } from './auth/auth.js'; import { + ConfigurationError, InvalidFileError, NetworkError, PermissionDeniedError, @@ -8,14 +9,16 @@ import { } from './errors.js'; import { pemToCryptoPublicKey, validateSecureUrl } from './utils.js'; -export class RewrapRequest { - signedRequestToken = ''; -} +export type RewrapRequest = { + signedRequestToken: string; +}; -export class RewrapResponse { - entityWrappedKey = ''; - sessionPublicKey = ''; -} +export type RewrapResponse = { + metadata: Record; + entityWrappedKey: string; + sessionPublicKey: string; + schemaVersion: string; +}; /** * Get a rewrapped access key to the document, if possible @@ -40,8 +43,10 @@ export async function fetchWrappedKey( body: JSON.stringify(requestBody), }); + let response: Response; + try { - const response = await fetch(req.url, { + response = await fetch(req.url, { method: req.method, mode: 'cors', // no-cors, *cors, same-origin cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached @@ -51,28 +56,33 @@ export async function fetchWrappedKey( referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url body: req.body as BodyInit, }); + } catch (e) { + throw new NetworkError(`unable to fetch wrapped key from [${url}]`, e); + } - if (!response.ok) { - switch (response.status) { - case 400: - throw new InvalidFileError( - `400 for [${req.url}]: rewrap failure [${await response.text()}]` - ); - case 401: - throw new UnauthenticatedError(`401 for [${req.url}]`); - case 403: - throw new PermissionDeniedError(`403 for [${req.url}]`); - default: - throw new NetworkError( - `${req.method} ${req.url} => ${response.status} ${response.statusText}` + if (!response.ok) { + switch (response.status) { + case 400: + throw new InvalidFileError( + `400 for [${req.url}]: rewrap bad request [${await response.text()}]` + ); + case 401: + throw new UnauthenticatedError(`401 for [${req.url}]; rewrap auth failure`); + case 403: + throw new PermissionDeniedError(`403 for [${req.url}]; rewrap permission denied`); + default: + if (response.status >= 500) { + throw new ServiceError( + `${response.status} for [${req.url}]: rewrap failure due to service error [${await response.text()}]` ); - } + } + throw new NetworkError( + `${req.method} ${req.url} => ${response.status} ${response.statusText}` + ); } - - return response.json(); - } catch (e) { - throw new NetworkError(`unable to fetch wrapped key from [${url}]: ${e}`); } + + return response.json(); } export type KasPublicKeyAlgorithm = 'ec:secp256r1' | 'rsa:2048'; @@ -100,7 +110,7 @@ export type KasPublicKeyInfo = { key: Promise; }; -async function noteInvalidPublicKey(url: string, r: Promise): Promise { +async function noteInvalidPublicKey(url: URL, r: Promise): Promise { try { return await r; } catch (e) { @@ -116,14 +126,36 @@ async function noteInvalidPublicKey(url: string, r: Promise): Promise * the value from `${kas}/kas_public_key`. */ export async function fetchECKasPubKey(kasEndpoint: string): Promise { + return fetchKasPubKey(kasEndpoint, 'ec:secp256r1'); +} + +export async function fetchKasPubKey( + kasEndpoint: string, + algorithm?: KasPublicKeyAlgorithm +): Promise { + if (!kasEndpoint) { + throw new ConfigurationError('KAS definition not found'); + } + // Logs insecure KAS. Secure is enforced in constructor validateSecureUrl(kasEndpoint); - const pkUrlV2 = `${kasEndpoint}/v2/kas_public_key?algorithm=ec:secp256r1&v=2`; - const kasPubKeyResponseV2 = await fetch(pkUrlV2); + const infoStatic = { url: kasEndpoint, algorithm: algorithm || 'rsa:2048' }; + + const pkUrlV2 = new URL('/v2/kas_public_key?v=2', kasEndpoint); + if (!pkUrlV2) { + throw new ConfigurationError(`KAS definition invalid: [${kasEndpoint}]`); + } + pkUrlV2.searchParams.set('algorithm', infoStatic.algorithm); + + let kasPubKeyResponseV2: Response; + try { + kasPubKeyResponseV2 = await fetch(pkUrlV2); + } catch (e) { + throw new NetworkError(`unable to fetch public key from [${pkUrlV2}]`, e); + } if (!kasPubKeyResponseV2.ok) { switch (kasPubKeyResponseV2.status) { case 404: - // v2 not implemented, perhaps a legacy server - break; + throw new ConfigurationError(`404 for [${pkUrlV2}]`); case 401: throw new UnauthenticatedError(`401 for [${pkUrlV2}]`); case 403: @@ -133,28 +165,6 @@ export async function fetchECKasPubKey(kasEndpoint: string): Promise ${kasPubKeyResponseV2.status} ${kasPubKeyResponseV2.statusText}` ); } - // most likely a server that does not implement v2 endpoint, so no key identifier - const pkUrlV1 = `${kasEndpoint}/kas_public_key?algorithm=ec:secp256r1`; - const r2 = await fetch(pkUrlV1); - if (!r2.ok) { - switch (r2.status) { - case 401: - throw new UnauthenticatedError(`401 for [${pkUrlV2}]`); - case 403: - throw new PermissionDeniedError(`403 for [${pkUrlV2}]`); - default: - throw new NetworkError( - `unable to load KAS public key from [${pkUrlV1}]. Received [${r2.status}:${r2.statusText}]` - ); - } - } - const pem = await r2.json(); - return { - key: noteInvalidPublicKey(pkUrlV1, pemToCryptoPublicKey(pem)), - publicKey: pem, - url: kasEndpoint, - algorithm: 'ec:secp256r1', - }; } const jsonContent = await kasPubKeyResponseV2.json(); const { publicKey, kid }: KasPublicKeyInfo = jsonContent; diff --git a/lib/src/auth/Eas.ts b/lib/src/auth/Eas.ts deleted file mode 100644 index 4c8245ec..00000000 --- a/lib/src/auth/Eas.ts +++ /dev/null @@ -1,79 +0,0 @@ -import axios, { type AxiosResponse, type RawAxiosRequestConfig } from 'axios'; - -import { AppIdAuthProvider, HttpRequest } from './auth.js'; - -const { request } = axios; - -// Required `any` below is to match type from axios library. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type RequestFunctor = >(config: RawAxiosRequestConfig) => Promise; - -/** - * Client for EAS interaction, specifically fetching entity object. - */ -class Eas { - authProvider: AppIdAuthProvider; - - endpoint: string; - - requestFunctor: RequestFunctor; - - /** - * Create an object for accessing an Entity Attribute Service. - * @param {object} config - options to configure this EAS accessor - * @param {AuthProvider|function} config.authProvider - interceptor for `http-request.Request` object manipulation - * @param {string} config.endpoint - the URI to connect to - * @param {function} [config.requestFunctor=request] - http request async function object - */ - constructor({ - authProvider, - endpoint, - requestFunctor, - }: { - authProvider: AppIdAuthProvider; - endpoint: string; - requestFunctor?: RequestFunctor; - }) { - this.authProvider = authProvider; - this.endpoint = endpoint; - this.requestFunctor = requestFunctor || request; - } - - /** - * Request an entity object for the current user. - * @param {object} config - options for the request - * @param {string} config.publicKey - String encoded public key from the keypair to be used with any subsequent requests refering to the returned EO - * @param {object} [config.etc] - additional parameters to be passed to the EAS entity-object endpoint - */ - async fetchEntityObject({ publicKey, ...etc }: { publicKey: string }) { - // Create a skeleton http request for EAS. - const incredibleHttpReq: HttpRequest = { - url: this.endpoint, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { publicKey, ...etc }, - }; - - // Delegate modifications to the auth provider. - // TODO: Handle various exception cases from interface docs. - const httpReq = await this.authProvider.withCreds(incredibleHttpReq); - - // Execute the http request using axios. - const axiosParams: RawAxiosRequestConfig = { - method: httpReq.method, - headers: httpReq.headers, - url: httpReq.url, - params: undefined, - data: undefined, - }; - // Allow the authProvider to change the method. - if (httpReq.method === 'POST' || httpReq.method === 'PATCH' || httpReq.method === 'PUT') { - axiosParams.data = httpReq.body; - } else { - axiosParams.params = httpReq.body; - } - return (await this.requestFunctor(axiosParams)).data; - } -} - -export default Eas; diff --git a/lib/src/auth/auth.ts b/lib/src/auth/auth.ts index 9bd50971..1022ccff 100644 --- a/lib/src/auth/auth.ts +++ b/lib/src/auth/auth.ts @@ -23,7 +23,7 @@ export class HttpRequest { url: string; - body?: BodyInit | null | unknown; + body?: BodyInit | null; constructor() { this.headers = {}; @@ -109,33 +109,3 @@ export function isAuthProvider(a?: unknown): a is AuthProvider { } return 'withCreds' in a; } - -/** - * An AuthProvider encapsulates all logic necessary to authenticate to a backend service, in the - * vein of AWS.Credentials. - *

- * The client will call into its configured AuthProvider to decorate remote TDF service calls with necessary - * authentication info. This approach allows the client to be agnostic to the auth scheme, allowing for - * methods like identify federation and custom service credentials to be used and changed at the developer's will. - *

- * This class is not intended to be used on its own. See the documented subclasses for public-facing implementations. - * - */ -export abstract class AppIdAuthProvider { - /** - * Augment the provided http request with custom auth info to be used by backend services. - * - * @param httpReq - Required. An http request pre-populated with the data public key. - */ - abstract withCreds(httpReq: HttpRequest): Promise; - - abstract _getName(): string; -} - -export default AppIdAuthProvider; diff --git a/lib/src/tdf/EntityObject.ts b/lib/src/tdf/EntityObject.ts deleted file mode 100644 index 582fac36..00000000 --- a/lib/src/tdf/EntityObject.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type AttributeObjectJwt from './AttributeObjectJwt.js'; - -/** - * Defined by the TDF3 spec and generated by an Entity Attribute Service, - * this object (when accompanied by a valid cert) defines what attributes - * a client has access to. - */ -export interface EntityObject { - readonly aliases: string[]; - readonly attributes: AttributeObjectJwt[]; - /** This should be present on validated EOs only - it is written by an EAS */ - readonly cert?: string; - readonly exp?: number; - readonly publicKey: string; - readonly userId: string; - /** The most recent version 1.1.0. */ - readonly schemaVersion?: string; -} diff --git a/lib/src/tdf/index.ts b/lib/src/tdf/index.ts index 373e61cc..02b4f25f 100644 --- a/lib/src/tdf/index.ts +++ b/lib/src/tdf/index.ts @@ -1,5 +1,4 @@ export { type AttributeObject, createAttribute } from './AttributeObject.js'; -export { type EntityObject } from './EntityObject.js'; export { type default as PolicyObject } from './PolicyObject.js'; export { type default as TypedArray } from './TypedArray.js'; export { default as Policy } from './Policy.js'; diff --git a/lib/src/utils.ts b/lib/src/utils.ts index d9513b09..512fc2fb 100644 --- a/lib/src/utils.ts +++ b/lib/src/utils.ts @@ -1,4 +1,3 @@ -import { type AxiosResponseHeaders, type RawAxiosResponseHeaders } from 'axios'; import { exportSPKI, importX509 } from 'jose'; import { base64 } from './encodings/index.js'; @@ -68,7 +67,7 @@ export const estimateSkew = async (serverEndpoint = window.origin): Promise { const localUnixTimeBefore = (dateNowBefore || Date.now()) / 1000; - let serverDateString; - if (headers.get) { - serverDateString = (headers as Headers).get('Date'); - } else { - serverDateString = (headers as AxiosResponseHeaders | RawAxiosResponseHeaders).date; - } + const serverDateString = headers.get('Date'); if (serverDateString === null) { throw Error('Cannot get access to Date header!'); } diff --git a/lib/tdf3/index.ts b/lib/tdf3/index.ts index bad39aa8..045783cf 100644 --- a/lib/tdf3/index.ts +++ b/lib/tdf3/index.ts @@ -25,13 +25,7 @@ import { SplitKey, type EncryptionInformation, } from './src/models/encryption-information.js'; -import { - AuthProvider, - AppIdAuthProvider, - type HttpMethod, - HttpRequest, - withHeaders, -} from '../src/auth/auth.js'; +import { AuthProvider, type HttpMethod, HttpRequest, withHeaders } from '../src/auth/auth.js'; import { AesGcmCipher } from './src/ciphers/aes-gcm-cipher.js'; import { NanoTDFClient, @@ -63,7 +57,6 @@ export type { export { AesGcmCipher, Algorithms, - AppIdAuthProvider, AuthProviders, Binary, Client, diff --git a/lib/tdf3/src/client/DecoratedReadableStream.ts b/lib/tdf3/src/client/DecoratedReadableStream.ts index 59567f21..0b3122f3 100644 --- a/lib/tdf3/src/client/DecoratedReadableStream.ts +++ b/lib/tdf3/src/client/DecoratedReadableStream.ts @@ -4,7 +4,7 @@ import { fileSave } from 'browser-fs-access'; import { isFirefox } from '../../../src/utils.js'; import { type Metadata } from '../tdf.js'; -import { type Manifest, type UpsertResponse } from '../models/index.js'; +import { type Manifest } from '../models/index.js'; import { ConfigurationError } from '../../../src/errors.js'; export async function streamToBuffer(stream: ReadableStream): Promise { @@ -29,7 +29,6 @@ export class DecoratedReadableStream { emit: EventEmitter['emit']; metadata?: Metadata; manifest: Manifest; - upsertResponse?: UpsertResponse; fileStreamServiceWorker?: string; constructor( diff --git a/lib/tdf3/src/client/builders.ts b/lib/tdf3/src/client/builders.ts index 37bb291b..deb494ba 100644 --- a/lib/tdf3/src/client/builders.ts +++ b/lib/tdf3/src/client/builders.ts @@ -5,7 +5,6 @@ import { Binary } from '../binary.js'; import { ConfigurationError } from '../../../src/errors.js'; import { PemKeyPair } from '../crypto/declarations.js'; -import { EntityObject } from '../../../src/tdf/EntityObject.js'; import { DecoratedReadableStream } from './DecoratedReadableStream.js'; import { type Chunker } from '../utils/chunkers.js'; import { AssertionConfig, AssertionVerificationKeys } from '../assertions.js'; @@ -41,12 +40,12 @@ export type EncryptParams = { scope?: Scope; metadata?: Metadata; keypair?: CryptoKeyPair; + // Deprecated: Only offline more is currently supported offline?: boolean; windowSize?: number; asHtml?: boolean; getPolicyId?: () => Scope['policyId']; mimeType?: string; - eo?: EntityObject; payloadKey?: Binary; keyMiddleware?: EncryptKeyMiddleware; splitPlan?: SplitStep[]; @@ -514,7 +513,6 @@ export type DecryptSource = | { type: 'file-browser'; location: Blob }; export type DecryptParams = { - eo?: EntityObject; source: DecryptSource; keyMiddleware?: DecryptKeyMiddleware; streamMiddleware?: DecryptStreamMiddleware; diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 176e699d..f7294491 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -1,11 +1,9 @@ import { v4 } from 'uuid'; -import axios from 'axios'; import { ZipReader, fromBuffer, fromDataSource, streamToBuffer, - isAppIdProviderCheck, type Chunker, keyMiddleware as defaultKeyMiddleware, } from '../utils/index.js'; @@ -24,19 +22,8 @@ import { import { OIDCRefreshTokenProvider } from '../../../src/auth/oidc-refreshtoken-provider.js'; import { OIDCExternalJwtProvider } from '../../../src/auth/oidc-externaljwt-provider.js'; import { CryptoService } from '../crypto/declarations.js'; -import { - type AuthProvider, - AppIdAuthProvider, - HttpRequest, - withHeaders, -} from '../../../src/auth/auth.js'; -import EAS from '../../../src/auth/Eas.js'; -import { - cryptoPublicToPem, - pemToCryptoPublicKey, - rstrip, - validateSecureUrl, -} from '../../../src/utils.js'; +import { type AuthProvider, HttpRequest, withHeaders } from '../../../src/auth/auth.js'; +import { pemToCryptoPublicKey, rstrip, validateSecureUrl } from '../../../src/utils.js'; import { EncryptParams, @@ -57,12 +44,11 @@ import { } from './builders.js'; import { KasPublicKeyInfo, OriginAllowList } from '../../../src/access.js'; import { ConfigurationError } from '../../../src/errors.js'; -import { EntityObject } from '../../../src/tdf/EntityObject.js'; import { Binary } from '../binary.js'; import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js'; import { toCryptoKeyPair } from '../crypto/crypto-utils.js'; import * as defaultCryptoService from '../crypto/index.js'; -import { type AttributeObject, AttributeSet, type Policy, SplitKey } from '../models/index.js'; +import { type AttributeObject, type Policy, SplitKey } from '../models/index.js'; import { plan } from '../../../src/policy/granter.js'; import { attributeFQNsAsValues } from '../../../src/policy/api.js'; import { type Value } from '../../../src/policy/attributes.js'; @@ -73,28 +59,6 @@ const HTML_BYTE_LIMIT = 100 * 1000 * 1000; // 100 MB, see WS-9476. // No default config for now. Delegate to Virtru wrapper for endpoints. const defaultClientConfig = { oidcOrigin: '', cryptoService: defaultCryptoService }; -export const uploadBinaryToS3 = async function ( - stream: ReadableStream, - uploadUrl: string, - fileSize: number -) { - try { - const body: Uint8Array = await streamToBuffer(stream); - - await axios.put(uploadUrl, body, { - headers: { - 'Content-Length': fileSize, - 'content-type': 'application/zip', - 'cache-control': 'no-store', - }, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }); - } catch (e) { - console.error(e); - throw e; - } -}; const getFirstTwoBytes = async (chunker: Chunker) => new TextDecoder().decode(await chunker(0, 2)); const makeChunkable = async (source: DecryptSource) => { @@ -160,7 +124,7 @@ export interface ClientConfig { kasPublicKey?: string; oidcOrigin?: string; externalJwt?: string; - authProvider?: AuthProvider | AppIdAuthProvider; + authProvider?: AuthProvider; readerUrl?: string; entityObjectEndpoint?: string; fileStreamServiceWorker?: string; @@ -179,7 +143,7 @@ export async function createSessionKeys({ cryptoService, dpopKeys, }: { - authProvider?: AuthProvider | AppIdAuthProvider; + authProvider?: AuthProvider; cryptoService: CryptoService; dpopKeys?: Promise; }): Promise { @@ -197,7 +161,7 @@ export async function createSessionKeys({ // Note that we base64 encode the PEM string here as a quick workaround, simply because // a formatted raw PEM string isn't a valid header value and sending it raw makes keycloak's // header parser barf. There are more subtle ways to solve this, but this works for now. - if (authProvider && !isAppIdProviderCheck(authProvider)) { + if (authProvider) { await authProvider?.updateClientPublicKey(signingKeys); } return signingKeys; @@ -259,7 +223,7 @@ export class Client { readonly clientId?: string; - readonly authProvider?: AuthProvider | AppIdAuthProvider; + readonly authProvider?: AuthProvider; readonly readerUrl?: string; @@ -270,8 +234,6 @@ export class Client { */ readonly dpopKeys: Promise; - readonly eas?: EAS; - readonly dpopEnabled: boolean; readonly clientConfig: ClientConfig; @@ -330,14 +292,6 @@ export class Client { this.authProvider = config.authProvider; this.clientConfig = clientConfig; - if (this.authProvider && isAppIdProviderCheck(this.authProvider)) { - this.eas = new EAS({ - authProvider: this.authProvider, - endpoint: - clientConfig.entityObjectEndpoint ?? `${clientConfig.easEndpoint}/api/entityobject`, - }); - } - this.clientId = clientConfig.clientId; if (!this.authProvider) { if (!clientConfig.clientId) { @@ -388,7 +342,6 @@ export class Client { * @param [metadata] Additional non-secret data to store with the TDF * @param [opts] Test only * @param [mimeType] mime type of source. defaults to `unknown` - * @param [offline] Where to store the policy. Defaults to `false` - which results in `upsert` events to store/update a policy * @param [windowSize] - segment size in bytes. Defaults to a a million bytes. * @param [keyMiddleware] - function that handle keys * @param [streamMiddleware] - function that handle stream @@ -402,14 +355,16 @@ export class Client { asHtml = false, metadata, mimeType, - offline = false, + offline = true, windowSize = DEFAULT_SEGMENT_SIZE, - eo, keyMiddleware = defaultKeyMiddleware, streamMiddleware = async (stream: DecoratedReadableStream) => stream, splitPlan, assertionConfigs = [], }: EncryptParams): Promise { + if (!offline) { + throw new ConfigurationError('online mode not supported'); + } const dpopKeys = await this.dpopKeys; const policyObject = asPolicy(scope); @@ -475,15 +430,6 @@ export class Client { const byteLimit = asHtml ? HTML_BYTE_LIMIT : GLOBAL_BYTE_LIMIT; const encryptionInformation = new SplitKey(new AesGcmCipher(this.cryptoService)); - let attributeSet: undefined | AttributeSet; - let entity: undefined | EntityObject; - if (eo) { - entity = eo; - const s = new AttributeSet(); - eo.attributes.forEach((attr) => s.addJwtAttribute(attr)); - attributeSet = s; - } - const splits: SplitStep[] = splitPlan?.length ? splitPlan : [{ kas: this.kasEndpoint }]; encryptionInformation.keyAccess = await Promise.all( splits.map(async ({ kas, sid }) => { @@ -492,7 +438,6 @@ export class Client { } const kasPublicKey = await this.kasKeys[kas]; return buildKeyAccess({ - attributeSet, type: offline ? 'wrapped' : 'remote', url: kasPublicKey.url, kid: kasPublicKey.kid, @@ -505,12 +450,10 @@ export class Client { const { keyForEncryption, keyForManifest } = await (keyMiddleware as EncryptKeyMiddleware)(); const ecfg: EncryptConfiguration = { allowList: this.allowedKases, - attributeSet, byteLimit, cryptoService: this.cryptoService, dpopKeys, encryptionInformation, - entity, segmentSizeDefault: windowSize, integrityAlgorithm: 'HS256', segmentIntegrityAlgorithm: 'GMAC', @@ -556,7 +499,6 @@ export class Client { * @see DecryptParamsBuilder */ async decrypt({ - eo, source, keyMiddleware = async (key: Binary) => key, streamMiddleware = async (stream: DecoratedReadableStream) => stream, @@ -565,17 +507,6 @@ export class Client { concurrencyLimit = 1, }: DecryptParams): Promise { const dpopKeys = await this.dpopKeys; - let entityObject; - if (this.eas || eo) { - const sessionPublicKey = await cryptoPublicToPem(dpopKeys.publicKey); - if (eo && eo.publicKey == sessionPublicKey) { - entityObject = eo; - } else if (this.eas) { - entityObject = await this.eas.fetchEntityObject({ - publicKey: sessionPublicKey, - }); - } - } if (!this.authProvider) { throw new ConfigurationError('AuthProvider missing'); } @@ -591,7 +522,6 @@ export class Client { concurrencyLimit, cryptoService: this.cryptoService, dpopKeys, - entity: entityObject, fileStreamServiceWorker: this.clientConfig.fileStreamServiceWorker, keyMiddleware, progressHandler: this.clientConfig.progressHandler, @@ -629,7 +559,6 @@ export class Client { export type { AuthProvider }; export { - AppIdAuthProvider, DecryptParamsBuilder, DecryptSource, EncryptParamsBuilder, diff --git a/lib/tdf3/src/models/index.ts b/lib/tdf3/src/models/index.ts index b7a9d0c0..c1818f55 100644 --- a/lib/tdf3/src/models/index.ts +++ b/lib/tdf3/src/models/index.ts @@ -4,5 +4,4 @@ export * from './key-access.js'; export * from './manifest.js'; export * from './payload.js'; export * from './policy.js'; -export * from './upsert-response.js'; export * from '../assertions.js'; diff --git a/lib/tdf3/src/models/upsert-response.ts b/lib/tdf3/src/models/upsert-response.ts deleted file mode 100644 index 6fa044e0..00000000 --- a/lib/tdf3/src/models/upsert-response.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type ArtifactFinder = { - upload?: string; - // Download URL for the payload. This can be a direct link to the file (S3), or a proxy URL. - download: string; - key?: string; - bucket?: string; -}; - -export type UpsertResponse = { - uuid: string; - storageLinks: { - payload: ArtifactFinder & { - proxy?: boolean | string; - }; - metadata?: ArtifactFinder; - }; -}[][]; diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 56f0c250..986cafbd 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -1,23 +1,18 @@ -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/index.js'; -import { pemToCryptoPublicKey, validateSecureUrl } from '../../src/utils.js'; +import { fetchKasPubKey as fetchKasPubKeyV2, fetchWrappedKey } from '../../src/access.js'; import { DecryptParams } from './client/builders.js'; import { AssertionConfig, AssertionKey, AssertionVerificationKeys } from './assertions.js'; import * as assertions from './assertions.js'; import { - AttributeSet, - isRemote as isRemoteKeyAccess, KeyAccessType, KeyInfo, Manifest, Policy, Remote as KeyAccessRemote, SplitKey, - UpsertResponse, Wrapped as KeyAccessWrapped, KeyAccess, KeyAccessObject, @@ -29,7 +24,6 @@ import { ZipReader, ZipWriter, base64ToBuffer, - isAppIdProviderCheck, keyMerge, buffToString, concatUint8, @@ -42,10 +36,6 @@ import { InvalidFileError, IntegrityError, NetworkError, - PermissionDeniedError, - ServiceError, - TdfError, - UnauthenticatedError, UnsafeUrlError, UnsupportedFeatureError as UnsupportedError, } from '../../src/errors.js'; @@ -54,13 +44,7 @@ import { htmlWrapperTemplate } from './templates/index.js'; // configurable // TODO: remove dependencies from ciphers so that we can open-source instead of relying on other Virtru libs import { AesGcmCipher } from './ciphers/index.js'; -import { - type AuthProvider, - AppIdAuthProvider, - HttpRequest, - type HttpMethod, - reqSignature, -} from '../../src/auth/auth.js'; +import { type AuthProvider, reqSignature } from '../../src/auth/auth.js'; import PolicyObject from '../../src/tdf/PolicyObject.js'; import { type CryptoService, type DecryptResult } from './crypto/declarations.js'; import { CentralDirectory } from './utils/zip-reader.js'; @@ -92,12 +76,10 @@ export type Metadata = { }; export type BuildKeyAccess = { - attributeSet?: AttributeSet; type: KeyAccessType; url?: string; kid?: string; publicKey: string; - attributeUrl?: string; metadata?: Metadata; sid?: string; }; @@ -139,9 +121,7 @@ export type EncryptConfiguration = { contentStream: ReadableStream; mimeType?: string; policy: Policy; - entity?: EntityObject; - attributeSet?: AttributeSet; - authProvider?: AuthProvider | AppIdAuthProvider; + authProvider?: AuthProvider; byteLimit: number; progressHandler?: (bytesProcessed: number) => void; keyForEncryption: KeyInfo; @@ -152,9 +132,8 @@ export type EncryptConfiguration = { export type DecryptConfiguration = { allowedKases?: string[]; allowList?: OriginAllowList; - authProvider: AuthProvider | AppIdAuthProvider; + authProvider: AuthProvider; cryptoService: CryptoService; - entity?: EntityObject; dpopKeys: CryptoKeyPair; @@ -170,8 +149,7 @@ export type DecryptConfiguration = { export type UpsertConfiguration = { allowedKases?: string[]; allowList?: OriginAllowList; - authProvider: AuthProvider | AppIdAuthProvider; - entity?: EntityObject; + authProvider: AuthProvider; privateKey: CryptoKey; @@ -186,12 +164,6 @@ export type RewrapRequest = { export type KasPublicKeyFormat = 'pkcs8' | 'jwks'; -type KasPublicKeyParams = { - algorithm?: KasPublicKeyAlgorithm; - fmt?: KasPublicKeyFormat; - v?: '1' | '2'; -}; - export type RewrapResponse = { entityWrappedKey: string; sessionPublicKey: string; @@ -205,96 +177,9 @@ export async function fetchKasPublicKey( kas: string, algorithm?: KasPublicKeyAlgorithm ): Promise { - if (!kas) { - throw new ConfigurationError('KAS definition not found'); - } - // Logs insecure KAS. Secure is enforced in constructor - validateSecureUrl(kas); - const infoStatic = { url: kas, algorithm: algorithm || 'rsa:2048' }; - const params: KasPublicKeyParams = {}; - if (algorithm) { - params.algorithm = algorithm; - } - const v2Url = `${kas}/v2/kas_public_key`; - try { - const response: { data: string | KasPublicKeyInfo } = await axios.get(v2Url, { - params: { - ...params, - v: '2', - }, - }); - const publicKey = - typeof response.data === 'string' - ? await extractPemFromKeyString(response.data) - : response.data.publicKey; - return { - publicKey, - key: pemToCryptoPublicKey(publicKey), - ...infoStatic, - ...(typeof response.data !== 'string' && response.data.kid && { kid: response.data.kid }), - }; - } catch (cause) { - const status = cause?.response?.status; - switch (status) { - case 400: - case 404: - // KAS does not yet implement v2, maybe - break; - case 401: - throw new UnauthenticatedError(`[${v2Url}] requires auth`, cause); - case 403: - throw new PermissionDeniedError(`[${v2Url}] permission denied`, cause); - default: - if (status && status >= 400 && status < 500) { - throw new ConfigurationError( - `[${v2Url}] request error [${status}] [${cause.name}] [${cause.message}]`, - cause - ); - } - throw new NetworkError( - `[${v2Url}] error [${status}] [${cause.name}] [${cause.message}]`, - cause - ); - } - } - // Retry with v1 params - const v1Url = `${kas}/kas_public_key`; - try { - const response: { data: string | KasPublicKeyInfo } = await axios.get(v1Url, { - params, - }); - const publicKey = - typeof response.data === 'string' - ? await extractPemFromKeyString(response.data) - : response.data.publicKey; - // future proof: allow v2 response even if not specified. - return { - publicKey, - key: pemToCryptoPublicKey(publicKey), - ...infoStatic, - ...(typeof response.data !== 'string' && response.data.kid && { kid: response.data.kid }), - }; - } catch (cause) { - const status = cause?.response?.status; - switch (status) { - case 401: - throw new UnauthenticatedError(`[${v1Url}] requires auth`, cause); - case 403: - throw new PermissionDeniedError(`[${v1Url}] permission denied`, cause); - default: - if (status && status >= 400 && status < 500) { - throw new ConfigurationError( - `[${v2Url}] request error [${status}] [${cause.name}] [${cause.message}]`, - cause - ); - } - throw new NetworkError( - `[${v1Url}] error [${status}] [${cause.name}] [${cause.message}]`, - cause - ); - } - } + return fetchKasPubKeyV2(kas, algorithm || 'rsa:2048'); } + /** * * @param payload The TDF content to encode in HTML @@ -360,7 +245,6 @@ export async function extractPemFromKeyString(keyString: string): Promise { @@ -395,27 +277,11 @@ export async function buildKeyAccess({ } } - // If an attributeUrl is provided try to load with that first. - if (attributeUrl && attributeSet) { - const attr = attributeSet.get(attributeUrl); - if (attr && attr.kasUrl && attr.pubKey) { - return createKeyAccess(type, attr.kasUrl, attr.kid, attr.pubKey, metadata); - } - } - // if url and pulicKey are specified load the key access object with them if (url && publicKey) { return createKeyAccess(type, url, kid, await extractPemFromKeyString(publicKey), metadata); } - // Assume the default attribute is the source for kasUrl and pubKey - const defaultAttr = attributeSet?.getDefault(); - if (defaultAttr) { - const { pubKey, kasUrl } = defaultAttr; - if (pubKey && kasUrl) { - return createKeyAccess(type, kasUrl, kid, await extractPemFromKeyString(pubKey), metadata); - } - } // All failed. Raise an error. throw new ConfigurationError('TDF.buildKeyAccess: No source for kasUrl or pubKey'); } @@ -481,113 +347,6 @@ async function getSignature( } } -function buildRequest(method: HttpMethod, url: string, body?: unknown): HttpRequest { - return { - headers: {}, - method: method, - url: url, - body, - }; -} - -export async function upsert({ - allowedKases, - allowList, - authProvider, - entity, - privateKey, - unsavedManifest, - ignoreType, -}: UpsertConfiguration): Promise { - const allowed = (() => { - if (allowList) { - return allowList; - } - if (!allowedKases) { - throw new ConfigurationError('Upsert cannot be done without allowlist'); - } - return new OriginAllowList(allowedKases); - })(); - const { keyAccess, policy } = unsavedManifest.encryptionInformation; - const isAppIdProvider = authProvider && isAppIdProviderCheck(authProvider); - if (authProvider === undefined) { - throw new ConfigurationError('Upsert cannot be done without auth provider'); - } - return Promise.all( - keyAccess.map(async (keyAccessObject) => { - // We only care about remote key access objects for the policy sync portion - const isRemote = isRemoteKeyAccess(keyAccessObject); - if (!ignoreType && !isRemote) { - return; - } - - if (!allowed.allows(keyAccessObject.url)) { - throw new UnsafeUrlError(`Unexpected KAS url: [${keyAccessObject.url}]`); - } - - const url = `${keyAccessObject.url}/${isAppIdProvider ? '' : 'v2/'}upsert`; - - //TODO I dont' think we need a body at all for KAS requests - // Do we need ANY of this if it's already embedded in the EO in the Bearer OIDC token? - const body: Record = { - keyAccess: keyAccessObject, - policy: unsavedManifest.encryptionInformation.policy, - entity: isAppIdProviderCheck(authProvider) ? entity : undefined, - authToken: undefined, - clientPayloadSignature: undefined, - }; - - if (isAppIdProviderCheck(authProvider)) { - body.authToken = await reqSignature({}, privateKey); - } else { - body.clientPayloadSignature = await reqSignature(body, privateKey); - } - const httpReq = await authProvider.withCreds(buildRequest('POST', url, body)); - - try { - const response = await axios.post(httpReq.url, httpReq.body, { - headers: httpReq.headers, - }); - - // Remove additional properties which were needed to sync, but not that we want to save to - // the manifest - delete keyAccessObject.wrappedKey; - delete keyAccessObject.encryptedMetadata; - delete keyAccessObject.policyBinding; - - if (isRemote) { - // Decode the policy and extract only the required info to save -- the uuid - const decodedPolicy = JSON.parse(base64.decode(policy)); - unsavedManifest.encryptionInformation.policy = base64.encode( - JSON.stringify({ uuid: decodedPolicy.uuid }) - ); - } - return response.data; - } catch (e) { - if (e.response) { - if (e.response.status >= 500) { - throw new ServiceError('upsert failure', e); - } else if (e.response.status === 403) { - throw new PermissionDeniedError('upsert failure', e); - } else if (e.response.status === 401) { - throw new UnauthenticatedError('upsert auth failure', e); - } else if (e.response.status === 400) { - throw new ConfigurationError('upsert bad request; likely a configuration error', e); - } else { - throw new NetworkError('upsert server error', e); - } - } else if (e.request) { - throw new NetworkError('upsert request failure', e); - } - throw new TdfError( - `Unable to perform upsert operation on the KAS: [${e.name}: ${e.message}], response: [${e?.response?.body}]`, - e - ); - } - }) - ); -} - export async function writeStream(cfg: EncryptConfiguration): Promise { if (!cfg.authProvider) { throw new ConfigurationError('No authorization middleware defined'); @@ -630,17 +389,6 @@ export async function writeStream(cfg: EncryptConfiguration): Promise { - const url = `${keySplitInfo.url}/${isAppIdProvider ? '' : 'v2/'}rewrap`; + const url = `${keySplitInfo.url}/v2/rewrap`; const ephemeralEncryptionKeys = await cryptoService.cryptoToPemPair( await cryptoService.generateKeyPair() ); @@ -952,32 +691,14 @@ async function unwrapKey({ }); const jwtPayload = { requestBody: requestBodyStr }; - const signedRequestToken = await reqSignature( - isAppIdProvider ? {} : jwtPayload, - dpopKeys.privateKey - ); + const signedRequestToken = await reqSignature(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 { entityWrappedKey, metadata } = await fetchWrappedKey( + url, + { signedRequestToken }, + authProvider, + '0.0.1' + ); const key = Binary.fromString(base64.decode(entityWrappedKey)); const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( @@ -1010,7 +731,7 @@ async function unwrapKey({ try { return await tryKasRewrap(keySplitInfo); } catch (e) { - throw handleRewrapError(e as Error | AxiosError); + throw handleRewrapError(e as Error); } }; } @@ -1035,31 +756,11 @@ async function unwrapKey({ } } -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 - ); +function handleRewrapError(error: Error) { + if (error.name === 'InvalidAccessError' || error.name === 'OperationError') { + return new DecryptError('unable to unwrap key from kas', error); } + return error; } async function decryptChunk( @@ -1211,7 +912,6 @@ export async function readStream(cfg: DecryptConfiguration) { authProvider: cfg.authProvider, allowedKases: allowList, dpopKeys: cfg.dpopKeys, - entity: cfg.entity, cryptoService: cfg.cryptoService, }); // async function unwrapKey(manifest: Manifest, allowedKases: string[], authProvider: AuthProvider | AppIdAuthProvider, publicKey: string, privateKey: string, entity: EntityObject) { diff --git a/lib/tdf3/src/utils/chunkers.ts b/lib/tdf3/src/utils/chunkers.ts index c4980661..01adfa39 100644 --- a/lib/tdf3/src/utils/chunkers.ts +++ b/lib/tdf3/src/utils/chunkers.ts @@ -1,12 +1,8 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { type DecoratedReadableStream, isDecoratedReadableStream, } from '../client/DecoratedReadableStream.js'; -import axiosRetry from 'axios-retry'; -import { ConfigurationError, NetworkError } from '../../../src/errors.js'; - -let axiosRemoteChunk: AxiosInstance | null = null; +import { ConfigurationError, InvalidFileError, NetworkError } from '../../../src/errors.js'; /** * Read data from a seekable stream. @@ -30,36 +26,50 @@ export const fromBuffer = (source: Uint8Array | Buffer): Chunker => { }; async function getRemoteChunk(url: string, range?: string): Promise { - if (!axiosRemoteChunk) { - axiosRemoteChunk = axios.create(); - // @ts-ignore: axiosRetry not typed - axiosRetry(axiosRemoteChunk, { - retries: 3, - retryDelay: axiosRetry.exponentialDelay, - retryCondition: () => true, - }); // Retries all idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE) - } - try { - const res: AxiosResponse = await axiosRemoteChunk.get(url, { - ...(range && { - headers: { - Range: `bytes=${range}`, - }, - }), - responseType: 'arraybuffer', - }); - if (!res.data) { + // loop with fetch for three times, with an exponential backoff + // if the fetch fails with a network error + // this is to handle transient network errors + const errors: Error[] = []; + for (let i = 0; i < 3; i++) { + let res: Response; + try { + res = await fetch(url, { + redirect: 'follow', // manual, *follow, error + ...(range && { + headers: { + Range: `bytes=${range}`, + }, + }), + }); + } catch (e) { + console.warn(`fetch failed with network error [${e}], retrying...`); + sleep(2 ** i * 1000); + continue; + } + if (!res.ok) { + if (res.status === 416) { + throw new InvalidFileError( + `${res.status}: range not satisfiable: requested [${range}] from [${url}]; response [${res.statusText}]` + ); + } else if (res.status === 404) { + throw new InvalidFileError( + `${res.status}: [${url}] not found; response: [${res.statusText}]` + ); + } + console.warn(`fetch failed with status [${res.status}: ${res.statusText}], retrying...`); + // waits for 1, 2, 4 seconds + sleep(2 ** i * 1000); + continue; + } + const data = await res.arrayBuffer(); + if (!data) { throw new NetworkError( - 'Unexpected response type: Server should have responded with an ArrayBuffer.' + `empty response for range request: requested [${range}] from [${url}]` ); } - return new Uint8Array(res.data); - } catch (e) { - if (e && e.response && e.response.status === 416) { - console.log('Warning: Range not satisfiable'); - } - throw e; + return new Uint8Array(data); } + throw new AggregateError(errors, 'fetch failed after 3 retries'); } export const fromUrl = async (location: string): Promise => { @@ -116,3 +126,6 @@ export const fromDataSource = async ({ type, location }: DataSource) => { throw new ConfigurationError(`Data source type not defined, or not supported: ${type}}`); } }; +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/lib/tdf3/src/utils/index.ts b/lib/tdf3/src/utils/index.ts index b8bf7494..c6f7d008 100644 --- a/lib/tdf3/src/utils/index.ts +++ b/lib/tdf3/src/utils/index.ts @@ -1,5 +1,4 @@ import { toByteArray, fromByteArray } from 'base64-js'; -import { AppIdAuthProvider, type AuthProvider } from '../../../src/auth/auth.js'; import * as WebCryptoService from '../crypto/index.js'; import { KeyInfo, SplitKey } from '../models/index.js'; @@ -30,11 +29,6 @@ export function base64ToBuffer(b64: string): Uint8Array { return Uint8Array.from(atob(b64).split(''), (c) => c.charCodeAt(0)); } -export function isAppIdProviderCheck( - provider: AuthProvider | AppIdAuthProvider -): provider is AppIdAuthProvider { - return (provider as AppIdAuthProvider)._getName !== undefined; -} export function concatUint8(uint8Arrays: Uint8Array[]): Uint8Array { const newLength = uint8Arrays.reduce( (accumulator, currentValue) => accumulator + currentValue.length, @@ -252,14 +246,14 @@ const MAX_ARGUMENTS_LENGTH = 0x1000; function decodeCodePointsArray(codePoints: number[]): string { const len = codePoints.length; if (len <= MAX_ARGUMENTS_LENGTH) { - return String.fromCharCode.apply(String, codePoints); // avoid extra slice() + return String.fromCharCode(...codePoints); // avoid extra slice() } // Decode in chunks to avoid "call stack size exceeded". let res = ''; let i = 0; while (i < len) { - res += String.fromCharCode.apply(String, codePoints.slice(i, (i += MAX_ARGUMENTS_LENGTH))); + res += String.fromCharCode(...codePoints.slice(i, (i += MAX_ARGUMENTS_LENGTH))); } return res; } diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 22c2cde9..4ff48033 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -1,8 +1,8 @@ // Simplest HTTP server that supports RANGE headers AFAIK. -import { assert } from 'chai'; +import { assert, expect } from 'chai'; import { getMocks } from '../mocks/index.js'; -import { HttpMethod, HttpRequest } from '../../src/auth/auth.js'; +import { AuthProvider, HttpRequest } from '../../src/auth/auth.js'; import { AesGcmCipher, KeyInfo, SplitKey, WebCryptoService } from '../../tdf3/index.js'; import { Client } from '../../tdf3/src/index.js'; import { AssertionConfig, AssertionVerificationKeys } from '../../tdf3/src/assertions.js'; @@ -50,44 +50,7 @@ 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?: AuthProvider }) { const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); if (customAuthProvider) { @@ -99,9 +62,7 @@ describe('rewrap error cases', function () { }); } - const eo = await Mocks.getEntityObject(); return client.encrypt({ - eo, metadata: Mocks.getMetadataObject(), offline: true, scope: { @@ -129,10 +90,8 @@ describe('rewrap error cases', function () { const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); - const eo = await Mocks.getEntityObject(); try { await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, @@ -156,10 +115,8 @@ describe('rewrap error cases', function () { const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); - const eo = await Mocks.getEntityObject(); try { await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, @@ -168,7 +125,7 @@ describe('rewrap error cases', function () { assert.fail('Expected PermissionDeniedError'); } catch (error) { assert.instanceOf(error, PermissionDeniedError); - assert.include(error.message, 'rewrap failure'); + assert.include(error.message, 'rewrap permission denied'); } }); @@ -188,10 +145,8 @@ describe('rewrap error cases', function () { const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); - const eo = await Mocks.getEntityObject(); try { await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, @@ -215,10 +170,8 @@ describe('rewrap error cases', function () { const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); - const eo = await Mocks.getEntityObject(); try { await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, @@ -226,8 +179,9 @@ describe('rewrap error cases', function () { }); assert.fail('Expected ServiceError'); } catch (error) { - assert.instanceOf(error, ServiceError); - assert.include(error.message, 'rewrap failure'); + expect(() => { + throw error; + }).to.throw(ServiceError, 'rewrap failure'); } }); @@ -246,10 +200,7 @@ describe('rewrap error cases', function () { const encryptedStream = await encryptTestData({}); - const eo = await Mocks.getEntityObject(); - await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, @@ -257,16 +208,18 @@ describe('rewrap error cases', function () { }); assert.fail('Expected NetworkError'); } catch (error) { - assert.instanceOf(error, NetworkError); + expect(() => { + throw error; + }).to.throw(NetworkError); } }); it('should handle decrypt errors with invalid keys', async function () { - const authProvider = { + const authProvider: AuthProvider = { updateClientPublicKey: async () => {}, withCreds: async (httpReq: HttpRequest) => ({ ...httpReq, - body: { invalidKey: true }, + body: new URLSearchParams({ invalidKey: 'true' }), headers: { ...httpReq.headers, 'x-test-response': '400', @@ -277,10 +230,8 @@ describe('rewrap error cases', function () { const encryptedStream = await encryptTestData({ customAuthProvider: authProvider }); - const eo = await Mocks.getEntityObject(); try { await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, @@ -288,8 +239,9 @@ describe('rewrap error cases', function () { }); assert.fail('Expected InvalidFileError'); } catch (error) { - assert.instanceOf(error, InvalidFileError); - assert.include(error.message, 'rewrap bad request'); + expect(() => { + throw error; + }).to.throw(InvalidFileError, 'rewrap bad request'); } }); }); @@ -330,8 +282,6 @@ describe('encrypt decrypt test', async function () { ['sign', 'verify'] ); const publicKey = keyPair.publicKey; - console.log('publicKey', publicKey); - const eo = await Mocks.getEntityObject(); const scope: Scope = { dissem: ['user@domain.com'], attributes: [], @@ -342,7 +292,6 @@ describe('encrypt decrypt test', async function () { crypto.getRandomValues(hs256Key); const encryptedStream = await client.encrypt({ - eo, metadata: Mocks.getMetadataObject(), offline: true, scope, @@ -399,8 +348,6 @@ describe('encrypt decrypt test', async function () { ] as AssertionConfig[], }); - console.log('encryptedStream', encryptedStream); - // Create AssertionVerificationKeys for verification const assertionVerificationKeys: AssertionVerificationKeys = { Keys: { @@ -416,7 +363,6 @@ describe('encrypt decrypt test', async function () { }; const decryptStream = await client.decrypt({ - eo, source: { type: 'stream', location: encryptedStream.stream, diff --git a/lib/tests/mocha/unit/chunkers.spec.ts b/lib/tests/mocha/unit/chunkers.spec.ts index eff139d7..97f5b654 100644 --- a/lib/tests/mocha/unit/chunkers.spec.ts +++ b/lib/tests/mocha/unit/chunkers.spec.ts @@ -105,7 +105,9 @@ describe('chunkers', () => { )(12, 5); expect.fail(); } catch (e) { - expect(e.message).to.include('416'); + expect(() => { + throw e; + }).to.throw('416'); } }); it('broken stream all', async () => { @@ -115,7 +117,9 @@ describe('chunkers', () => { await c(); expect.fail(); } catch (e) { - expect(e.message).to.include('404'); + expect(() => { + throw e; + }).to.throw('404'); } }); it('broken stream some', async () => { @@ -125,7 +129,9 @@ describe('chunkers', () => { await c(1); expect.fail(); } catch (e) { - expect(e.message).to.include('404'); + expect(() => { + throw e; + }).to.throw('404'); } }); }); diff --git a/lib/tests/mocks/index.ts b/lib/tests/mocks/index.ts index 9e0c2ab8..0afcf140 100644 --- a/lib/tests/mocks/index.ts +++ b/lib/tests/mocks/index.ts @@ -34,13 +34,6 @@ type createAttributeSetContext = { createAttribute: (prop: CreateAttributePayload) => AttributeObject; }; -type GetEntityObjectContext = { - aaPrivateKey: string; - entityPublicKey: string; - createJwtAttribute: (prop: CreateAttributePayload) => Promise<{ jwt?: string }>; - getUserId: () => string; -}; - type GetPolicyObjectContext = { getUserId: () => string; }; @@ -185,32 +178,6 @@ tN5S0umLPkMUJ6zBIxh1RQK1ZYjfuKij+EEimbqtte9rYyQr3Q== return aSet; }, - async getEntityObject(this: GetEntityObjectContext, attributes = []) { - const jwtAttributes = await Promise.all( - attributes.map(async (options) => { - const attrJwt = await this.createJwtAttribute(options); - return attrJwt; - }) - ); - const baseObject = { - userId: this.getUserId(), - aliases: [], - attributes: jwtAttributes, - publicKey: this.entityPublicKey, - cert: {}, - }; - - const pkKeyLike = await importPKCS8(this.aaPrivateKey, 'RS256'); - - baseObject.cert = await new SignJWT(baseObject) - .setProtectedHeader({ alg: 'RS256' }) - .setIssuedAt() - .setExpirationTime('24h') - .sign(pkKeyLike); - - return baseObject; - }, - getUserId() { return 'user@domain.com'; }, diff --git a/lib/tests/web/utils.test.ts b/lib/tests/web/utils.test.ts index 6791f4be..ef02662a 100644 --- a/lib/tests/web/utils.test.ts +++ b/lib/tests/web/utils.test.ts @@ -1,5 +1,4 @@ import { expect } from '@esm-bundle/chai'; -import axios from 'axios'; import sinon from 'sinon'; import { addNewLines, @@ -135,10 +134,10 @@ describe('skew estimation', () => { }); describe('estimateSkewFromHeaders', () => { - it('axios', async () => { + it('fetch', async () => { console.log(window.origin); const before = Date.now(); - const aResponse = await axios.get(window.origin); + const aResponse = await fetch(window.origin); await new Promise((r) => setTimeout(r, 1000)); const estimate = estimateSkewFromHeaders(aResponse.headers, before); expect(estimate).to.be.lessThan(3);