diff --git a/.github/workflows/roundtrip/demo-idp.sh b/.github/workflows/roundtrip/demo-idp.sh index d03a64b4..08d55ddc 100644 --- a/.github/workflows/roundtrip/demo-idp.sh +++ b/.github/workflows/roundtrip/demo-idp.sh @@ -1,4 +1,3 @@ - : "${KC_VERSION:=24.0.3}" : "${KC_BROWSERTEST_CLIENT_SECRET:=$(uuidgen)}" @@ -8,11 +7,23 @@ unzip kc.zip -d keycloak-${KC_VERSION} export PATH=$PATH:$(pwd)/keycloak-${KC_VERSION}/bin -kcadm.sh config credentials --server http://localhost:65432/auth --realm master --user admin << EOF +kcadm.sh config credentials --server http://localhost:65432/auth --realm master --user admin < /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:8888/auth/health/live'] interval: 5s diff --git a/lib/src/auth/auth.ts b/lib/src/auth/auth.ts index 1eeae0bd..8d265f0c 100644 --- a/lib/src/auth/auth.ts +++ b/lib/src/auth/auth.ts @@ -80,7 +80,7 @@ export async function reqSignature( * ephemeral key, to be included in * [the claims object](https://github.com/opentdf/spec/blob/main/schema/ClaimsObject.md). */ -export abstract class AuthProvider { +export type AuthProvider = { /** * This function should be called if the consumer of this auth provider * changes the client keypair, or wishes to set the keypair after creating @@ -94,15 +94,15 @@ export abstract class AuthProvider { * @param signingKey the client signing key pair. Will be bound * to the OIDC token and require a DPoP header, when set. */ - abstract updateClientPublicKey(clientPubkey: string, signingKey?: CryptoKeyPair): Promise; + updateClientPublicKey(clientPubkey: string, signingKey?: CryptoKeyPair): Promise; /** * 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; -} + withCreds(httpReq: HttpRequest): Promise; +}; /** * An AuthProvider encapsulates all logic necessary to authenticate to a backend service, in the diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index 46de5c26..360200d4 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -1,4 +1,4 @@ -import { AuthProvider, type HttpRequest } from './auth.js'; +import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ExternalJwtCredentials } from './oidc.js'; export class OIDCExternalJwtProvider implements AuthProvider { diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index 5827c957..122986e3 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -1,4 +1,4 @@ -import { AuthProvider, type HttpRequest } from './auth.js'; +import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type RefreshTokenCredentials } from './oidc.js'; export class OIDCRefreshTokenProvider implements AuthProvider { diff --git a/lib/src/auth/providers.ts b/lib/src/auth/providers.ts index 3601aef9..660349f9 100644 --- a/lib/src/auth/providers.ts +++ b/lib/src/auth/providers.ts @@ -6,7 +6,7 @@ import { } from './oidc.js'; import { OIDCClientCredentialsProvider } from './oidc-clientcredentials-provider.js'; import { OIDCExternalJwtProvider } from './oidc-externaljwt-provider.js'; -import { AuthProvider } from './auth.js'; +import { type AuthProvider } from './auth.js'; import { OIDCRefreshTokenProvider } from './oidc-refreshtoken-provider.js'; import { isBrowser } from '../utils.js'; diff --git a/lib/src/index.ts b/lib/src/index.ts index e6b5fc22..25a39f73 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,7 +10,7 @@ import { } from './nanotdf/index.js'; import { keyAgreement, extractPublicFromCertToCrypto } from './nanotdf-crypto/index.js'; import { TypedArray, createAttribute, Policy } from './tdf/index.js'; -import { AuthProvider } from './auth/auth.js'; +import { type AuthProvider } from './auth/auth.js'; async function fetchKasPubKey(kasUrl: string): Promise { const kasPubKeyResponse = await fetch(`${kasUrl}/kas_public_key?algorithm=ec:secp256r1`); diff --git a/lib/src/kas.ts b/lib/src/kas.ts index 213804d9..c3c884ac 100644 --- a/lib/src/kas.ts +++ b/lib/src/kas.ts @@ -1,4 +1,4 @@ -import { AuthProvider } from './auth/auth.js'; +import { type AuthProvider } from './auth/auth.js'; export class RewrapRequest { signedRequestToken = ''; diff --git a/lib/tdf3/index.ts b/lib/tdf3/index.ts index 2e360d99..f1589c03 100644 --- a/lib/tdf3/index.ts +++ b/lib/tdf3/index.ts @@ -24,7 +24,13 @@ import { SplitKey, type EncryptionInformation, } from './src/models/encryption-information.js'; -import { AuthProvider, AppIdAuthProvider, type HttpMethod, HttpRequest } from '../src/auth/auth.js'; +import { + AuthProvider, + AppIdAuthProvider, + type HttpMethod, + HttpRequest, + withHeaders, +} from '../src/auth/auth.js'; import { AesGcmCipher } from './src/ciphers/aes-gcm-cipher.js'; import { NanoTDFClient, @@ -39,6 +45,7 @@ import { type Chunker } from './src/utils/chunkers.js'; export type { AlgorithmName, AlgorithmUrn, + AuthProvider, Chunker, CryptoService, DecryptResult, @@ -55,7 +62,6 @@ export { AesGcmCipher, Algorithms, AppIdAuthProvider, - AuthProvider, AuthProviders, Binary, Client, @@ -77,6 +83,7 @@ export { TDF3Client, clientType, createSessionKeys, + withHeaders, version, }; diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 9be056cd..02084b4d 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -24,7 +24,12 @@ import { import { OIDCRefreshTokenProvider } from '../../../src/auth/oidc-refreshtoken-provider.js'; import { OIDCExternalJwtProvider } from '../../../src/auth/oidc-externaljwt-provider.js'; import { CryptoService, PemKeyPair } from '../crypto/declarations.js'; -import { AuthProvider, AppIdAuthProvider, HttpRequest } from '../../../src/auth/auth.js'; +import { + type AuthProvider, + AppIdAuthProvider, + HttpRequest, + withHeaders, +} from '../../../src/auth/auth.js'; import EAS from '../../../src/auth/Eas.js'; import { validateSecureUrl } from '../../../src/utils.js'; @@ -119,6 +124,7 @@ export interface ClientConfig { organizationName?: string; clientId?: string; dpopEnabled?: boolean; + dpopKeys?: Promise; kasEndpoint?: string; /** * List of allowed KASes to connect to for rewrap requests. @@ -152,18 +158,22 @@ export async function createSessionKeys({ cryptoService, dpopEnabled, keypair, + dpopKeys, }: { authProvider?: AuthProvider | AppIdAuthProvider; cryptoService: CryptoService; dpopEnabled?: boolean; keypair?: PemKeyPair; + dpopKeys?: Promise; }): Promise { //If clientconfig has keypair, assume auth provider was already set up with pubkey and bail const k2 = keypair ?? (await cryptoService.cryptoToPemPair(await cryptoService.generateKeyPair())); let signingKeys; - if (dpopEnabled) { + if (dpopKeys) { + signingKeys = await dpopKeys; + } else if (dpopEnabled) { signingKeys = await crypto.subtle.generateKey(rsaPkcs1Sha256(), true, ['sign']); } @@ -251,7 +261,7 @@ export class Client { constructor(config: ClientConfig) { const clientConfig = { ...defaultClientConfig, ...config }; this.cryptoService = clientConfig.cryptoService; - this.dpopEnabled = !!clientConfig.dpopEnabled; + this.dpopEnabled = !!(clientConfig.dpopEnabled || clientConfig.dpopKeys); clientConfig.readerUrl && (this.readerUrl = clientConfig.readerUrl); @@ -316,16 +326,13 @@ export class Client { }); } } - if (clientConfig.keypair) { - this.sessionKeys = Promise.resolve({ keypair: clientConfig.keypair }); - } else { - this.sessionKeys = createSessionKeys({ - authProvider: this.authProvider, - cryptoService: this.cryptoService, - dpopEnabled: this.dpopEnabled, - keypair: clientConfig.keypair, - }); - } + this.sessionKeys = createSessionKeys({ + authProvider: this.authProvider, + cryptoService: this.cryptoService, + dpopEnabled: this.dpopEnabled, + dpopKeys: clientConfig.dpopKeys, + keypair: clientConfig.keypair, + }); if (clientConfig.kasPublicKey) { this.kasPublicKey = Promise.resolve({ url: this.kasEndpoint, @@ -535,12 +542,14 @@ export class Client { } } +export type { AuthProvider }; + export { - AuthProvider, AppIdAuthProvider, DecryptParamsBuilder, DecryptSource, EncryptParamsBuilder, HttpRequest, fromDataSource, + withHeaders, }; diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index f001564f..f3e506ec 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -48,7 +48,7 @@ import { htmlWrapperTemplate } from './templates/index.js'; // 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 { - AuthProvider, + type AuthProvider, AppIdAuthProvider, HttpRequest, type HttpMethod, diff --git a/lib/tdf3/src/utils/index.ts b/lib/tdf3/src/utils/index.ts index a36ec311..1fa940f1 100644 --- a/lib/tdf3/src/utils/index.ts +++ b/lib/tdf3/src/utils/index.ts @@ -1,5 +1,5 @@ import { toByteArray, fromByteArray } from 'base64-js'; -import { AppIdAuthProvider, AuthProvider } from '../../../src/auth/auth.js'; +import { AppIdAuthProvider, type AuthProvider } from '../../../src/auth/auth.js'; import * as WebCryptoService from '../crypto/index.js'; import { KeyInfo, SplitKey } from '../models/index.js'; diff --git a/lib/tests/web/nano-roundtrip.test.ts b/lib/tests/web/nano-roundtrip.test.ts index 3b9a9cde..5cce2f17 100644 --- a/lib/tests/web/nano-roundtrip.test.ts +++ b/lib/tests/web/nano-roundtrip.test.ts @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; -import { AuthProvider, HttpRequest, withHeaders } from '../../src/auth/auth.js'; +import { type AuthProvider, HttpRequest, withHeaders } from '../../src/auth/auth.js'; import { NanoTDFClient } from '../../src/index.js'; diff --git a/opentdf.yaml b/opentdf.yaml index 14462de9..fad96fdf 100644 --- a/opentdf.yaml +++ b/opentdf.yaml @@ -15,7 +15,7 @@ services: enabled: true authorization: enabled: true - url: http://localhost:8888 + url: http://localhost:65432 client: "tdf-entity-resolution" secret: "secret" realm: "opentdf" @@ -23,8 +23,8 @@ services: server: auth: enabled: true - audience: "http://localhost:8080" - issuer: http://localhost:8888/auth/realms/opentdf + audience: "http://localhost:65432" + issuer: http://localhost:65432/auth/realms/opentdf clients: - "opentdf" - "opentdf-sdk" diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 1508fec2..0bb74582 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -602,7 +602,7 @@ "node_modules/@opentdf/client": { "version": "2.0.0", "resolved": "file:../lib/opentdf-client-2.0.0.tgz", - "integrity": "sha512-MDVV3LtjCC8NYQFiYceXWG0kZffX5OIvUTdnRBdgMSnbk69TKFjawKLlvouTYqGn9r4ZM9H9F9HXTbhzQizEvg==", + "integrity": "sha512-HayM7L2fO9hP7fuM8SujAv5JEpWngIYaKw6C52qC6R4ZuD0rmFIWUT+KmDXClEjDXf7tnLBEKfe9Fty6MbkThg==", "license": "BSD-3-Clause-Clear", "dependencies": { "ajv": "^8.12.0", @@ -4101,7 +4101,7 @@ }, "@opentdf/client": { "version": "file:../lib/opentdf-client-2.0.0.tgz", - "integrity": "sha512-MDVV3LtjCC8NYQFiYceXWG0kZffX5OIvUTdnRBdgMSnbk69TKFjawKLlvouTYqGn9r4ZM9H9F9HXTbhzQizEvg==", + "integrity": "sha512-HayM7L2fO9hP7fuM8SujAv5JEpWngIYaKw6C52qC6R4ZuD0rmFIWUT+KmDXClEjDXf7tnLBEKfe9Fty6MbkThg==", "requires": { "ajv": "^8.12.0", "axios": "^1.6.1", diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 7ec6c2c2..b8fb2370 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -2,7 +2,11 @@ import { clsx } from 'clsx'; import { useState, useEffect, type ChangeEvent } from 'react'; import { showSaveFilePicker } from 'native-file-system-adapter'; import './App.css'; -import { TDF3Client, type DecryptSource, NanoTDFClient, AuthProviders } from '@opentdf/client'; +import { + TDF3Client, + type DecryptSource, + NanoTDFClient, +} from '@opentdf/client'; import { type SessionInformation, OidcClient } from './session.js'; function decryptedFileName(encryptedFileName: string): string { @@ -316,6 +320,8 @@ function App() { }), }; }; + // http://localhost:65432/auth/realms/opentdf/protocol/openid-connect/token + // http://localhost:65432/auth/realms/opentdf/protocol/openid-connect/token const handleEncrypt = async () => { if (!inputSource) { @@ -329,12 +335,6 @@ function App() { } const inputFileName = fileNameFor(inputSource); console.log(`Encrypting [${inputFileName}] as ${encryptContainerType} to ${sinkType}`); - const authProvider = await AuthProviders.refreshAuthProvider({ - exchange: 'refresh', - clientId: oidcClient.clientId, - oidcOrigin: oidcClient.host, - refreshToken, - }); switch (encryptContainerType) { case 'nano': { if ('url' in inputSource) { @@ -344,7 +344,7 @@ function App() { 'file' in inputSource ? await inputSource.file.arrayBuffer() : randomArrayBuffer(inputSource); - const nanoClient = new NanoTDFClient(authProvider, 'http://localhost:65432/api/kas'); + const nanoClient = new NanoTDFClient(oidcClient, 'http://localhost:65432/kas'); setDownloadState('Encrypting...'); switch (sinkType) { case 'file': @@ -375,8 +375,9 @@ function App() { } case 'html': { const client = new TDF3Client({ - authProvider, - kasEndpoint: 'http://localhost:65432/api/kas', + authProvider: oidcClient, + dpopKeys: oidcClient.getSigningKey(), + kasEndpoint: 'http://localhost:65432/kas', readerUrl: 'https://secure.virtru.com/start?htmlProtocol=1', }); let source: ReadableStream, size: number; @@ -443,8 +444,9 @@ function App() { } case 'tdf': { const client = new TDF3Client({ - authProvider, - kasEndpoint: 'http://localhost:65432/api/kas', + authProvider: oidcClient, + dpopKeys: oidcClient.getSigningKey(), + kasEndpoint: 'http://localhost:65432/kas', }); const sc = new AbortController(); setStreamController(sc); @@ -521,12 +523,6 @@ function App() { console.log( `Decrypting ${decryptContainerType} ${JSON.stringify(inputSource)} to ${sinkType} ${dfn}` ); - const authProvider = await AuthProviders.refreshAuthProvider({ - exchange: 'refresh', - clientId: oidcClient.clientId, - oidcOrigin: oidcClient.host, - refreshToken: authState.user.refreshToken, - }); let f; if (sinkType === 'fsapi') { f = await getNewFileHandle(decryptedFileExtension(fileNameFor(inputSource)), dfn); @@ -534,8 +530,9 @@ function App() { switch (decryptContainerType) { case 'tdf': { const client = new TDF3Client({ - authProvider, - kasEndpoint: 'http://localhost:65432/api/kas', + authProvider: oidcClient, + dpopKeys: oidcClient.getSigningKey(), + kasEndpoint: 'http://localhost:65432/kas', }); try { const sc = new AbortController(); @@ -587,7 +584,7 @@ function App() { if ('url' in inputSource) { throw new Error('Unsupported : fetch the url I guess?'); } - const nanoClient = new NanoTDFClient(authProvider, 'http://localhost:65432/api/kas'); + const nanoClient = new NanoTDFClient(oidcClient, 'http://localhost:65432/kas'); try { const cipherText = 'file' in inputSource diff --git a/web-app/src/session.ts b/web-app/src/session.ts index b177c75b..86d984aa 100644 --- a/web-app/src/session.ts +++ b/web-app/src/session.ts @@ -1,5 +1,7 @@ import { decodeJwt } from 'jose'; +import { default as dpopFn } from 'dpop'; import { base64 } from '@opentdf/client/encodings'; +import { AuthProvider, HttpRequest, withHeaders } from '@opentdf/client'; export type OpenidConfiguration = { issuer: string; @@ -89,12 +91,25 @@ export type Sessions = { requests: Record; /** state for most recent request */ lastRequest?: string; + /** DPoP key */ + k?: string[]; }; function getTimestampInSeconds() { return Math.floor(Date.now() / 1000); } +function rsaPkcs1Sha256(): RsaHashedKeyGenParams { + return { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256', + }, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 24 bit representation of 65537 + }; +} + const extractAuthorizationResponse = (url: string): AuthorizationResponse | null => { const queryParams = new URLSearchParams(url); console.log(`response: ${JSON.stringify(queryParams.toString())}`); @@ -152,12 +167,14 @@ async function fetchConfig(server: string): Promise { return response.json(); } -export class OidcClient { +export class OidcClient implements AuthProvider { clientId: string; host: string; scope: string; sessionIdentifier: string; _sessions?: Sessions; + signingKey?: CryptoKeyPair; + wrapperPubKey?: string; constructor(host: string, clientId: string, sessionIdentifier: string) { this.clientId = clientId; @@ -189,7 +206,7 @@ export class OidcClient { return this._sessions; } - async storeSessions() { + storeSessions() { sessionStorage.setItem(this.ssk('sessions'), JSON.stringify(this._sessions)); } @@ -271,6 +288,8 @@ export class OidcClient { console.log('Ignoring repeated redirect code'); return; } + currentSession.usedCodes.push(response.code); + this.storeSessions(); try { currentSession.user = await this._makeAccessTokenRequest({ grantType: 'authorization_code', @@ -288,6 +307,24 @@ export class OidcClient { } } + async getSigningKey(): Promise { + if (this.signingKey) { + return this.signingKey; + } + if (this._sessions?.k) { + const k = this._sessions?.k.map((e) => base64.decodeArrayBuffer(e)); + const algorithm = rsaPkcs1Sha256(); + const [publicKey, privateKey] = await Promise.all([ + crypto.subtle.importKey('spki', k[0], algorithm, true, ['verify']), + crypto.subtle.importKey('pkcs8', k[1], algorithm, false, ['sign']), + ]); + this.signingKey = { privateKey, publicKey }; + } else { + this.signingKey = await crypto.subtle.generateKey(rsaPkcs1Sha256(), true, ['sign']); + } + return this.signingKey; + } + private async _makeAccessTokenRequest(options: { grantType: 'authorization_code' | 'refresh_token'; codeOrRefreshToken: string; @@ -312,11 +349,26 @@ export class OidcClient { if (!config) { throw new Error('Unable to autoconfigure OIDC'); } + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + const signingKey = await this.getSigningKey(); + if (this._sessions && this.signingKey) { + const k = await Promise.all([ + crypto.subtle.exportKey('spki', this.signingKey.publicKey), + crypto.subtle.exportKey('pkcs8', this.signingKey.privateKey), + ]); + this._sessions.k = k.map((e) => base64.encodeArrayBuffer(e)); + } + console.info(`signing token request with DPoP key ${JSON.stringify(await crypto.subtle.exportKey("jwk", signingKey.publicKey))}`); + headers.DPoP = await dpopFn( + signingKey, + 'http://localhost:8888/auth/realms/opentdf/protocol/openid-connect/token', + 'POST' + ); const response = await fetch(config.token_endpoint, { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers, body: params, credentials: 'include', }); @@ -335,4 +387,35 @@ export class OidcClient { refreshToken: refresh_token, }; } + + async updateClientPublicKey(clientPubKey: string): Promise { + this.wrapperPubKey = clientPubKey; + } + + async withCreds(httpReq: HttpRequest): Promise { + const user = await this.currentUser(); + if (!user) { + console.error('Not logged in'); + return httpReq; + } + const { accessToken } = user; + const { signingKey } = this; + if (!signingKey) { + console.error('missing DPoP key'); + return httpReq; + } + console.info(`signing request for ${httpReq.url} with DPoP key ${JSON.stringify(await crypto.subtle.exportKey("jwk", this.signingKey.publicKey))}`); + const dpopToken = await dpopFn( + signingKey, + httpReq.url, + httpReq.method, + /* nonce */ undefined, + accessToken + ); + if (this.wrapperPubKey) { + httpReq = withHeaders(httpReq, {"X-VirtruPubKey": this.wrapperPubKey}) + } + // TODO: Consider: only set DPoP if cnf.jkt is present in access token? + return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + } }