diff --git a/.github/workflows/roundtrip/config-demo-idp.sh b/.github/workflows/roundtrip/config-demo-idp.sh index 6978f161e..dcda88bf1 100755 --- a/.github/workflows/roundtrip/config-demo-idp.sh +++ b/.github/workflows/roundtrip/config-demo-idp.sh @@ -2,7 +2,7 @@ set -x -: "${KC_VERSION:=24.0.3}" +: "${KC_VERSION:=26.2.0}" if ! which kcadm.sh; then KCADM_URL=https://github.com/keycloak/keycloak/releases/download/${KC_VERSION}/keycloak-${KC_VERSION}.zip @@ -48,7 +48,8 @@ kcadm.sh create clients -r opentdf \ -s enabled=true \ -s standardFlowEnabled=true \ -s serviceAccountsEnabled=true \ - -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' + -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' \ + -s 'attributes={"dpop.bound.access.tokens":"true"}' kcadm.sh create users -r opentdf -s username=user1 -s enabled=true -s firstName=Alice -s lastName=User kcadm.sh set-password -r opentdf --username user1 --new-password testuser123 diff --git a/.github/workflows/roundtrip/docker-compose.yaml b/.github/workflows/roundtrip/docker-compose.yaml index 0b513b45d..6b39b65ce 100644 --- a/.github/workflows/roundtrip/docker-compose.yaml +++ b/.github/workflows/roundtrip/docker-compose.yaml @@ -1,18 +1,12 @@ services: keycloak: - image: keycloak/keycloak:24.0.5 + image: keycloak/keycloak:26.2 restart: always command: - "start-dev" - "--verbose" environment: - KC_DB_VENDOR: postgres - KC_DB_URL_HOST: keycloakdb - KC_DB_URL_PORT: 5432 - KC_DB_URL_DATABASE: keycloak - KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: changeme - KC_FEATURES: 'preview,token-exchange' + KC_FEATURES: "preview,token-exchange,admin-fine-grained-authz:v1" KC_HEALTH_ENABLED: 'true' KC_HOSTNAME_ADMIN_URL: 'http://localhost:65432/auth' KC_HOSTNAME_PORT: '65432' @@ -34,19 +28,6 @@ services: timeout: 10s retries: 3 start_period: 2m - keycloakdb: - image: postgres:15-alpine - restart: always - user: postgres - environment: - POSTGRES_PASSWORD: changeme - POSTGRES_USER: postgres - POSTGRES_DB: keycloak - healthcheck: - test: ["CMD-SHELL", "pg_isready"] - interval: 5s - timeout: 5s - retries: 10 opentdfdb: image: postgres:15-alpine restart: always diff --git a/.github/workflows/roundtrip/encrypt-decrypt.sh b/.github/workflows/roundtrip/encrypt-decrypt.sh index a57dc2bf8..e88106015 100755 --- a/.github/workflows/roundtrip/encrypt-decrypt.sh +++ b/.github/workflows/roundtrip/encrypt-decrypt.sh @@ -16,6 +16,7 @@ _tdf3_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample.txt.tdf \ encrypt "${plain}" \ --containerType tdf3 \ @@ -28,6 +29,7 @@ _tdf3_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample_out.txt \ --containerType tdf3 \ decrypt sample.txt.tdf @@ -50,6 +52,7 @@ _tdf3_inspect_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample-with-attrs.txt.tdf \ --attributes 'https://attr.io/attr/a/value/1,https://attr.io/attr/x/value/2' \ encrypt "${plain}" \ diff --git a/.github/workflows/roundtrip/keycloak_data.yaml b/.github/workflows/roundtrip/keycloak_data.yaml index 201a2b654..da410a4b8 100644 --- a/.github/workflows/roundtrip/keycloak_data.yaml +++ b/.github/workflows/roundtrip/keycloak_data.yaml @@ -42,9 +42,11 @@ realms: serviceAccountsEnabled: true clientAuthenticatorType: client-secret secret: secret + attributes: + dpop.bound.access.tokens: "true" protocolMappers: - *customAudMapper - sa_realm_roles: + sa_realm_roles: - opentdf-standard - client: clientID: tdf-entity-resolution diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 241146d8a..873679b31 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -21,6 +21,7 @@ import { CLIError, Level, log } from './logger.js'; import * as assertions from '@opentdf/sdk/assertions'; import { base64 } from '@opentdf/sdk/encodings'; import { type KeyPair } from '@opentdf/sdk/singlecontainer'; +import { resolveDPoPFromArgs } from './dpop-helpers.js'; type AuthToProcess = { auth?: string; @@ -52,14 +53,10 @@ const parseJwtComplete = (jwt: string) => { return { header: parseJwt(jwt, 0), payload: parseJwt(jwt) }; }; -async function processAuth({ - auth, - clientId, - clientSecret, - concurrencyLimit, - oidcEndpoint, - userId, -}: AuthToProcess): Promise { +async function processAuth( + { auth, clientId, clientSecret, concurrencyLimit, oidcEndpoint, userId }: AuthToProcess, + dpopKeyPair?: KeyPair +): Promise { log('DEBUG', 'Processing auth params'); if (!oidcEndpoint) { throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); @@ -78,11 +75,19 @@ async function processAuth({ 'Auth expects clientId and clientSecret, or combined auth param' ); } + // Pass DPoP key into the provider config so the AccessToken is born with + // DPoP enabled (config.dpopEnabled + signingKey). Without this, the very + // first POST /token would go out without a DPoP proof — Keycloak clients + // with dpop_bound_access_tokens=true reject that with 400 invalid_request. + // Without a key, DPoP stays off so non-DPoP clients still get plain Bearer + // tokens that the platform will accept. const actual = await AuthProviders.clientSecretAuthProvider({ clientId, oidcOrigin: oidcEndpoint, exchange: 'client', clientSecret, + dpopEnabled: !!dpopKeyPair, + signingKey: dpopKeyPair, }); if (concurrencyLimit !== 1) { await actual.oidcAuth.get(); @@ -90,6 +95,12 @@ async function processAuth({ const requestLog: AuthProviders.HttpRequest[] = []; return { requestLog, + // Forward the wrapped provider's per-client DPoP-Nonce cache. Without this, + // the auth interceptor/transport fall back to the shared default cache while + // `withCreds` (delegated below) mints proofs from the wrapped provider's own + // cache — the two diverge and the DPoP-Nonce challenge retry never carries + // the server nonce (RFC 9449 §9). + nonceCache: actual.nonceCache, updateClientPublicKey: async (signingKey: KeyPair) => { actual.updateClientPublicKey(signingKey); log('DEBUG', `updateClientPublicKey: [${signingKey?.publicKey}]`); @@ -393,8 +404,14 @@ export const handleArgs = (args: string[]) => { }) .option('dpop', { group: 'Security:', - desc: 'Use DPoP for token binding', - type: 'boolean', + desc: 'Enable DPoP token binding. Optional value selects algorithm: ES256 (default), ES384, ES512, RS256. Use --dpop=ES512 to specify.', + type: 'string', + }) + .option('dpopKey', { + alias: 'dpop-key', + group: 'Security:', + desc: 'Path to PEM-encoded PKCS8 private key for DPoP signing. Enables DPoP alone if --dpop is omitted.', + type: 'string', }) .implies('auth', '--no-clientId') .implies('auth', '--no-clientSecret') @@ -510,6 +527,21 @@ export const handleArgs = (args: string[]) => { description: 'output file', }) + .command( + 'supports ', + 'Check if a feature is supported', + (yargs) => { + yargs.strict().positional('feature', { + describe: 'feature name to check', + type: 'string', + choices: ['dpop'], + }); + }, + async () => { + // yargs choices validation ensures feature is supported; return naturally exits 0 + } + ) + .command( 'inspect [file]', 'Inspect TDF and extract header information, without decrypting', @@ -558,9 +590,11 @@ export const handleArgs = (args: string[]) => { if (!argv.oidcEndpoint) { throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); } - const authProvider = await processAuth(argv); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); + const authProvider = await processAuth(argv, dpopKeyPair); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); + const client = new OpenTDF({ authProvider, defaultCreateOptions: { @@ -571,7 +605,8 @@ export const handleArgs = (args: string[]) => { ignoreAllowlist: ignoreAllowList, noVerify: !!argv.noVerifyAssertions, }, - disableDPoP: !argv.dpop, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, policyEndpoint: guessedPolicyEndpoint, platformUrl: argv.platformUrl || guessedPolicyEndpoint, }); @@ -597,14 +632,14 @@ export const handleArgs = (args: string[]) => { console.assert(!accessToken, 'Multiple authorization headers found'); accessToken = parseJwt(lastRequest.headers[h].split(' ')[1]); log('INFO', `Access Token: ${JSON.stringify(accessToken)}`); - if (argv.dpop) { + if (dpopEnabled) { console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); } break; } } console.assert(accessToken, 'No access_token found'); - console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent'); + console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); } finally { client.close(); } @@ -621,7 +656,8 @@ export const handleArgs = (args: string[]) => { }, async (argv) => { log('DEBUG', 'Running encrypt command'); - const authProvider = await processAuth(argv); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); + const authProvider = await processAuth(argv, dpopKeyPair); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); @@ -630,7 +666,8 @@ export const handleArgs = (args: string[]) => { defaultCreateOptions: { defaultKASEndpoint: argv.kasEndpoint, }, - disableDPoP: !argv.dpop, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, policyEndpoint: guessedPolicyEndpoint, platformUrl: argv.platformUrl || guessedPolicyEndpoint, }); diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts new file mode 100644 index 000000000..b155df444 --- /dev/null +++ b/cli/src/dpop-helpers.ts @@ -0,0 +1,204 @@ +// cli/src/dpop-helpers.ts +import { readFile } from 'node:fs/promises'; +import { type webcrypto } from 'node:crypto'; +import { type KeyPair, WebCryptoService } from '@opentdf/sdk/singlecontainer'; +import { CLIError } from './logger.js'; + +const VALID_DPOP_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512'] as const; +export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; + +const EC_CURVE_MAP: Record = { + ES256: 'P-256', + ES384: 'P-384', + ES512: 'P-521', +}; + +/** Resolve the optional WebCryptoService.importPrivateKey method, failing with a clear CLIError if absent. */ +function requireImportPrivateKey() { + if (!WebCryptoService.importPrivateKey) { + throw new CLIError( + 'CRITICAL', + 'WebCryptoService.importPrivateKey is unavailable in this SDK build; cannot load DPoP private keys' + ); + } + return WebCryptoService.importPrivateKey; +} + +/** Convert a DER buffer to a PEM string with the given type label. */ +export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { + const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; + const b64 = Buffer.from(bytes).toString('base64'); + const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64; + return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; +} + +/** + * Generate an ephemeral DPoP key pair for the given JWS algorithm. + * ES256/ES384/ES512 → ECDSA key via WebCrypto + SDK import. + * RS256/RS384/RS512 → RSA-2048 via SDK's generateSigningKeyPair() (all map to RS256 in DPoP proof). + */ +export async function generateEphemeralDPoPKeyPair(alg: string): Promise { + if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { + throw new CLIError( + 'CRITICAL', + `Unsupported DPoP algorithm: ${alg}. Valid values: ${VALID_DPOP_ALGS.join(', ')}` + ); + } + + if (alg === 'RS384' || alg === 'RS512') { + console.warn( + `[WARN] DPoP algorithm ${alg} requested but the SDK only supports RS256 for RSA keys; generating RSA-2048 (RS256) key.` + ); + } + + const namedCurve = EC_CURVE_MAP[alg]; + if (namedCurve) { + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(privDer, 'PRIVATE KEY'); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + const importPriv = requireImportPrivateKey(); + const [privateKey, publicKey] = await Promise.all([ + importPriv(privPem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; + } + + // RSA fallback — generateSigningKeyPair() produces RSA-2048 (DPoP maps this to RS256) + return WebCryptoService.generateSigningKeyPair(); +} + +/** + * Load a DPoP key pair from a PKCS8 PEM-encoded private key file. + * Derives the public key from the private key via JWK round-trip. + * Supports ECDSA (P-256, P-384, P-521) and RSA (PKCS1-v1_5 SHA-256). + */ +export async function loadDPoPKeyPairFromPem(pemPath: string): Promise { + let privatePem: string; + try { + privatePem = await readFile(pemPath, 'utf8'); + } catch (err) { + throw new CLIError('CRITICAL', `Cannot read DPoP key file: ${pemPath}`, err as Error); + } + + let der: Uint8Array; + try { + const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n\s]/g, ''); + der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + } catch (err) { + throw new CLIError( + 'CRITICAL', + `Cannot decode DPoP key file as PEM/base64: ${pemPath}. Ensure the file is a PKCS8 PEM-encoded private key.`, + err as Error + ); + } + + // Try EC curves (P-256, P-384, P-521). Catch only the importKey call so that + // any SDK-layer errors from buildKeyPairFromCryptoKey propagate with full context. + for (const namedCurve of ['P-256', 'P-384', 'P-521']) { + let privCK: webcrypto.CryptoKey | undefined; + try { + privCK = await crypto.subtle.importKey('pkcs8', der, { name: 'ECDSA', namedCurve }, true, [ + 'sign', + ]); + } catch { + // wrong curve or not an EC key — try next + } + if (privCK) { + return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + } + } + + // Try RSA (PKCS1-v1_5 SHA-256). Same narrowing rationale as above. + let rsaCK: webcrypto.CryptoKey | undefined; + try { + rsaCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['sign'] + ); + } catch { + // not RSA either + } + if (rsaCK) { + return await buildKeyPairFromCryptoKey(privatePem, rsaCK, { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + } + + throw new CLIError( + 'CRITICAL', + `Cannot parse DPoP key from ${pemPath}: expected PKCS8 PEM with ECDSA (P-256/P-384/P-521) or RSA private key` + ); +} + +/** + * Derive the public key from an already-imported private CryptoKey via JWK round-trip, + * then import both through the SDK to get the opaque KeyPair type. + */ +async function buildKeyPairFromCryptoKey( + privatePem: string, + privCK: webcrypto.CryptoKey, + algorithm: + | webcrypto.AlgorithmIdentifier + | webcrypto.RsaHashedImportParams + | webcrypto.EcKeyImportParams +): Promise { + // Export private key as JWK; strip private components to build the public JWK + const privJwk = await crypto.subtle.exportKey('jwk', privCK); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, ...pubJwkProps } = privJwk; + const pubJwk: webcrypto.JsonWebKey = { ...pubJwkProps, key_ops: ['verify'] }; + + const pubCK = await crypto.subtle.importKey('jwk', pubJwk, algorithm, true, ['verify']); + const pubDer = await crypto.subtle.exportKey('spki', pubCK); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + + const importPriv = requireImportPrivateKey(); + const [privateKey, publicKey] = await Promise.all([ + importPriv(privatePem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +/** + * Main entry point: resolve a DPoP KeyPair from CLI arguments. + * Returns undefined if DPoP is not requested. + */ +export async function resolveDPoPKeyPair( + alg: string | undefined, + keyPath: string | undefined +): Promise { + if (keyPath) { + return loadDPoPKeyPairFromPem(keyPath); + } + if (alg) { + return generateEphemeralDPoPKeyPair(alg); + } + return undefined; +} + +/** + * Resolve DPoP configuration from CLI argv. Bare `--dpop` defaults to ES256; + * `--dpopKey` enables DPoP even without `--dpop`. + */ +export async function resolveDPoPFromArgs(argv: { + dpop?: string; + dpopKey?: string; +}): Promise<{ dpopEnabled: boolean; dpopKeyPair: KeyPair | undefined }> { + const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + return { dpopEnabled, dpopKeyPair }; +} diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts new file mode 100644 index 000000000..444a318af --- /dev/null +++ b/cli/tests/dpop-helpers.spec.ts @@ -0,0 +1,254 @@ +// cli/tests/dpop-helpers.spec.ts +import { expect } from '@esm-bundle/chai'; +import { type webcrypto } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + derToPem, + generateEphemeralDPoPKeyPair, + loadDPoPKeyPairFromPem, + resolveDPoPFromArgs, + resolveDPoPKeyPair, +} from '../src/dpop-helpers.js'; + +describe('derToPem', function () { + it('wraps DER bytes in PEM armor with the given type', function () { + const der = new Uint8Array([0x01, 0x02, 0x03]); + const pem = derToPem(der, 'PUBLIC KEY'); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + expect(pem).to.include('AQID'); // base64 of [1,2,3] + }); + + it('wraps an ArrayBuffer in PEM armor', function () { + const der = new Uint8Array([0x01, 0x02]).buffer; + const pem = derToPem(der, 'PRIVATE KEY'); + expect(pem).to.include('-----BEGIN PRIVATE KEY-----'); + expect(pem).to.include('-----END PRIVATE KEY-----'); + }); +}); + +describe('generateEphemeralDPoPKeyPair', function () { + it('generates ES256 (ec:secp256r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES256'); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('generates ES384 (ec:secp384r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES384'); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('generates ES512 (ec:secp521r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES512'); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('generates RS256 (rsa:2048) key pair', async function () { + this.timeout(15_000); + const kp = await generateEphemeralDPoPKeyPair('RS256'); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws on unknown algorithm', async function () { + try { + await generateEphemeralDPoPKeyPair('HS256'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); +}); + +type GeneratedPair = { privateKey: webcrypto.CryptoKey; publicKey: webcrypto.CryptoKey }; + +async function ecPrivatePem(curve: 'P-256' | 'P-384' | 'P-521'): Promise { + const raw = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: curve }, true, [ + 'sign', + 'verify', + ])) as GeneratedPair; + const der = await crypto.subtle.exportKey('pkcs8', raw.privateKey); + return derToPem(der, 'PRIVATE KEY'); +} + +async function rsaPrivatePem(): Promise { + const raw = (await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + )) as GeneratedPair; + const der = await crypto.subtle.exportKey('pkcs8', raw.privateKey); + return derToPem(der, 'PRIVATE KEY'); +} + +describe('loadDPoPKeyPairFromPem', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-helpers-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeTmp(name: string, contents: string): Promise { + const path = join(tmpDir, name); + await writeFile(path, contents); + return path; + } + + it('loads a P-256 PEM into an ec:secp256r1 key pair', async function () { + const path = await writeTmp('p256.pem', await ecPrivatePem('P-256')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('loads a P-384 PEM into an ec:secp384r1 key pair', async function () { + const path = await writeTmp('p384.pem', await ecPrivatePem('P-384')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('loads a P-521 PEM into an ec:secp521r1 key pair', async function () { + const path = await writeTmp('p521.pem', await ecPrivatePem('P-521')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('loads an RSA-2048 PEM into an rsa:2048 key pair', async function () { + this.timeout(15_000); + const path = await writeTmp('rsa.pem', await rsaPrivatePem()); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws CLIError when the file cannot be read', async function () { + try { + await loadDPoPKeyPairFromPem(join(tmpDir, 'does-not-exist.pem')); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot read DPoP key file'); + } + }); + + it('throws CLIError when the PEM body is not valid base64', async function () { + const path = await writeTmp( + 'corrupt.pem', + '-----BEGIN PRIVATE KEY-----\n!!!not-base64!!!\n-----END PRIVATE KEY-----' + ); + try { + await loadDPoPKeyPairFromPem(path); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot decode DPoP key file as PEM/base64'); + } + }); + + it('throws CLIError when the bytes are not a recognized key type', async function () { + // Valid base64 but the decoded bytes are not a PKCS8 EC or RSA key. + const path = await writeTmp( + 'garbage.pem', + '-----BEGIN PRIVATE KEY-----\nQUJDREVGR0g=\n-----END PRIVATE KEY-----' + ); + try { + await loadDPoPKeyPairFromPem(path); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot parse DPoP key from'); + } + }); +}); + +describe('resolveDPoPKeyPair', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-resolve-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns undefined when both alg and keyPath are undefined', async function () { + const result = await resolveDPoPKeyPair(undefined, undefined); + expect(result).to.be.undefined; + }); + + it('returns an ES256 key pair when alg is ES256', async function () { + const result = await resolveDPoPKeyPair('ES256', undefined); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('loads from the keyPath PEM when only keyPath is provided', async function () { + const path = join(tmpDir, 'p256-from-path.pem'); + await writeFile(path, await ecPrivatePem('P-256')); + const result = await resolveDPoPKeyPair(undefined, path); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('prefers keyPath over alg when both are provided', async function () { + const path = join(tmpDir, 'p384-pref.pem'); + await writeFile(path, await ecPrivatePem('P-384')); + const result = await resolveDPoPKeyPair('ES256', path); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp384r1'); + }); +}); + +describe('resolveDPoPFromArgs', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-args-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns disabled when neither --dpop nor --dpopKey is set', async function () { + const result = await resolveDPoPFromArgs({}); + expect(result.dpopEnabled).to.be.false; + expect(result.dpopKeyPair).to.be.undefined; + }); + + it('defaults to ES256 when --dpop is passed without a value', async function () { + // yargs delivers a bare `--dpop` as the empty string for type: 'string' + const result = await resolveDPoPFromArgs({ dpop: '' }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('honours an explicit --dpop=ES384', async function () { + const result = await resolveDPoPFromArgs({ dpop: 'ES384' }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('enables DPoP from --dpopKey alone (no --dpop)', async function () { + const path = join(tmpDir, 'args-key.pem'); + await writeFile(path, await ecPrivatePem('P-256')); + const result = await resolveDPoPFromArgs({ dpopKey: path }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('propagates the CLIError for an invalid algorithm', async function () { + try { + await resolveDPoPFromArgs({ dpop: 'INVALID' }); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); +}); diff --git a/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md new file mode 100644 index 000000000..ec38e2b13 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md @@ -0,0 +1,594 @@ +# DPoP CLI Flags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `--dpop[=alg]` and `--dpop-key ` flags to the `@opentdf/ctl` CLI so callers can enable DPoP with ES256 (default) or a specific algorithm, using an auto-generated or PEM-supplied key. + +**Architecture:** One new file (`cli/src/dpop-helpers.ts`) holds all key-management logic; `cli/src/cli.ts` changes only its option definitions and call sites. The helpers generate ECDSA keys via WebCrypto directly, then wrap them through the SDK's `importPrivateKey`/`importPublicKey` to get the opaque `KeyPair` type `OpenTDF` needs. + +**Tech Stack:** TypeScript 5, yargs 18, Node 24 WebCrypto (`crypto.subtle`), `@opentdf/sdk` (singlecontainer subpath provides `WebCryptoService` and `KeyPair`) + +--- + +## File Map + +| Path | Action | Responsibility | +|---|---|---| +| `cli/src/dpop-helpers.ts` | **Create** | DPoP key-pair generation, PEM loading, algorithm resolution | +| `cli/tests/dpop-helpers.spec.ts` | **Create** | Unit tests for the helpers | +| `cli/src/cli.ts` | **Modify** | Option definitions, enablement logic, wiring into encrypt/decrypt | + +--- + +### Task 1: Write failing tests + +**Files:** +- Create: `cli/tests/dpop-helpers.spec.ts` + +- [ ] **Step 1.1: Create the test file** + +```typescript +// cli/tests/dpop-helpers.spec.ts +import { expect } from '@esm-bundle/chai'; +import { + derToPem, + generateEphemeralDPoPKeyPair, + resolveDPoPKeyPair, +} from '../src/dpop-helpers.js'; + +describe('derToPem', function () { + it('wraps DER bytes in PEM armor with the given type', function () { + const der = new Uint8Array([0x01, 0x02, 0x03]); + const pem = derToPem(der, 'PUBLIC KEY'); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + expect(pem).to.include('AQID'); // base64 of [1,2,3] + }); + + it('wraps an ArrayBuffer in PEM armor', function () { + const der = new Uint8Array([0x01, 0x02]).buffer; + const pem = derToPem(der, 'PRIVATE KEY'); + expect(pem).to.include('-----BEGIN PRIVATE KEY-----'); + expect(pem).to.include('-----END PRIVATE KEY-----'); + }); +}); + +describe('generateEphemeralDPoPKeyPair', function () { + it('generates ES256 (ec:secp256r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES256'); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('generates ES384 (ec:secp384r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES384'); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('generates ES512 (ec:secp521r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES512'); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('generates RS256 (rsa:2048) key pair', async function () { + this.timeout(15_000); + const kp = await generateEphemeralDPoPKeyPair('RS256'); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws on unknown algorithm', async function () { + try { + await generateEphemeralDPoPKeyPair('HS256'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); +}); + +describe('resolveDPoPKeyPair', function () { + it('returns undefined when both alg and keyPath are undefined', async function () { + const result = await resolveDPoPKeyPair(undefined, undefined); + expect(result).to.be.undefined; + }); + + it('returns an ES256 key pair when alg is ES256', async function () { + const result = await resolveDPoPKeyPair('ES256', undefined); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); +}); +``` + +- [ ] **Step 1.2: Verify tests fail (module not found)** + +```bash +cd cli && npm run build 2>&1 | tail -5 +``` + +Expected: TypeScript error — `Cannot find module '../src/dpop-helpers.js'` + +--- + +### Task 2: Implement `cli/src/dpop-helpers.ts` + +**Files:** +- Create: `cli/src/dpop-helpers.ts` + +- [ ] **Step 2.1: Create the implementation file** + +```typescript +// cli/src/dpop-helpers.ts +import { readFile } from 'node:fs/promises'; +import { type KeyPair, WebCryptoService } from '@opentdf/sdk/singlecontainer'; +import { CLIError } from './logger.js'; + +const VALID_DPOP_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512'] as const; +export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; + +const EC_CURVE_MAP: Record = { + ES256: 'P-256', + ES384: 'P-384', + ES512: 'P-521', +}; + +/** Convert a DER buffer to a PEM string with the given type label. */ +export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { + const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; + const b64 = btoa(String.fromCharCode(...bytes)); + const lines = b64.match(/.{1,64}/g)!.join('\n'); + return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; +} + +/** + * Generate an ephemeral DPoP key pair for the given JWS algorithm. + * ES256/ES384/ES512 → ECDSA key via WebCrypto + SDK import. + * RS256/RS384/RS512 → RSA-2048 via SDK's generateSigningKeyPair() (all map to RS256 in DPoP proof). + */ +export async function generateEphemeralDPoPKeyPair(alg: string): Promise { + if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { + throw new CLIError( + 'CRITICAL', + `Unsupported DPoP algorithm: ${alg}. Valid values: ${VALID_DPOP_ALGS.join(', ')}` + ); + } + + const namedCurve = EC_CURVE_MAP[alg]; + if (namedCurve) { + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(privDer, 'PRIVATE KEY'); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + WebCryptoService.importPrivateKey!(privPem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; + } + + // RSA fallback — generateSigningKeyPair() produces RSA-2048 (DPoP maps this to RS256) + return WebCryptoService.generateSigningKeyPair(); +} + +/** + * Load a DPoP key pair from a PKCS8 PEM-encoded private key file. + * Derives the public key from the private key via JWK round-trip. + * Supports ECDSA (P-256, P-384, P-521) and RSA (PKCS1-v1_5 SHA-256). + */ +export async function loadDPoPKeyPairFromPem(pemPath: string): Promise { + let privatePem: string; + try { + privatePem = await readFile(pemPath, 'utf8'); + } catch (err) { + throw new CLIError('CRITICAL', `Cannot read DPoP key file: ${pemPath}`, err as Error); + } + + const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n]/g, ''); + const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + + // Try EC curves (P-256, P-384, P-521) + for (const namedCurve of ['P-256', 'P-384', 'P-521']) { + try { + const privCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'ECDSA', namedCurve }, + true, + ['sign'] + ); + return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + } catch { + // wrong curve or not an EC key — try next + } + } + + // Try RSA (PKCS1-v1_5 SHA-256) + try { + const privCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['sign'] + ); + return await buildKeyPairFromCryptoKey(privatePem, privCK, { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + } catch { + // not RSA either + } + + throw new CLIError( + 'CRITICAL', + `Cannot parse DPoP key from ${pemPath}: expected PKCS8 PEM with ECDSA (P-256/P-384/P-521) or RSA private key` + ); +} + +/** + * Derive the public key from an already-imported private CryptoKey via JWK round-trip, + * then import both through the SDK to get the opaque KeyPair type. + */ +async function buildKeyPairFromCryptoKey( + privatePem: string, + privCK: CryptoKey, + algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams +): Promise { + // Export private key as JWK; strip private components to build the public JWK + const privJwk = await crypto.subtle.exportKey('jwk', privCK); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, ...pubJwkProps } = privJwk; + const pubJwk: JsonWebKey = { ...pubJwkProps, key_ops: ['verify'] }; + + const pubCK = await crypto.subtle.importKey('jwk', pubJwk, algorithm, true, ['verify']); + const pubDer = await crypto.subtle.exportKey('spki', pubCK); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + + const [privateKey, publicKey] = await Promise.all([ + WebCryptoService.importPrivateKey!(privatePem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +/** + * Main entry point: resolve a DPoP KeyPair from CLI arguments. + * Returns undefined if DPoP is not requested. + */ +export async function resolveDPoPKeyPair( + alg: string | undefined, + keyPath: string | undefined +): Promise { + if (keyPath) { + return loadDPoPKeyPairFromPem(keyPath); + } + if (alg) { + return generateEphemeralDPoPKeyPair(alg); + } + return undefined; +} +``` + +- [ ] **Step 2.2: Run tests to verify they pass** + +```bash +cd cli && npm test 2>&1 | tail -20 +``` + +Expected: All `dpop-helpers` tests pass. The logger tests also still pass. + +- [ ] **Step 2.3: Commit** + +```bash +cd cli && git add src/dpop-helpers.ts tests/dpop-helpers.spec.ts && git commit -m "feat(cli): add DPoP key pair helpers (DSPX-3397)" +``` + +--- + +### Task 3: Update CLI option definitions in `cli.ts` + +**Files:** +- Modify: `cli/src/cli.ts` + +- [ ] **Step 3.1: Add import for dpop helpers and `readFile`** + +At the top of `cli/src/cli.ts`, change: + +```typescript +// Before: +import { type KeyPair } from '@opentdf/sdk/singlecontainer'; + +// After: +import { type KeyPair } from '@opentdf/sdk/singlecontainer'; +import { resolveDPoPKeyPair } from './dpop-helpers.js'; +``` + +- [ ] **Step 3.2: Replace the `--dpop` boolean option with a string option, add `--dpop-key`** + +Find this block (around line 320 in the global options): + +```typescript + .option('dpop', { + group: 'Security:', + desc: 'Use DPoP for token binding', + type: 'boolean', + }) +``` + +Replace with: + +```typescript + .option('dpop', { + group: 'Security:', + desc: 'Enable DPoP token binding. Optional value selects algorithm: ES256 (default), ES384, ES512, RS256. Use --dpop=ES512 to specify.', + type: 'string', + }) + .option('dpopKey', { + alias: 'dpop-key', + group: 'Security:', + desc: 'Path to PEM-encoded PKCS8 private key for DPoP signing. Enables DPoP alone if --dpop is omitted.', + type: 'string', + }) +``` + +- [ ] **Step 3.3: Build to verify no type errors** + +```bash +cd cli && npm run build 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: No errors. (TypeScript will now treat `argv.dpop` as `string | undefined` instead of `boolean | undefined` — we'll fix the call sites in the next tasks.) + +--- + +### Task 4: Wire DPoP into the `encrypt` command + +**Files:** +- Modify: `cli/src/cli.ts` — the `encrypt` command handler + +- [ ] **Step 4.1: Add DPoP enablement logic and key resolution before creating `OpenTDF`** + +Find the `encrypt` command handler (around line 600). It currently starts like: + +```typescript + async (argv) => { + log('DEBUG', 'Running encrypt command'); + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + disableDPoP: !argv.dpop, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +Replace with: + +```typescript + async (argv) => { + log('DEBUG', 'Running encrypt command'); + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + + const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +- [ ] **Step 4.2: Build to verify** + +```bash +cd cli && npm run build 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: No errors. + +--- + +### Task 5: Wire DPoP into the `decrypt` command + +**Files:** +- Modify: `cli/src/cli.ts` — the `decrypt` command handler + +- [ ] **Step 5.1: Add DPoP enablement logic and fix DPoP assertions** + +Find the `decrypt` command handler. It currently starts with: + +```typescript + async (argv) => { + log('DEBUG', 'Running decrypt command'); + const allowedKases = argv.allowList?.split(','); + log('DEBUG', `Allowed KASes: ${allowedKases}`); + const ignoreAllowList = !!argv.ignoreAllowList; + if (!argv.oidcEndpoint) { + throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); + } + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + defaultReadOptions: { + allowedKASEndpoints: allowedKases, + ignoreAllowlist: ignoreAllowList, + noVerify: !!argv.noVerifyAssertions, + }, + disableDPoP: !argv.dpop, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +Replace with: + +```typescript + async (argv) => { + log('DEBUG', 'Running decrypt command'); + const allowedKases = argv.allowList?.split(','); + log('DEBUG', `Allowed KASes: ${allowedKases}`); + const ignoreAllowList = !!argv.ignoreAllowList; + if (!argv.oidcEndpoint) { + throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); + } + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + + const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + defaultReadOptions: { + allowedKASEndpoints: allowedKases, + ignoreAllowlist: ignoreAllowList, + noVerify: !!argv.noVerifyAssertions, + }, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +- [ ] **Step 5.2: Fix DPoP token assertions in the decrypt command** + +In the same `decrypt` handler, find the two DPoP assertion lines (inside the `for` loop over headers and after it). Change both from `argv.dpop` to `dpopEnabled`: + +```typescript + // Before: + if (argv.dpop) { + console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); + } + + // After: + if (dpopEnabled) { + console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); + } +``` + +```typescript + // Before: + console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent'); + + // After: + console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); +``` + +- [ ] **Step 5.3: Build to verify no remaining type errors** + +```bash +cd cli && npm run build 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: No errors. + +- [ ] **Step 5.4: Run all tests** + +```bash +cd cli && npm test 2>&1 | tail -20 +``` + +Expected: All tests pass (logger + dpop-helpers). + +- [ ] **Step 5.5: Commit** + +```bash +git add cli/src/cli.ts && git commit -m "feat(cli): add --dpop[=alg] and --dpop-key flags for DPoP support (DSPX-3397)" +``` + +--- + +### Task 6: Smoke test and final push + +**Files:** none changed + +- [ ] **Step 6.1: Verify help output contains dpop** + +```bash +cd cli && node dist/src/cli.js encrypt --help | grep -i dpop +``` + +Expected output (both lines must appear): +``` + --dpop Enable DPoP token binding. Optional value selects algorithm... + --dpop-key Path to PEM-encoded PKCS8 private key for DPoP signing... +``` + +- [ ] **Step 6.2: Verify `supports dpop` exits 0** + +```bash +cd cli && node dist/src/cli.js supports dpop; echo "exit: $?" +``` + +Expected: `exit: 0` + +- [ ] **Step 6.3: Verify `--dpop` parses without error** + +```bash +cd cli && node dist/src/cli.js encrypt --dpop --help 2>&1 | grep -i dpop +``` + +Expected: no parse errors, dpop flags appear in help. + +- [ ] **Step 6.4: Push to remote** + +```bash +git push origin DSPX-3397-web-sdk +``` + +Expected: Push succeeds. Pre-commit hooks (prettier, eslint) run during the earlier commits — if they fail, run `npm run format && npm run lint` in `cli/` and re-commit. + +--- + +## Self-Review + +**Spec coverage:** +- `--dpop` (no value → ES256) ✓ Task 3 + `dpopAlg = argv.dpop || 'ES256'` +- `--dpop=` (specific algorithm) ✓ Task 3, yargs string type captures `=value` +- `--dpop-key ` (PEM key) ✓ Task 3, Task 2 `loadDPoPKeyPairFromPem` +- `--dpop-key` alone enables DPoP ✓ `dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey` +- Help text mentions "dpop" ✓ both option descriptions contain the word +- Wire into existing interceptor ✓ `dpopKeys` passed to `OpenTDF` which feeds the existing `authTokenDPoPInterceptor` +- Don't reimplement nonce-retry ✓ interceptor unchanged +- No new auth client ✓ +- `npm run build` + `npm test` verification ✓ Task 2 and Task 6 +- Smoke `grep -i dpop` ✓ Task 6 step 1 +- `feat(cli):` commit convention ✓ Task 5 step 5 commit message + +**Placeholder scan:** None found. + +**Type consistency:** +- `resolveDPoPKeyPair(alg, keyPath)` — defined in Task 2, used identically in Tasks 4 and 5 ✓ +- `dpopAlg`, `dpopEnabled`, `dpopKeyPair` — defined and used within the same handler in each task ✓ +- `WebCryptoService.importPrivateKey!` — non-null assertion consistent in both usages within `dpop-helpers.ts` ✓ diff --git a/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md b/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md new file mode 100644 index 000000000..95a694fb7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md @@ -0,0 +1,114 @@ +# DPoP CLI Flags Design — DSPX-3397 (web-sdk slice) + +**Date:** 2026-06-09 +**Branch:** DSPX-3397-web-sdk +**Scope:** `cli/src/cli.ts` only — no SDK core changes + +--- + +## Background + +The branch already ships `lib/src/auth/dpop-nonce.ts` (nonce cache) and `lib/src/auth/interceptors.ts` (`authTokenDPoPInterceptor` with 401-retry). The CLI already has `--dpop` as a boolean and wires `disableDPoP: !argv.dpop` into `OpenTDF`. However, it always falls back to RSA-2048 key generation (not ES256 as RFC 9449 §4.2 requires by default) and has no way to supply a custom PEM key. + +--- + +## Flags + +| Flag | Yargs config | Semantics | +|---|---|---| +| `--dpop[=alg]` | `type: 'string'`, group `Security:` | `--dpop` → enable with ES256 (empty string → default). `--dpop=ES512` → enable with specific alg. Omitted → DPoP disabled. | +| `--dpop-key ` | `type: 'string'`, alias `dpop-key`, group `Security:` | PEM-encoded private key file. Enables DPoP alone (algorithm inferred from key type). | + +Supported algorithm values: `ES256`, `ES384`, `ES512`, `RS256`, `RS384`, `RS512`. RS384/RS512 are accepted but the SDK's `determineJWSAlgorithmFromKeyInfo` maps all RSA keys to RS256 — document in help. + +Help text for both flags contains the word "dpop" so `grep -i dpop` matches. + +--- + +## DPoP Enablement Logic + +```ts +// Normalize the --dpop flag: '' (flag with no value) → 'ES256' +const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); +const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; +``` + +--- + +## Key Pair Resolution + +A single async helper `resolveDPoPKeyPair(alg, keyPath)` in `cli.ts`: + +### Auto-generated keys + +- **EC (ES256/ES384/ES512):** `crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, ['sign','verify'])` → export PKCS8/SPKI PEM → `WebCryptoService.importPrivateKey/importPublicKey(pem, { usage: 'sign' })` +- **RSA (RS256/RS384/RS512):** `WebCryptoService.generateSigningKeyPair()` (existing, returns RSA-2048) + +### PEM key from file (`--dpop-key`) + +1. Read the file +2. Strip PEM armor, decode DER +3. Try `crypto.subtle.importKey('pkcs8', der, { name: 'ECDSA', namedCurve }, true, ['sign'])` for each curve (P-256, P-384, P-521), then RSA fallback +4. Export successful import as JWK; strip private components (`d`, `p`, `q`, `dp`, `dq`, `qi`); import public JWK; export as SPKI PEM +5. Import both keys through `WebCryptoService.importPrivateKey/importPublicKey(pem, { usage: 'sign' })` to get the opaque `KeyPair` + +Algorithm of the loaded key is inferred automatically (the SDK's `importPrivateKey` reads the OID). + +--- + +## OpenTDF Constructor Changes + +Same pattern in both `encrypt` and `decrypt` handlers: + +```ts +const dpopKeyPair = dpopEnabled + ? await resolveDPoPKeyPair(dpopAlg, argv.dpopKey) + : undefined; + +const client = new OpenTDF({ + ...existingOptions, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, +}); +``` + +The existing interceptor in the SDK then uses these keys for every request, including the 401-nonce retry flow. + +--- + +## Type Change: `--dpop` boolean → string + +`argv.dpop` changes from `boolean | undefined` to `string | undefined`. Two places in the decrypt handler need updating: + +```ts +// Before +console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); // guarded by if (argv.dpop) +console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent'); + +// After (use dpopEnabled instead of argv.dpop) +console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); // guarded by if (dpopEnabled) +console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); +``` + +--- + +## Validation + +- Unknown algorithm string → `CLIError` before key generation +- PEM file not found / unparseable → `CLIError` with path in message +- `--dpop-key` with a valid PEM overrides the algorithm from `--dpop` (key type wins) + +--- + +## Verification Steps + +1. `npm run build` from `cli/` — must succeed +2. `npm test` — existing logger tests must pass +3. `npx @opentdf/ctl encrypt --help | grep -i dpop` — must show both `--dpop` and `--dpop-key` +4. `node dist/src/cli.js supports dpop; echo $?` — must print `0` + +--- + +## Files Changed + +- `cli/src/cli.ts` — only file touched diff --git a/lib/src/access.ts b/lib/src/access.ts index 5f2b11cd0..9ec3dd3e2 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -2,6 +2,7 @@ import { type AuthConfig, resolveAuthConfig } from './auth/interceptors.js'; import { RewrapResponse } from './platform/kas/kas_pb.js'; import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js'; import { base64 } from './encodings/index.js'; +import { InvalidFileError, PermissionDeniedError, UnauthenticatedError } from './errors.js'; import { fetchKasBasePubKey, @@ -41,32 +42,69 @@ export async function fetchWrappedKey( fulfillableObligationFQNs: string[] ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); + // Pass the original AuthConfig (not just its interceptors) so the RPC layer can + // recover the provider's per-client DPoP nonce cache and keep the transport's + // nonce capture and the interceptor's retry on the same instance (RFC 9449 §9). const rpcCall = () => fetchWrappedKeysRpc( platformUrl, signedRequestToken, - { interceptors }, + auth, rewrapAdditionalContextHeader(fulfillableObligationFQNs) ); // When no AuthProvider is available, skip the legacy fallback so the real - // RPC error propagates instead of being masked by tryPromisesUntilFirstSuccess. + // RPC error propagates instead of being masked. if (!authProvider) { return await rpcCall(); } - return await tryPromisesUntilFirstSuccess( - rpcCall, + // Try the modern Connect-RPC rewrap first. + try { + return await rpcCall(); + } catch (rpcError) { + // A definitive auth/validation answer from KAS (401/403/400 — including a + // post-nonce-challenge 401, RFC 9449 §9) must surface as-is. Falling back to + // the legacy REST endpoint here would mask it with a 404 on Connect-only + // platforms. + if (isRewrapAuthError(rpcError)) { + throw rpcError; + } + // Otherwise (transport/network error, or a platform old enough to be missing + // the Connect rewrap endpoint) fall back to the legacy REST rewrap for + // backwards compatibility. If that also fails, surface the original RPC error + // rather than the legacy 404. // We intentionally do not provide the rewrap additional context to legacy requests destined for older platforms. // Platforms new enough to have knowledge of obligations will be handling RPC requests successfully. - () => - fetchWrappedKeysLegacy( + console.info('v2 rewrap request error', rpcError); + try { + return (await fetchWrappedKeysLegacy( url, { signedRequestToken }, authProvider - ) as unknown as Promise + )) as unknown as RewrapResponse; + } catch (legacyError) { + // Surface the (more meaningful) RPC error, but don't silently drop the + // legacy failure — log it so the fallback path is debuggable. + console.info('legacy rewrap fallback also failed', legacyError); + throw rpcError; + } + } +} + +/** + * An auth/validation error from the RPC rewrap represents a definitive answer + * from KAS and must not be masked by the legacy REST fallback (which 404s on + * Connect-only platforms). Other errors (network failures, or an old platform + * missing the Connect endpoint) remain eligible for the legacy fallback. + */ +function isRewrapAuthError(e: unknown): boolean { + return ( + e instanceof UnauthenticatedError || + e instanceof PermissionDeniedError || + e instanceof InvalidFileError ); } @@ -175,9 +213,11 @@ export async function fetchKeyAccessServers( platformUrl: string, auth: AuthConfig ): Promise { - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); - const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, { interceptors }); + // Pass the original AuthConfig so the RPC layer shares the provider's per-client + // DPoP nonce cache with the transport (see fetchWrappedKey). + const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, auth); if (!authProvider) { return await rpcCall(); diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index f25627e29..6c6b8c01e 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,5 +1,10 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; -import { type AuthProvider } from '../auth/auth.js'; +import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; +import { + adoptChallengeNonce, + defaultNonceCache, + warmNonceFromResponse, +} from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -10,6 +15,67 @@ import { } from '../errors.js'; import { validateSecureUrl } from '../utils.js'; +/** fetch() options shared by the authenticated legacy requests. */ +type FetchInit = Omit; + +/** + * Signs `httpReq` via the AuthProvider, sends it, and handles a single + * DPoP-Nonce challenge (RFC 9449 §9): if a resource server rejects the request + * with a fresh `DPoP-Nonce` header, cache the nonce and retry once so + * `withCreds` can mint a proof carrying it. Non-DPoP providers and servers + * never emit a `DPoP-Nonce`, so they take the single-request path unchanged. + * + * The caller keeps ownership of status-code handling; this only owns transport + * and the nonce retry. + */ +async function fetchWithCredsAndNonceRetry( + authProvider: AuthProvider, + httpReq: HttpRequest, + init: FetchInit, + networkErrorMessage: string +): Promise { + const send = async (): Promise => { + const req = await authProvider.withCreds(httpReq); + try { + return await fetch(req.url, { + ...init, + method: req.method, + headers: req.headers, + body: req.body as BodyInit, + }); + } catch (e) { + throw new NetworkError(`${networkErrorMessage} [${req.url}]`, e); + } + }; + + // Use the provider's per-client cache so the retry proof carries the nonce + // withCreds reads back (falls back to the shared default for custom providers). + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + + let origin: string | undefined; + try { + origin = new URL(httpReq.url).origin; + } catch { + // Non-absolute URL: nonce caching is keyed by origin, so just pass through. + } + + let response = await send(); + + if (!response.ok && origin) { + const sentNonce = nonceCache.get(origin); + if (adoptChallengeNonce(nonceCache, origin, response.headers, sentNonce)) { + response = await send(); + } + } + + // Keep the cache warm from whichever response we end on. + if (origin) { + warmNonceFromResponse(nonceCache, origin, response.headers); + } + + return response; +} + export type RewrapRequest = { signedRequestToken: string; }; @@ -33,53 +99,43 @@ export async function fetchWrappedKey( requestBody: RewrapRequest, authProvider: AuthProvider ): Promise { - const req = await authProvider.withCreds({ - url, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - let response: Response; - - try { - response = await fetch(req.url, { - method: req.method, + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + } as HttpRequest, + { mode: 'cors', // no-cors, *cors, same-origin cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached credentials: 'same-origin', // include, *same-origin, omit - headers: req.headers, redirect: 'follow', // manual, *follow, error 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); - } + }, + 'unable to fetch wrapped key from' + ); if (!response.ok) { switch (response.status) { case 400: throw new InvalidFileError( - `400 for [${req.url}]: rewrap bad request [${await response.text()}]` + `400 for [${url}]: rewrap bad request [${await response.text()}]` ); case 401: - throw new UnauthenticatedError(`401 for [${req.url}]; rewrap auth failure`); + throw new UnauthenticatedError(`401 for [${url}]; rewrap auth failure`); case 403: - throw new PermissionDeniedError( - `403 for [${req.url}]; rewrap permission denied: forbidden` - ); + throw new PermissionDeniedError(`403 for [${url}]; rewrap permission denied: forbidden`); default: if (response.status >= 500) { throw new ServiceError( - `${response.status} for [${req.url}]: rewrap failure due to service error [${await response.text()}]` + `${response.status} for [${url}]: rewrap failure due to service error [${await response.text()}]` ); } - throw new NetworkError( - `${req.method} ${req.url} => ${response.status} ${response.statusText}` - ); + throw new NetworkError(`POST ${url} => ${response.status} ${response.statusText}`); } } @@ -93,32 +149,29 @@ export async function fetchKeyAccessServers( let nextOffset = 0; const allServers = []; do { - const req = await authProvider.withCreds({ - url: `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`, - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - let response: Response; - try { - response = await fetch(req.url, { - method: req.method, - headers: req.headers, - body: req.body as BodyInit, + const requestUrl = `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`; + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url: requestUrl, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } as HttpRequest, + { mode: 'cors', cache: 'no-cache', credentials: 'same-origin', redirect: 'follow', referrerPolicy: 'no-referrer', - }); - } catch (e) { - throw new NetworkError(`unable to fetch kas list from [${req.url}]`, e); - } + }, + 'unable to fetch kas list from' + ); // if we get an error from the kas registry, throw an error if (!response.ok) { throw new ServiceError( - `unable to fetch kas list from [${req.url}], status: ${response.status}` + `unable to fetch kas list from [${requestUrl}], status: ${response.status}` ); } const { keyAccessServers = [], pagination = {} } = await response.json(); diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index 56c106e9d..b4b350dff 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -7,6 +7,7 @@ import { } from '../access.js'; import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js'; +import { isAuthProvider } from '../auth/auth.js'; import { ConfigurationError, InvalidFileError, @@ -41,7 +42,14 @@ export async function fetchWrappedKey( rewrapAdditionalContextHeader?: string ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl }); + // Share the provider's per-client nonce cache so the transport's nonce capture + // and the auth interceptor's retry read the same instance (RFC 9449 §9). + const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined; + const platform = new PlatformClient({ + interceptors: resolveInterceptors(auth), + platformUrl, + nonceCache, + }); const options: CallOptions = {}; if (rewrapAdditionalContextHeader) { options.headers = { @@ -129,7 +137,13 @@ export async function fetchKeyAccessServers( ): Promise { let nextOffset = 0; const allServers = []; - const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl }); + // Share the provider's per-client nonce cache (see fetchWrappedKey above). + const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined; + const platform = new PlatformClient({ + interceptors: resolveInterceptors(auth), + platformUrl, + nonceCache, + }); do { let response: ListKeyAccessServersResponse; diff --git a/lib/src/auth/auth.ts b/lib/src/auth/auth.ts index 7405b3a40..6482a6c06 100644 --- a/lib/src/auth/auth.ts +++ b/lib/src/auth/auth.ts @@ -4,6 +4,7 @@ import { type PrivateKey, } from '../../tdf3/src/crypto/declarations.js'; import { signJwt, type JwtHeader, type JwtPayload } from '../../tdf3/src/crypto/jwt.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; export type HttpMethod = | 'GET' @@ -110,6 +111,15 @@ export type AuthProvider = { * @param httpReq - Required. An http request pre-populated with the data public key. */ withCreds(httpReq: HttpRequest): Promise; + + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8), keyed by origin. Optional so that + * custom/legacy providers remain valid; consumers fall back to a shared default + * when it is absent. Providers created by this SDK expose the cache their own + * proofs read and write, so the auth interceptor and the transport share one + * instance. + */ + nonceCache?: DPoPNonceCache; }; export function isAuthProvider(a?: unknown): a is AuthProvider { diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts new file mode 100644 index 000000000..4d554f316 --- /dev/null +++ b/lib/src/auth/dpop-nonce.ts @@ -0,0 +1,142 @@ +/** + * DPoP-Nonce cache manager per RFC 9449 §8. + * Caches server-issued nonces by origin for use in subsequent DPoP proofs. + */ + +export class DPoPNonceCache { + private cache = new Map(); + + /** + * Get cached nonce for an origin. + */ + get(origin: string): string | undefined { + return this.cache.get(origin); + } + + /** + * Store a nonce for an origin. + * Overwrites any existing nonce for that origin. + */ + set(origin: string, nonce: string): void { + this.cache.set(origin, nonce); + } + + /** + * Clear nonce for an origin (e.g., when it's rejected by the server). + */ + clear(origin: string): void { + this.cache.delete(origin); + } + + /** + * Clear all cached nonces. Useful for test teardown. + */ + clearAll(): void { + this.cache.clear(); + } + + /** + * Extract DPoP-Nonce from response headers (case-insensitive). + */ + static extractNonce(headers?: Headers): string | undefined { + return typeof headers?.get === 'function' ? headers.get('dpop-nonce') || undefined : undefined; + } +} + +/** + * A `DPoP-Nonce` header source: a raw `Response`'s headers or a Connect error's + * metadata (both are `Headers`, whose `get` is case-insensitive). + */ +type NonceHeaders = Headers | undefined; + +/** + * Given a response's headers, return a *fresh* challenge nonce that differs from + * the one we just sent (`sentNonce`), recording it in `cache`. Returns + * `undefined` when there is no nonce or it matches what we already used — i.e. + * when the caller should NOT retry. RFC 9449 §9. + */ +export function adoptChallengeNonce( + cache: DPoPNonceCache, + origin: string, + headers: NonceHeaders, + sentNonce: string | undefined +): string | undefined { + const challenge = DPoPNonceCache.extractNonce(headers); + if (challenge && challenge !== sentNonce) { + cache.set(origin, challenge); + return challenge; + } + return undefined; +} + +/** + * Connect-error variant of {@link adoptChallengeNonce}. The transport `fetch` + * wrapper usually records the nonce off the raw 401, but Connect errors don't + * reliably surface response headers, so we also consult the cache and the error + * metadata. Returns a fresh nonce to retry with, or `undefined`. + */ +export function adoptChallengeNonceFromConnectError( + cache: DPoPNonceCache, + origin: string, + metadata: NonceHeaders, + sentNonce: string | undefined +): string | undefined { + const serverNonce = cache.get(origin) ?? DPoPNonceCache.extractNonce(metadata); + if (serverNonce && serverNonce !== sentNonce) { + cache.set(origin, serverNonce); + return serverNonce; + } + return undefined; +} + +/** + * Warm the cache from a response's `DPoP-Nonce` header (RFC 9449 §8). No-op when + * the response carries no nonce. + */ +export function warmNonceFromResponse( + cache: DPoPNonceCache, + origin: string, + headers: NonceHeaders +): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (nonce) { + cache.set(origin, nonce); + } +} + +/** + * Fallback nonce cache used when a caller (e.g. a custom/legacy `AuthProvider`, + * or the interceptor-only wiring) does not supply its own. SDK-built providers + * each own a per-client {@link DPoPNonceCache} instead, so nonces don't leak + * across clients; this shared instance only backs the paths that opt out of that. + */ +export const defaultNonceCache = new DPoPNonceCache(); + +/** + * @deprecated Prefer a per-client {@link DPoPNonceCache} (SDK providers expose + * one via `nonceCache`). Retained as an alias of {@link defaultNonceCache} for + * backwards compatibility — it is the *same object*, not a second cache. + */ +export const globalNonceCache = defaultNonceCache; + +/** + * Record a `DPoP-Nonce` response header into `cache`, keyed by the request's origin. + * + * This works directly off the raw `Response`, so it captures the nonce even when + * a transport (e.g. Connect-RPC) does not surface response headers on its error + * type. Some resource servers (KAS) reject a proof minted without a nonce with a + * raw HTTP 401 carrying `DPoP-Nonce` + `WWW-Authenticate: DPoP error="use_dpop_nonce"` + * (RFC 9449 §9); capturing here lets the auth layer mint a nonce-bearing proof on + * retry. + */ +export function captureNonce(cache: DPoPNonceCache, requestUrl: string, headers?: Headers): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (!nonce) { + return; + } + try { + cache.set(new URL(requestUrl).origin, nonce); + } catch { + // Non-absolute URL: the nonce cache is origin-keyed, so nothing to store. + } +} diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index 61a6ae0ec..2430088fe 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -7,6 +7,7 @@ import type { PrivateKey, AsymmetricSigningAlgorithm, } from '../../tdf3/src/crypto/declarations.js'; +import { derToIeeeP1363 } from '../../tdf3/src/crypto/core/signing.js'; export type JsonObject = { [Key in string]?: JsonValue }; export type JsonArray = JsonValue[]; @@ -19,11 +20,11 @@ function buf(input: string): Uint8Array { return encoder.encode(input); } -interface DPoPJwtHeaderParameters { +type DPoPJwtHeaderParameters = { alg: JWSAlgorithm; typ: string; jwk: JsonWebKey; -} +}; /** * Minimal JWT sign() implementation using CryptoService. @@ -35,11 +36,16 @@ async function jwt( cryptoService: CryptoService ) { const input = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(claimsSet)))}`; - const signature = await cryptoService.sign( - buf(input), - privateKey, - header.alg as AsymmetricSigningAlgorithm - ); + const alg = header.alg as AsymmetricSigningAlgorithm; + let signature = await cryptoService.sign(buf(input), privateKey, alg); + // JWS requires raw IEEE P1363 (R || S) for ECDSA per RFC 7518 §3.4, but + // cryptoService.sign currently returns DER. Convert here so DPoP proofs are + // accepted by RFC-conformant verifiers (Keycloak, panva-jose). RSA/EdDSA + // signatures are already raw bytes — no conversion. See DSPX-3634 for the + // broader cleanup that would make this transform unnecessary. + if (alg.startsWith('ES')) { + signature = derToIeeeP1363(signature, alg); + } return `${input}.${b64u(signature)}`; } diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c0a0f7971..8791b3aac 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -1,10 +1,16 @@ -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; export type { Interceptor } from '@connectrpc/connect'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js'; import DPoP from './dpop.js'; import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; +import { + adoptChallengeNonceFromConnectError, + DPoPNonceCache, + defaultNonceCache, + warmNonceFromResponse, +} from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -22,6 +28,12 @@ export type DPoPInterceptorOptions = { dpopKeys?: KeyPair | Promise; /** CryptoService for signing. Defaults to DefaultCryptoService. */ cryptoService?: CryptoService; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8). Defaults to the shared + * {@link defaultNonceCache}; pass the same instance to `PlatformClient` for + * strict per-client isolation on the interceptor-only path. + */ + nonceCache?: DPoPNonceCache; }; /** @@ -78,6 +90,7 @@ export function authTokenInterceptor(tokenProvider: TokenProvider): Interceptor */ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPInterceptor { const cryptoService = options.cryptoService ?? DefaultCryptoService; + const nonceCache = options.nonceCache ?? defaultNonceCache; const dpopKeysPromise: Promise = options.dpopKeys ? Promise.resolve(options.dpopKeys) : cryptoService.generateSigningKeyPair(); @@ -86,19 +99,60 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const [token, keys] = await Promise.all([options.tokenProvider(), dpopKeysPromise]); const url = new URL(req.url); - const httpUri = `${url.origin}${url.pathname}`; + const origin = url.origin; + const httpUri = `${origin}${url.pathname}`; + + // Check for cached nonce + const cachedNonce = nonceCache.get(origin); // Generate DPoP proof JWT for this request - const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST'); + const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST', cachedNonce, token); // Export public key PEM for X-VirtruPubKey header const publicKeyPem = await cryptoService.exportPublicKeyPem(keys.publicKey); - req.header.set('Authorization', `Bearer ${token}`); + req.header.set('Authorization', `DPoP ${token}`); req.header.set('DPoP', dpopProof); req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem)); - return next(req); + // Call next and handle DPoP-Nonce retry + try { + const response = await next(req); + warmNonceFromResponse(nonceCache, origin, response.header); + return response; + } catch (err) { + // A Connect Unauthenticated error may carry a DPoP-Nonce challenge. The + // transport's fetch wrapper records the nonce from the raw 401 response + // into the cache (Connect errors don't reliably surface response headers); + // error metadata is a fallback for transports that do expose it. + if (err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = adoptChallengeNonceFromConnectError( + nonceCache, + origin, + err.metadata, + cachedNonce + ); + if (serverNonce) { + // Regenerate proof with the server nonce and retry once. + const retryDpopProof = await DPoP( + keys, + cryptoService, + httpUri, + 'POST', + serverNonce, + token + ); + req.header.set('DPoP', retryDpopProof); + + const retryResponse = await next(req); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); + return retryResponse; + } + } + + // Re-throw if not a nonce challenge or retry failed + throw err; + } }; // Attach dpopKeys to the interceptor function @@ -121,38 +175,93 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI */ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor { return (next) => async (req) => { - const url = new URL(req.url); - const pathOnly = url.pathname; - // Signs only the path of the url in the request - let token; - try { - token = await authProvider.withCreds({ - url: pathOnly, - method: 'POST', - // Start with any headers Connect already has - headers: { - ...Object.fromEntries(req.header.entries()), - 'Content-Type': 'application/json', - }, + // Pass the full request URL to withCreds. DPoP-enabled providers need the + // absolute URL to compute the proof's `htu` claim and the origin for the + // nonce cache; `new URL()` on a bare path throws "Invalid URL". Non-DPoP + // providers ignore the URL (they only add a Bearer header), so this stays + // backwards-compatible with legacy AuthProviders. + + // Re-sign the request via withCreds and apply the resulting headers. Called + // once normally, and again on a DPoP-Nonce challenge so the provider mints a + // fresh proof carrying the server-issued nonce (read from nonceCache). + const sign = async (): Promise => { + let token; + try { + token = await authProvider.withCreds({ + url: req.url, + method: 'POST', + // Start with any headers Connect already has + headers: { + ...Object.fromEntries(req.header.entries()), + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { + throw new Error( + 'PlatformClient: DPoP key binding is not complete. ' + + 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + + '`await client.ready` before constructing PlatformClient. ' + + `Original error: ${msg}` + ); + } + throw err; + } + + Object.entries(token.headers).forEach(([key, value]) => { + req.header.set(key, value); }); + }; + + // Share the provider's per-client cache so the nonce withCreds embeds and the + // one we read back on a 401 are the same instance (falls back to the shared + // default for custom providers that don't expose one). + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + + let origin: string | undefined; + try { + origin = new URL(req.url).origin; + } catch { + // Non-absolute URL: nonce caching is keyed by origin, so just pass through. + } + + await sign(); + // Snapshot the nonce we just signed with (withCreds reads it from the cache) + // so a 401 can tell us whether the server handed back a *new* one to retry. + const sentNonce = origin ? nonceCache.get(origin) : undefined; + + try { + const response = await next(req); + // Keep the nonce cache warm from successful responses (RFC 9449 §8). + if (origin) { + warmNonceFromResponse(nonceCache, origin, response.header); + } + return response; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { - throw new Error( - 'PlatformClient: DPoP key binding is not complete. ' + - 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + - '`await client.ready` before constructing PlatformClient. ' + - `Original error: ${msg}` + // A DPoP resource server rejects a proof minted without (or with a stale) + // nonce by returning Unauthenticated with a fresh `DPoP-Nonce`. The + // transport's fetch wrapper captures that header from the raw response into + // the cache (Connect errors don't reliably surface response headers); we + // also fall back to error metadata. Re-sign so withCreds embeds the nonce + // and retry once (RFC 9449 §9). Non-DPoP providers/servers never emit a + // DPoP-Nonce, so this is a no-op for them. + if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = adoptChallengeNonceFromConnectError( + nonceCache, + origin, + err.metadata, + sentNonce ); + if (serverNonce) { + await sign(); + const retryResponse = await next(req); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); + return retryResponse; + } } throw err; } - - Object.entries(token.headers).forEach(([key, value]) => { - req.header.set(key, value); - }); - - return await next(req); }; } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 8d3629bae..d14a57b2b 100644 --- a/lib/src/auth/oidc-clientcredentials-provider.ts +++ b/lib/src/auth/oidc-clientcredentials-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ClientSecretCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -14,6 +15,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -29,6 +32,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -41,4 +46,9 @@ export class OIDCClientCredentialsProvider implements AuthProvider { async withCreds(httpReq: HttpRequest): Promise { return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index 2a7266882..0a706e888 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ExternalJwtCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -15,6 +16,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -30,6 +33,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -50,4 +55,9 @@ export class OIDCExternalJwtProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index 9f7bac2d9..42391ebb9 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type RefreshTokenCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -29,6 +30,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -44,6 +47,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -64,4 +69,9 @@ export class OIDCRefreshTokenProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index b842890e6..6490f4b0d 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,6 +5,7 @@ import { base64 } from '../encodings/index.js'; import { ConfigurationError, TdfError } from '../errors.js'; import { rstrip } from '../utils.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import { adoptChallengeNonce, DPoPNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -109,7 +110,18 @@ export class AccessToken { cryptoService: CryptoService; - constructor(cfg: OIDCCredentials, cryptoService: CryptoService, request?: typeof fetch) { + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8). Owned here — the interceptor and + * legacy fetch path read the same instance via the provider's `nonceCache`. + */ + readonly nonceCache: DPoPNonceCache; + + constructor( + cfg: OIDCCredentials, + cryptoService: CryptoService, + request?: typeof fetch, + nonceCache: DPoPNonceCache = new DPoPNonceCache() + ) { if (!cfg.clientId) { throw new ConfigurationError( 'A Keycloak client identifier is currently required for all auth mechanisms' @@ -137,6 +149,7 @@ export class AccessToken { this.userInfoEndpoint = cfg.oidcUserInfoEndpoint || `${this.baseUrl}/protocol/openid-connect/userinfo`; this.signingKey = cfg.signingKey; + this.nonceCache = nonceCache; } /** @@ -145,21 +158,57 @@ export class AccessToken { * @returns */ async info(accessToken: string): Promise { + const origin = new URL(this.userInfoEndpoint).origin; const headers = { ...this.extraHeaders, - Authorization: `Bearer ${accessToken}`, } as Record; + let cachedNonce: string | undefined; if (this.config.dpopEnabled && this.signingKey) { + cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, this.userInfoEndpoint, - 'POST' + 'GET', + cachedNonce, + accessToken ); + headers.Authorization = `DPoP ${accessToken}`; + } else { + headers.Authorization = `Bearer ${accessToken}`; } - const response = await (this.request || fetch)(this.userInfoEndpoint, { + let response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); + + // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. + if (this.config.dpopEnabled && this.signingKey && !response.ok) { + const challengeNonce = adoptChallengeNonce( + this.nonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { + headers.DPoP = await dpopFn( + this.signingKey, + this.cryptoService, + this.userInfoEndpoint, + 'GET', + challengeNonce, + accessToken + ); + response = await (this.request || fetch)(this.userInfoEndpoint, { + headers, + }); + } + } + + // Update nonce cache from final response + if (this.config.dpopEnabled) { + warmNonceFromResponse(this.nonceCache, origin, response.headers); + } + if (!response.ok) { console.error(await response.text()); throw new TdfError( @@ -171,11 +220,13 @@ export class AccessToken { } async doPost(url: string, o: Record) { + const origin = new URL(url).origin; const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }; // add DPoP headers if configured + let cachedNonce: string | undefined; if (this.config.dpopEnabled) { if (!this.signingKey) { throw new ConfigurationError('No signature configured'); @@ -185,13 +236,54 @@ export class AccessToken { // TODO: Rename to X-OpenTDF-PubKey; requires coordinated change with // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST'); + + cachedNonce = this.nonceCache.get(origin); + headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); } - return (this.request || fetch)(url, { + + const response = await (this.request || fetch)(url, { method: 'POST', headers, body: qstringify(o), }); + + // Handle DPoP-Nonce challenge. RFC 9449 §8: authorization servers return + // HTTP 400 with error=use_dpop_nonce; §9: resource servers return 401. + // Trigger on any non-OK response that carries a fresh DPoP-Nonce header. + if (this.config.dpopEnabled && !response.ok) { + const challengeNonce = adoptChallengeNonce( + this.nonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { + // Regenerate DPoP proof with the server-provided nonce and retry. + headers.DPoP = await dpopFn( + this.signingKey!, + this.cryptoService, + url, + 'POST', + challengeNonce + ); + + const retryResponse = await (this.request || fetch)(url, { + method: 'POST', + headers, + body: qstringify(o), + }); + + warmNonceFromResponse(this.nonceCache, origin, retryResponse.headers); + return retryResponse; + } + } + + // Update nonce cache from successful responses + if (this.config.dpopEnabled && response.ok) { + warmNonceFromResponse(this.nonceCache, origin, response.headers); + } + + return response; } async accessTokenLookup(cfg: OIDCCredentials) { @@ -296,6 +388,12 @@ export class AccessToken { delete this.cachedExpiry; delete this.inFlight; this.signingKey = signingKey; + // A DPoP-bound token (cnf.jkt) is tied to a specific key; rotating the + // signing key invalidates any cached token. Non-DPoP tokens are key- + // independent and can stay cached. + if (this.config.dpopEnabled) { + delete this.data; + } } /** @@ -333,16 +431,23 @@ export class AccessToken { } const accessToken = await this.get(); if (this.config.dpopEnabled && this.signingKey) { + const url = new URL(httpReq.url); + const origin = url.origin; + // RFC 9449 §4.2: the `htu` claim is the request URI without query and + // fragment. Resource servers (and the mock) recompute and compare it, so + // a proof carrying the query string is rejected. + const htu = `${origin}${url.pathname}`; + const cachedNonce = this.nonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, - httpReq.url, + htu, httpReq.method, - /* nonce */ undefined, + cachedNonce, accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}` }); } diff --git a/lib/src/auth/providers.ts b/lib/src/auth/providers.ts index d5893f609..b36d3134a 100644 --- a/lib/src/auth/providers.ts +++ b/lib/src/auth/providers.ts @@ -42,6 +42,8 @@ export const clientSecretAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -74,6 +76,8 @@ export const externalAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -104,6 +108,8 @@ export const refreshAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); diff --git a/lib/src/platform.ts b/lib/src/platform.ts index fdff544eb..afab6f8f5 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,6 +5,29 @@ export * as platformConnect from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; import type { AuthProvider } from '../tdf3/index.js'; import { authProviderInterceptor } from './auth/interceptors.js'; +import { captureNonce, DPoPNonceCache, defaultNonceCache } from './auth/dpop-nonce.js'; + +/** + * Build a `fetch` wrapper that records any `DPoP-Nonce` response header into + * `nonceCache` before handing the response back to the Connect transport. The + * Connect error type does not reliably surface response headers, so capturing at + * the transport layer is what lets the DPoP auth interceptors mint a + * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. `nonceCache` + * must be the same instance the auth interceptor reads. + */ +function makeNonceCapturingFetch(nonceCache: DPoPNonceCache): typeof globalThis.fetch { + return async (input, init) => { + const response = await fetch(input, init); + const requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + captureNonce(nonceCache, requestUrl, response.headers); + return response; + }; +} import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; @@ -54,6 +77,13 @@ export interface PlatformClientOptions { interceptors?: Interceptor[]; /** Base URL of the platform API. */ platformUrl: string; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8) for the transport's nonce capture. + * When an `authProvider` is supplied its own `nonceCache` is used; otherwise + * pass the same instance given to `authTokenDPoPInterceptor` for the + * interceptor-only path. Defaults to the shared {@link defaultNonceCache}. + */ + nonceCache?: DPoPNonceCache; } /** @@ -96,9 +126,14 @@ export class PlatformClient { interceptors.push(...options.interceptors); } + // Capture nonces into the same cache the auth interceptor reads: the auth + // provider's own cache when present, else the caller-supplied/default one. + const nonceCache = options.authProvider?.nonceCache ?? options.nonceCache ?? defaultNonceCache; + const transport = createConnectTransport({ baseUrl: options.platformUrl, interceptors, + fetch: makeNonceCapturingFetch(nonceCache), }); this.v1 = { diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c3b824f9d..9516472cd 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -40,10 +40,13 @@ function getSigningAlgorithmParams(algorithm: AsymmetricSigningAlgorithm): { } /** - * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format (used by JWT). + * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format. * RS256 signatures don't need conversion. */ -function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { +export function ieeeP1363ToDer( + signature: Uint8Array, + algorithm: AsymmetricSigningAlgorithm +): Uint8Array { if (algorithm === 'RS256') { return signature; } @@ -94,10 +97,17 @@ function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgor } /** - * Convert DER signature format (used by JWT) to IEEE P1363 format (used by WebCrypto ECDSA). - * RS256 signatures don't need conversion. + * Convert DER-encoded ECDSA signature to raw IEEE P1363 (R||S) format. + * RS256 signatures pass through unchanged. + * + * Exported because callers that emit JWS (e.g. DPoP proofs in lib/src/auth/dpop.ts) + * must produce raw R||S per RFC 7518 §3.4, while cryptoService.sign() currently + * returns DER. See DSPX-3634 for the broader cleanup. */ -function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { +export function derToIeeeP1363( + signature: Uint8Array, + algorithm: AsymmetricSigningAlgorithm +): Uint8Array { if (algorithm === 'RS256') { return signature; } diff --git a/lib/tdf3/src/crypto/jwt.ts b/lib/tdf3/src/crypto/jwt.ts index 5ae08fd20..9ea29e37c 100644 --- a/lib/tdf3/src/crypto/jwt.ts +++ b/lib/tdf3/src/crypto/jwt.ts @@ -17,6 +17,7 @@ import { } from 'jose'; import jwtClaimsSet from './jose/jwt-claims-set.js'; import validateCrit from './jose/validate-crit.js'; +import { derToIeeeP1363, ieeeP1363ToDer } from './core/signing.js'; export type JwtHeader = JWTHeaderParameters & { alg: SigningAlgorithm }; export type JwtPayload = JWTPayload; @@ -134,11 +135,16 @@ export async function signJwt( if (key._brand !== 'PrivateKey') { throw new Error(`${header.alg} requires a PrivateKey`); } - signature = await cryptoService.sign( - signingInputBytes, - key, - header.alg as AsymmetricSigningAlgorithm - ); + const alg = header.alg as AsymmetricSigningAlgorithm; + signature = await cryptoService.sign(signingInputBytes, key, alg); + // JWS requires raw IEEE P1363 (R || S) for ECDSA per RFC 7518 §3.4, but + // cryptoService.sign returns DER. Convert here so the JWT (e.g. the KAS + // rewrap request token) is accepted by RFC-conformant verifiers. RSA/EdDSA + // signatures are already raw bytes — no conversion. Mirrors the DPoP proof + // signer in src/auth/dpop.ts. + if (alg.startsWith('ES')) { + signature = derToIeeeP1363(signature, alg); + } } // Return compact JWT @@ -232,12 +238,12 @@ export async function verifyJwt( typeof key === 'string' ? await cryptoService.importPublicKey(key, { usage: 'sign' }) : (key as PublicKey); - valid = await cryptoService.verify( - signingInputBytes, - signature, - publicKey, - header.alg as AsymmetricSigningAlgorithm - ); + const alg = header.alg as AsymmetricSigningAlgorithm; + // JWS carries ECDSA signatures as raw IEEE P1363 (RFC 7518 §3.4), but + // cryptoService.verify expects DER. Convert here so we accept RFC-conformant + // ES* JWTs (matches the signJwt signer above). RSA is unchanged. + const verifySignature = alg.startsWith('ES') ? ieeeP1363ToDer(signature, alg) : signature; + valid = await cryptoService.verify(signingInputBytes, verifySignature, publicKey, alg); } if (!valid) { diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 9d03f20ed..0563df0a9 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -38,8 +38,10 @@ import { SymmetricCipher } from './ciphers/symmetric-cipher-base.js'; import { DecryptParams } from './client/builders.js'; import { DecoratedReadableStream } from './client/DecoratedReadableStream.js'; import { + type AsymmetricSigningAlgorithm, type CryptoService, type DecryptResult, + type KeyAlgorithm, type KeyPair, type SymmetricKey, } from './crypto/declarations.js'; @@ -748,6 +750,27 @@ type RewrapResponseData = { requiredObligations: string[]; }; +/** + * Map an opaque key's algorithm to the JWS signing algorithm used to sign the + * rewrap request token. RSA keys sign with RS256; EC keys sign with the ECDSA + * algorithm matching their curve. + */ +function signingAlgForKeyAlgorithm(algorithm: KeyAlgorithm): AsymmetricSigningAlgorithm { + switch (algorithm) { + case 'rsa:2048': + case 'rsa:4096': + return 'RS256'; + case 'ec:secp256r1': + return 'ES256'; + case 'ec:secp384r1': + return 'ES384'; + case 'ec:secp521r1': + return 'ES512'; + default: + throw new ConfigurationError(`Unsupported signing key algorithm [${algorithm}]`); + } +} + async function unwrapKey({ manifest, allowedKases, @@ -838,7 +861,12 @@ async function unwrapKey({ const requestBodyStr = toJsonString(UnsignedRewrapRequestSchema, unsignedRequest); const jwtPayload = { requestBody: requestBodyStr }; - const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey, cryptoService); + // The request token must be signed with the algorithm matching the dpop key + // type. Defaulting to RS256 breaks EC keys (e.g. DPoP ES256), since WebCrypto + // rejects signing an EC key with RSA params ("Unable to use this key to sign"). + const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey, cryptoService, { + alg: signingAlgForKeyAlgorithm(dpopKeys.privateKey.algorithm), + }); const rewrapResp = await fetchWrappedKey( url, diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts new file mode 100644 index 000000000..e601153e4 --- /dev/null +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai'; +import { AccessToken } from '../../src/auth/oidc.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by server.ts /protocol/openid-connect/token endpoint +const SERVER_NONCE = 'dpop-test-nonce-abc'; + +describe('DPoP nonce challenge — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // Each AccessToken/provider owns its own per-client nonce cache, so tests are + // naturally isolated — no shared-cache teardown needed. + + it('transparently retries with server-issued nonce and returns 200', async () => { + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + // No fetch override: uses global fetch (Node 18+) against the real server + ); + + // doPost sends the initial request (no nonce), gets 401 + DPoP-Nonce, + // then automatically retries with the nonce and receives 200. + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + const body = (await response.json()) as { access_token: string }; + expect(body.access_token).to.equal('test-dpop-token'); + + // Cache must be populated with the server's nonce after the round-trip + expect(accessToken.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('uses cached nonce on the first request after a prior successful challenge', async () => { + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + ); + + // Pre-seed this client's cache as if a prior request already populated it + accessToken.nonceCache.set(SERVER_ORIGIN, SERVER_NONCE); + + // With the correct nonce already cached, the first request should succeed directly (no retry). + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + }); + + it('initial token fetch via clientSecretAuthProvider sends DPoP proof when configured with a signing key', async () => { + // Mirrors the CLI path: when --dpop is set, the provider is constructed + // with dpopEnabled + signingKey so the very first POST /token carries a + // DPoP header (RFC 9449 §5) and survives the nonce challenge. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('test-dpop-token'); + expect(provider.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('omits DPoP header when no signing key is configured, even after updateClientPublicKey binds one for body signing', async () => { + // Mirrors the legacy/non-DPoP CLI path: no --dpop flag, but TDF3Client. + // createSessionKeys still calls updateClientPublicKey to bind a key used + // for TDF body signing. The token POST must NOT include a DPoP header, + // otherwise Keycloak issues a DPoP-bound token that the platform's + // Connect-RPC interceptors then present as plain Bearer → 401. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: 'http://localhost:3000', // any origin; we never hit token endpoint here + oidcTokenEndpoint: 'http://localhost:3000/protocol/openid-connect/non-dpop-token', + exchange: 'client', + // No dpopEnabled / signingKey — non-DPoP flow. + }); + await provider.updateClientPublicKey(keyPair); + // The exposed AccessToken config must remain non-DPoP after the bind. + expect(provider.oidcAuth.config.dpopEnabled).to.not.equal(true); + }); +}); diff --git a/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts new file mode 100644 index 000000000..53dc7d3b5 --- /dev/null +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -0,0 +1,164 @@ +import { expect } from 'chai'; +import * as jose from 'jose'; + +import dpopFn from '../../src/auth/dpop.js'; +import { DefaultCryptoService } from '../../tdf3/src/crypto/index.js'; +import { importPrivateKey, importPublicKey } from '../../tdf3/src/crypto/core/key-format.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +/** + * End-to-end DPoP proof signing tests. + * + * These tests verify the proofs minted by `dpopFn` (the function called from + * `AccessToken.doPost` and `withCreds`) against an independent, RFC 9449 / + * RFC 7518 §3.4 conformant verifier (`jose.jwtVerify`). + * + * Why these tests exist: the SDK's internal sign/verify pair is symmetric + * (both encode/decode ECDSA signatures as DER), so it round-trips inside this + * SDK even when the wire format is non-conformant. `jose.jwtVerify` is the + * same library used by real Keycloak under the hood — feeding our proofs + * through it catches DER-vs-raw and similar bugs that the in-SDK round-trip + * cannot. The earlier DSPX-3397 "Invalid token signature" failure from + * Keycloak would have been caught locally by these tests. + */ + +const HTU = 'https://example.test/protocol/openid-connect/token'; +const HTM = 'POST'; + +async function ecdsaKeyPair(namedCurve: 'P-256' | 'P-384' | 'P-521'): Promise { + // Generate via raw WebCrypto, then round-trip through PEM to obtain the + // SDK's opaque PrivateKey/PublicKey types (the same dance the CLI does in + // `cli/src/dpop-helpers.ts`). Keeps the test aligned with what real DPoP + // callers feed `dpopFn`. + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(new Uint8Array(privDer), 'PRIVATE KEY'); + const pubPem = derToPem(new Uint8Array(pubDer), 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + importPrivateKey(privPem, { usage: 'sign', extractable: true }), + importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +function derToPem(der: Uint8Array, label: string): string { + let b = ''; + for (let i = 0; i < der.length; i++) b += String.fromCharCode(der[i]); + const b64 = + btoa(b) + .match(/.{1,64}/g) + ?.join('\n') ?? btoa(b); + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----`; +} + +const CURVES: Array<{ namedCurve: 'P-256' | 'P-384' | 'P-521'; alg: 'ES256' | 'ES384' | 'ES512' }> = + [ + { namedCurve: 'P-256', alg: 'ES256' }, + { namedCurve: 'P-384', alg: 'ES384' }, + { namedCurve: 'P-521', alg: 'ES512' }, + ]; + +describe('DPoP proof — JWS conformance vs jose.jwtVerify (RFC 9449 + RFC 7518 §3.4)', function (this: Mocha.Suite) { + this.timeout(10_000); + + for (const { namedCurve, alg } of CURVES) { + it(`${alg} proof verifies against jose.jwtVerify`, async () => { + const kp = await ecdsaKeyPair(namedCurve); + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + + // Verify with the public key extracted from the proof's own header, the + // way a real DPoP-aware server (Keycloak) would. + const header = jose.decodeProtectedHeader(proof); + expect(header.typ).to.equal('dpop+jwt'); + expect(header.alg).to.equal(alg); + expect(header.jwk).to.exist; + + const key = await jose.importJWK(header.jwk as jose.JWK, alg); + const { payload } = await jose.jwtVerify(proof, key); + expect(payload.htu).to.equal(HTU); + expect(payload.htm).to.equal(HTM); + expect(payload.jti).to.be.a('string').and.have.length.greaterThan(0); + expect(payload.iat).to.be.a('number'); + }); + + it(`${alg} proof verification rejects a flipped signature byte`, async () => { + const kp = await ecdsaKeyPair(namedCurve); + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + const tampered = flipOneBitInSignatureSegment(proof); + + const header = jose.decodeProtectedHeader(proof); + const key = await jose.importJWK(header.jwk as jose.JWK, alg); + let threw = false; + try { + await jose.jwtVerify(tampered, key); + } catch { + threw = true; + } + expect(threw, 'jose.jwtVerify must reject a tampered signature').to.equal(true); + }); + + it(`${alg} proof verification rejects a swapped jwk header (binding intact, key wrong)`, async () => { + const kp1 = await ecdsaKeyPair(namedCurve); + const kp2 = await ecdsaKeyPair(namedCurve); + + const proof = await dpopFn(kp1, DefaultCryptoService, HTU, HTM); + + // Build a forged proof: same payload + signature but kp2's public JWK in + // the header. A correct verifier must reject because the signature was + // made by kp1.privateKey. + const [hdrB64, payloadB64, sigB64] = proof.split('.'); + const realHeader = JSON.parse( + new TextDecoder().decode(jose.base64url.decode(hdrB64)) + ) as jose.ProtectedHeaderParameters; + const fakeJwk = await crypto.subtle.exportKey( + 'jwk', + (await jose.importJWK((await proofHeaderJwkFor(kp2, alg)) as jose.JWK, alg)) as CryptoKey + ); + delete (fakeJwk as Record).d; + delete (fakeJwk as Record).key_ops; + realHeader.jwk = fakeJwk as jose.JWK; + const forgedHdrB64 = jose.base64url.encode( + new TextEncoder().encode(JSON.stringify(realHeader)) + ); + const forged = `${forgedHdrB64}.${payloadB64}.${sigB64}`; + + const key = await jose.importJWK(realHeader.jwk as jose.JWK, alg); + let threw = false; + try { + await jose.jwtVerify(forged, key); + } catch { + threw = true; + } + expect(threw, 'jose.jwtVerify must reject a forged proof with mismatched jwk').to.equal(true); + }); + } +}); + +/** + * Mint a real proof solely to extract a clean JWK for the public key. + * Round-tripping through `dpopFn` ensures the JWK shape matches what the + * SDK emits in real proofs. + */ +async function proofHeaderJwkFor(kp: KeyPair, alg: 'ES256' | 'ES384' | 'ES512'): Promise { + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + const header = jose.decodeProtectedHeader(proof); + void alg; // alg unused; kept in signature for caller clarity + return header.jwk; +} + +/** + * Flip exactly one bit of the base64url-decoded signature segment. + * Re-encodes back into the JWT compact form. + */ +function flipOneBitInSignatureSegment(jwt: string): string { + const [h, p, s] = jwt.split('.'); + const sig = jose.base64url.decode(s); + sig[0] ^= 0x01; + return `${h}.${p}.${jose.base64url.encode(sig)}`; +} diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts new file mode 100644 index 000000000..81ef6b8fe --- /dev/null +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -0,0 +1,91 @@ +import { assert, expect } from 'chai'; + +import { getMocks } from '../mocks/index.js'; +import { Client } from '../../tdf3/src/index.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import type { Scope } from '../../tdf3/src/client/builders.js'; + +const Mocks = getMocks(); + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce the mock server's resource-server (rewrap) gate demands; see +// DPOP_RS_NONCE in tests/server.ts. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * End-to-end regression for the Connect-RPC DPoP-Nonce challenge retry on the + * KAS *rewrap* path (RFC 9449 §9) — the exact xtest scenario + * (`test_dpop_server_issued_nonce_retry`) that failed only when js was the + * decrypt SDK against a `require_nonce` KAS. + * + * Drives a full encrypt → decrypt roundtrip through a DPoP auth provider so the + * rewrap carries `Authorization: DPoP ` and trips the mock server's RS + * gate. The first proof lacks the RS nonce, the server challenges with a 401 + + * `DPoP-Nonce`, and `authProviderInterceptor` must cache the nonce, re-sign, and + * retry once for the rewrap (and therefore the decrypt) to succeed. + */ +describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let dpopKeyPair: KeyPair; + + before(async () => { + dpopKeyPair = await generateSigningKeyPair(); + }); + + // The provider owns its per-client nonce cache, so each test is isolated. + + it('decrypt survives the rewrap nonce challenge and returns the plaintext', async () => { + const expectedVal = 'rewrap nonce roundtrip'; + + // A DPoP-enabled provider makes every authenticated request (token + rewrap) + // present `Authorization: DPoP` and a proof, which is what activates the RS + // gate on the mock KAS rewrap endpoint. + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: dpopKeyPair, + }); + + const client = new Client.Client({ + kasEndpoint: SERVER_ORIGIN, + platformUrl: SERVER_ORIGIN, + allowedKases: [SERVER_ORIGIN], + dpopKeys: Mocks.entityKeyPair(), + clientId: 'test-client', + authProvider, + }); + + const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + offline: true, + scope, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { type: 'stream', location: encryptedStream.stream }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + + // A successful decrypt proves the rewrap survived the challenge; the cached + // RS nonce proves a challenge actually happened and the retry adopted it. + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/mocha/dpop-rpc-nonce.spec.ts b/lib/tests/mocha/dpop-rpc-nonce.spec.ts new file mode 100644 index 000000000..0ae030c34 --- /dev/null +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { PlatformClient } from '../../src/platform.js'; +import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by the mock server's resource-server (RPC) endpoints. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * End-to-end regression for the Connect-RPC DPoP-Nonce challenge retry. + * + * Drives a real PlatformClient (Connect transport) through a DPoP auth provider + * against the mock server so `ListKeyAccessServers` issues an RS nonce challenge + * and the `authProviderInterceptor` must catch the ConnectError, cache the nonce, + * re-sign, and retry once. Before that interceptor fix the first call rejected + * with a Code.Unauthenticated ConnectError — exactly the bug that reached xtest + * (`test_dpop_server_issued_nonce_retry`). + */ +describe('DPoP RS nonce retry over Connect-RPC — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // The provider owns its per-client nonce cache, so each test is isolated. + + it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + // No RS nonce is cached for this origin yet: the first proof carries the + // wrong (or no) nonce, the server challenges with the RS nonce, and the + // interceptor must retry once for this call to resolve. + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + + // The consumed challenge leaves the RS nonce cached for the origin, proving + // a challenge happened and the retry adopted it. + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index e1ba9fc72..659a6e294 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -13,7 +13,12 @@ import { Assertion, } from '../../tdf3/src/assertions.js'; import { Scope } from '../../tdf3/src/client/builders.js'; -import { NetworkError } from '../../src/errors.js'; +import { + NetworkError, + PermissionDeniedError, + ServiceError, + UnauthenticatedError, +} from '../../src/errors.js'; const Mocks = getMocks(); @@ -101,7 +106,9 @@ describe('rewrap error cases', function () { }); assert.fail('Expected Error'); } catch (error) { - assert.instanceOf(error, NetworkError); + // The real RPC auth error must surface; the legacy REST fallback no longer + // masks it with a 404/NetworkError (RFC 9449 §9 / DSPX-3397). + assert.instanceOf(error, UnauthenticatedError); } }); @@ -125,7 +132,7 @@ describe('rewrap error cases', function () { }); assert.fail('Expected Error'); } catch (error) { - assert.instanceOf(error, NetworkError); + assert.instanceOf(error, PermissionDeniedError); } }); @@ -154,7 +161,7 @@ describe('rewrap error cases', function () { }); assert.fail('Expected Error'); } catch (error) { - assert.instanceOf(error, NetworkError); + assert.instanceOf(error, ServiceError); } }); @@ -178,7 +185,7 @@ describe('rewrap error cases', function () { }); assert.fail('Expected ServiceError'); } catch (error) { - assert.instanceOf(error, NetworkError); + assert.instanceOf(error, ServiceError); } }); @@ -233,10 +240,12 @@ describe('rewrap error cases', function () { location: encryptedStream.stream, }, }); - assert.fail('Expected InvalidFileError'); + assert.fail('Expected ServiceError'); } catch (error) { - assert.instanceOf(error, NetworkError); - assert.include(error.message, '404 Not Found'); + // Previously the legacy REST fallback masked the real RPC error with a + // "404 Not Found" NetworkError; now the RPC error surfaces directly. + assert.instanceOf(error, ServiceError); + assert.notInclude((error as Error).message, '404 Not Found'); } }); }); @@ -363,6 +372,53 @@ describe('encrypt decrypt test', async function () { } } + it('decrypt signs the rewrap request token with EC dpop keys (ES256)', async function () { + // Regression for DSPX-3397: the rewrap request token was always signed with + // RS256, which made WebCrypto reject EC dpop keys ("Unable to use this key to + // sign"). The token alg must follow the dpop key algorithm. + const cipher = new AesGcmCipher(WebCryptoService); + const encryptionInformation = new SplitKey(cipher); + const key1 = await encryptionInformation.generateKey(); + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + const client = new Client.Client({ + kasEndpoint: kasUrl, + platformUrl: kasUrl, + dpopKeys: Mocks.entityECKeyPair(), + clientId: 'id', + authProvider, + }); + + const scope: Scope = { + dissem: ['user@domain.com'], + attributes: [], + }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: 'rsa:2048', + offline: true, + scope, + keyMiddleware, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); + it('encrypt-decrypt with system metadata assertion', async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); diff --git a/lib/tests/mocha/reqsignature-jws.spec.ts b/lib/tests/mocha/reqsignature-jws.spec.ts new file mode 100644 index 000000000..099726b6a --- /dev/null +++ b/lib/tests/mocha/reqsignature-jws.spec.ts @@ -0,0 +1,92 @@ +import { expect } from 'chai'; +import * as jose from 'jose'; + +import { reqSignature } from '../../src/auth/auth.js'; +import { signJwt, verifyJwt } from '../../tdf3/src/crypto/jwt.js'; +import { DefaultCryptoService } from '../../tdf3/src/crypto/index.js'; +import { importPrivateKey, importPublicKey } from '../../tdf3/src/crypto/core/key-format.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +/** + * RFC 7518 §3.4 conformance for `signJwt`/`reqSignature` (the KAS rewrap request + * token signer). + * + * Regression for DSPX-3397: the rewrap request token was signed with ECDSA + * signatures in DER form, which a real (RFC-conformant) KAS rejects with + * "unable to verify request token". The mock test server only `decodeJwt`s the + * token (no signature check), so the in-SDK round-trip and the mock both passed + * while the real platform failed. Verifying against `jose.jwtVerify` — which + * requires raw IEEE P1363 (R||S) signatures — catches the DER-vs-raw bug. + */ + +const CURVES: Array<{ namedCurve: 'P-256' | 'P-384' | 'P-521'; alg: 'ES256' | 'ES384' | 'ES512' }> = + [ + { namedCurve: 'P-256', alg: 'ES256' }, + { namedCurve: 'P-384', alg: 'ES384' }, + { namedCurve: 'P-521', alg: 'ES512' }, + ]; + +function derToPem(der: Uint8Array, label: string): string { + let b = ''; + for (let i = 0; i < der.length; i++) b += String.fromCharCode(der[i]); + const b64 = + btoa(b) + .match(/.{1,64}/g) + ?.join('\n') ?? btoa(b); + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----`; +} + +async function ecdsaKeyPair( + namedCurve: 'P-256' | 'P-384' | 'P-521' +): Promise<{ sdk: KeyPair; pubPem: string }> { + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(new Uint8Array(privDer), 'PRIVATE KEY'); + const pubPem = derToPem(new Uint8Array(pubDer), 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + importPrivateKey(privPem, { usage: 'sign', extractable: true }), + importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { sdk: { publicKey, privateKey }, pubPem }; +} + +describe('reqSignature / signJwt — JWS conformance vs jose.jwtVerify (RFC 7518 §3.4)', function (this: Mocha.Suite) { + this.timeout(10_000); + + for (const { namedCurve, alg } of CURVES) { + it(`reqSignature ${alg} token verifies against jose.jwtVerify`, async () => { + const { sdk, pubPem } = await ecdsaKeyPair(namedCurve); + + const token = await reqSignature( + { requestBody: 'hello' }, + sdk.privateKey, + DefaultCryptoService, + { + alg, + } + ); + + // jose requires raw IEEE P1363 signatures — this rejects DER. + const key = await jose.importSPKI(pubPem, alg); + const { payload } = await jose.jwtVerify(token, key); + expect(payload.requestBody).to.equal('hello'); + expect(payload.iat).to.be.a('number'); + expect(payload.exp).to.be.a('number'); + }); + + it(`signJwt ${alg} round-trips through verifyJwt`, async () => { + const { sdk } = await ecdsaKeyPair(namedCurve); + const token = await signJwt(DefaultCryptoService, { sub: 'test' }, sdk.privateKey, { alg }); + const { payload } = await verifyJwt(DefaultCryptoService, token, sdk.publicKey, { + algorithms: [alg], + }); + expect(payload.sub).to.equal('test'); + }); + } +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 6f389b201..e47f0dd3f 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,5 +1,5 @@ import * as jose from 'jose'; -import { createServer, IncomingMessage, RequestListener } from 'node:http'; +import { createServer, IncomingMessage, RequestListener, ServerResponse } from 'node:http'; import { base64 } from '../src/encodings/index.js'; import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; @@ -24,6 +24,276 @@ import { const Mocks = getMocks(); +// ============================================================================= +// DPoP proof verification (RFC 9449 + RFC 7518 §3.4) for the mock server. +// Strict on purpose: this is what real Keycloak / panva-jose do, so when a +// regression in our SDK's proof minting (e.g. DER-encoded ECDSA) lands, the +// integration tests fail locally instead of only at xtest time. +// ============================================================================= + +const DPOP_TOKEN_NONCE = 'dpop-test-nonce-abc'; +const DPOP_RS_NONCE = 'dpop-test-rs-nonce-xyz'; +const DPOP_IAT_SKEW_SECONDS = 60; + +// access_token → JWK SHA-256 thumbprint of the key it was bound to. +// Populated by the token endpoint when minting a DPoP-bound token; consulted +// by the KAS rewrap handler to enforce RFC 9449 §6.1 jkt binding. +const dpopBoundJkts = new Map(); + +// Seen jti values per minted-by-this-server lifetime to detect replay. +// Real servers would TTL-evict; this is a test mock, full clear on shutdown is fine. +const seenJtis = new Set(); + +type DPoPCheckOpts = { + htm: string; + htu: string; + requireAth?: { accessToken: string }; + requireBoundJkt?: string; + requireNonce?: string; +}; + +type DPoPCheckResult = + | { ok: true; jkt: string; jti: string; payload: jose.JWTPayload } + | { + ok: false; + status: number; + error: string; + error_description: string; + // If set, the server must include this DPoP-Nonce header so the client retries. + challengeNonce?: string; + }; + +/** Strict-mode parse and verify a DPoP proof per RFC 9449 + RFC 7518 §3.4. */ +async function verifyDpopProof( + rawProof: string | undefined, + opts: DPoPCheckOpts +): Promise { + if (!rawProof) { + return { + ok: false, + status: 400, + error: 'invalid_request', + error_description: 'DPoP header required', + }; + } + + let protectedHeader: jose.ProtectedHeaderParameters; + try { + protectedHeader = jose.decodeProtectedHeader(rawProof); + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `cannot decode DPoP header: ${(err as Error).message}`, + }; + } + if (protectedHeader.typ !== 'dpop+jwt') { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `typ must be "dpop+jwt", got ${String(protectedHeader.typ)}`, + }; + } + const alg = protectedHeader.alg; + if (!alg || alg === 'none' || alg.startsWith('HS') || !/^(ES|RS|PS|EdDSA)/.test(alg)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `alg "${String(alg)}" is not an allowed asymmetric JWS alg`, + }; + } + const jwk = protectedHeader.jwk; + if (!jwk || typeof jwk !== 'object') { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jwk header parameter missing', + }; + } + for (const forbidden of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'k']) { + if (forbidden in (jwk as Record)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `jwk must not contain private parameter "${forbidden}"`, + }; + } + } + + let key: jose.CryptoKey | Uint8Array; + try { + key = (await jose.importJWK(jwk as jose.JWK, alg)) as jose.CryptoKey; + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `cannot import jwk: ${(err as Error).message}`, + }; + } + + let payload: jose.JWTPayload; + try { + ({ payload } = await jose.jwtVerify(rawProof, key)); + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `signature verification failed: ${(err as Error).message}`, + }; + } + + if (payload.htm !== opts.htm) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `htm mismatch: expected ${opts.htm}, got ${String(payload.htm)}`, + }; + } + if (payload.htu !== opts.htu) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `htu mismatch: expected ${opts.htu}, got ${String(payload.htu)}`, + }; + } + const now = Math.floor(Date.now() / 1000); + if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_IAT_SKEW_SECONDS) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `iat out of window (${String(payload.iat)} vs server now ${now})`, + }; + } + if (typeof payload.jti !== 'string' || payload.jti.length === 0) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jti claim required', + }; + } + if (seenJtis.has(payload.jti)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jti replay detected', + }; + } + + if (opts.requireNonce && payload.nonce !== opts.requireNonce) { + return { + ok: false, + status: 0, // caller decides 400 (AS) vs 401 (RS) + error: 'use_dpop_nonce', + error_description: 'DPoP nonce required', + challengeNonce: opts.requireNonce, + }; + } + + if (opts.requireAth) { + const expected = await athClaim(opts.requireAth.accessToken); + if (payload.ath !== expected) { + return { + ok: false, + status: 401, + error: 'invalid_dpop_proof', + error_description: `ath mismatch: expected ${expected}, got ${String(payload.ath)}`, + }; + } + } + + const jkt = await jose.calculateJwkThumbprint(jwk as jose.JWK); + if (opts.requireBoundJkt && opts.requireBoundJkt !== jkt) { + return { + ok: false, + status: 401, + error: 'invalid_token', + error_description: 'access token cnf.jkt does not match DPoP proof jkt', + }; + } + + seenJtis.add(payload.jti); + return { ok: true, jkt, jti: payload.jti, payload }; +} + +/** RFC 9449 §6.1: ath = base64url-nopad(SHA-256(ASCII(access_token))). */ +async function athClaim(accessToken: string): Promise { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)); + return base64UrlNoPad(new Uint8Array(hash)); +} + +function base64UrlNoPad(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** Build the htu (target URI sans query and fragment) for a server request. */ +function requestHtu(req: IncomingMessage): string { + // The test server listens on http://localhost:3000; URL fields beyond + // pathname (query, fragment) MUST be stripped per RFC 9449 §4.2. + const url = new URL(req.url ?? '/', 'http://localhost:3000'); + return `${url.origin}${url.pathname}`; +} + +/** + * RFC 9449 resource-server DPoP gate for Connect-RPC endpoints. A no-op + * (returns true) unless the request carries `Authorization: DPoP `, so + * Bearer / unauthenticated callers pass through unchanged and the many non-DPoP + * tests keep working. + * + * On a proof failure it writes a Connect-correct response and returns false; the + * caller MUST `return` immediately. The status is always 401 so connect-web maps + * it to Code.Unauthenticated (HTTP 400 would map to Code.Internal, which the + * SDK's nonce-retry interceptor does not act on). The nonce travels in the + * `DPoP-Nonce` response header (surfaced to the client via ConnectError.metadata), + * and the JSON body uses the Connect `{code, message}` envelope. + * + * The proof's `htm` is always 'POST': both SDK interceptors hard-code POST when + * minting the proof regardless of the verb the Connect transport uses, so we must + * NOT derive htm from req.method here. + */ +async function enforceRsDpop(req: IncomingMessage, res: ServerResponse): Promise { + const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; + const dpopMatch = /^DPoP\s+(.+)$/.exec(authHeader); + if (!dpopMatch) return true; // non-DPoP request → unchanged behavior + + const accessToken = dpopMatch[1]; + const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { + htm: 'POST', + htu: requestHtu(req), + requireAth: { accessToken }, + requireBoundJkt: dpopBoundJkts.get(accessToken), + requireNonce: DPOP_RS_NONCE, + }); + if (proofCheck.ok) return true; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (proofCheck.challengeNonce) { + headers['DPoP-Nonce'] = proofCheck.challengeNonce; + headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; + } + res.writeHead(401, headers); + res.end( + JSON.stringify({ + code: 'unauthenticated', + message: proofCheck.error_description || proofCheck.error, + }) + ); + return false; +} + function range(start: number, end: number): Uint8Array { const result = []; for (let i = start; i <= end; i++) { @@ -73,9 +343,11 @@ const kas: RequestListener = async (req, res) => { 'roundtrip-test-response', 'connect-protocol-version', 'connect-streaming-protocol-version', + 'x-virtrupubkey', ].join(', ') ); res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Expose-Headers', 'DPoP-Nonce'); // GET should be allowed for everything except rewrap, POST only for rewrap but IDC res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST'); try { @@ -194,6 +466,13 @@ const kas: RequestListener = async (req, res) => { res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; } + + // Strict RFC 9449 DPoP resource-server check. Only triggers when the + // request actually carries `Authorization: DPoP `; non-DPoP + // (Bearer or unauthenticated) callers pass through unchanged so the + // many non-DPoP rewrap tests keep working. + if (!(await enforceRsDpop(req, res))) return; + const body = await getBody(req); const bodyText = new TextDecoder().decode(body); const { signedRequestToken } = JSON.parse(bodyText); @@ -381,9 +660,12 @@ const kas: RequestListener = async (req, res) => { res.end(fullRange); } } else if (url.pathname === '/policy.attributes.AttributesService/GetAttributeValuesByFqns') { + // DPoP callers are authenticated by the RS gate; Bearer callers fall + // through to the legacy `Bearer dummy-auth-token` check below. + if (!(await enforceRsDpop(req, res))) return; res.setHeader('Content-Type', 'application/json'); const token = req.headers['authorization'] as string; - if (!token || !token.startsWith('Bearer dummy-auth-token')) { + if (!token || !(token.startsWith('Bearer dummy-auth-token') || token.startsWith('DPoP '))) { res.statusCode = 401; res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; @@ -431,6 +713,7 @@ const kas: RequestListener = async (req, res) => { } else if ( url.pathname === '/policy.kasregistry.KeyAccessServerRegistryService/ListKeyAccessServers' ) { + if (!(await enforceRsDpop(req, res))) return; res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end( @@ -459,8 +742,11 @@ const kas: RequestListener = async (req, res) => { ); return; } else if (url.pathname === '/policy.attributes.AttributesService/ListAttributes') { + // DPoP callers are authenticated by the RS gate; Bearer callers fall + // through to the legacy `Bearer dummy-auth-token` check below. + if (!(await enforceRsDpop(req, res))) return; const token = req.headers['authorization'] as string; - if (!token || !token.startsWith('Bearer dummy-auth-token')) { + if (!token || !(token.startsWith('Bearer dummy-auth-token') || token.startsWith('DPoP '))) { res.statusCode = 401; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'error' })); @@ -470,6 +756,37 @@ const kas: RequestListener = async (req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'ok' })); return; + } else if (url.pathname === '/protocol/openid-connect/token') { + // Mock Keycloak token endpoint with strict RFC 9449 DPoP verification. + // First request gets a nonce challenge (400 + use_dpop_nonce + DPoP-Nonce header + // per RFC 9449 §8 — note: AS uses 400, RS uses 401). The retry must include + // a proof whose `nonce` claim matches. + const dpopHeader = req.headers['dpop'] as string | undefined; + const htu = requestHtu(req); + const check = await verifyDpopProof(dpopHeader, { + htm: 'POST', + htu, + requireNonce: DPOP_TOKEN_NONCE, + }); + if (!check.ok) { + const status = + check.error === 'use_dpop_nonce' ? 400 : check.status > 0 ? check.status : 400; + const headers: Record = { 'Content-Type': 'application/json' }; + if (check.challengeNonce) headers['DPoP-Nonce'] = check.challengeNonce; + res.writeHead(status, headers); + res.end(JSON.stringify({ error: check.error, error_description: check.error_description })); + return; + } + + // Mint an opaque access token; bind it to the DPoP proof's JWK thumbprint + // so the rewrap handler (RS-side, below) can enforce cnf.jkt binding. + const accessToken = 'test-dpop-token'; + dpopBoundJkts.set(accessToken, check.jkt); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 })); + return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); res.statusCode = 404; diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index abdba1c86..ab3bb23cd 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -16,6 +16,7 @@ import { UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -230,6 +231,96 @@ describe('access-fetch.js', () => { }); }); + describe('DPoP-Nonce challenge retry (RFC 9449 §9)', () => { + const platformUrl = 'https://platform.example.com'; + const origin = 'https://platform.example.com'; + const challengeNonce = 'server-issued-nonce-123'; + + // A response carrying real Headers so DPoPNonceCache.extractNonce works. + // @ts-expect-error test helper, loose body typing + const responseWithNonce = (body, ok, status, nonce?: string) => + Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + headers: new Headers(nonce ? { 'DPoP-Nonce': nonce } : {}), + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + } as Response); + + // withCreds that signs each request with whatever nonce is currently cached + // for the origin, recording it so the test can confirm the retry saw the + // server challenge. + const noncesSeen: (string | undefined)[] = []; + // The provider owns its per-client cache; the retry path reads it back. + const nonceCache = new DPoPNonceCache(); + const dpopAuthProvider: AuthProvider = { + nonceCache, + withCreds: sinon.stub().callsFake(async (req) => { + noncesSeen.push(nonceCache.get(origin)); + return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; + }), + } as unknown as AuthProvider; + + beforeEach(() => { + noncesSeen.length = 0; + nonceCache.clear(origin); + // @ts-expect-error stub + dpopAuthProvider.withCreds.resetHistory(); + }); + + afterEach(() => { + nonceCache.clear(origin); + }); + + it('retries once with the server nonce and succeeds', async () => { + fetchStub + .onCall(0) + .returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + fetchStub + .onCall(1) + .returns( + responseWithNonce( + { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, + true, + 200 + ) + ); + + const result = await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + + expect(fetchStub.calledTwice).to.be.true; + // First proof had no nonce; the retry proof was minted after caching it. + expect(noncesSeen).to.deep.equal([undefined, challengeNonce]); + expect(result.origins).to.include('https://kas1.example.com'); + }); + + it('does not retry when the 401 carries no DPoP-Nonce', async () => { + fetchStub.returns(responseWithNonce('nope', false, 401)); + + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceError); + } + expect(fetchStub.calledOnce).to.be.true; + }); + + it('does not retry again when the same nonce is returned twice', async () => { + // Server keeps rejecting with the same nonce: retry once, then give up. + fetchStub.returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceError); + } + expect(fetchStub.calledTwice).to.be.true; + }); + }); + describe('fetchKasPubKey', () => { const kasEndpoint = 'https://kas.example.com'; // FIX: Provide a real, valid base64-encoded key. The `...` is not valid. diff --git a/lib/tests/web/auth/auth.test.ts b/lib/tests/web/auth/auth.test.ts index 85168d5d1..b3447a64c 100644 --- a/lib/tests/web/auth/auth.test.ts +++ b/lib/tests/web/auth/auth.test.ts @@ -427,5 +427,38 @@ describe('AccessToken', () => { expect(e.message).to.match(/required when DPoP is enabled/); } }); + + it('strips query and fragment from the DPoP proof htu (RFC 9449 §4.2)', async () => { + const signingKey = await generateTestSigningKey(); + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + signingKey, + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + const result = await accessToken.withCreds({ + url: 'https://platform.invalid/key-access-servers?pagination.offset=0', + method: 'GET', + headers: {}, + }); + + const decodeJwtPayload = (jwt: string): Record => { + let b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4 !== 0) { + b64 += '='; + } + return JSON.parse(atob(b64)); + }; + const payload = decodeJwtPayload(result.headers.DPoP); + expect(payload.htu).to.equal('https://platform.invalid/key-access-servers'); + expect(payload.htm).to.equal('GET'); + }); }); }); diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts new file mode 100644 index 000000000..c695dc654 --- /dev/null +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -0,0 +1,177 @@ +import { expect } from '@esm-bundle/chai'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { stub } from 'sinon'; +import { AccessToken } from '../../../src/auth/oidc.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { authTokenDPoPInterceptor } from '../../../src/auth/interceptors.js'; +import { DefaultCryptoService, generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; + +/** Decode JWT payload without verification (base64url → JSON). */ +function decodeJwtPayload(jwt: string): Record { + const b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '='); + return JSON.parse(atob(padded)); +} + +// ── AccessToken.doPost nonce retry ────────────────────────────────────────── + +describe('AccessToken.doPost DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const TOKEN_URL = `${ORIGIN}/protocol/openid-connect/token`; + const NONCE = 'server-nonce-xyz'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // Each AccessToken owns its own per-client nonce cache — tests are isolated. + + function makeAccessToken(fetchStub: typeof fetch) { + return new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService, + fetchStub + ); + } + + it('retries with nonce when server responds 401 with DPoP-Nonce header', async () => { + const fetchStub = stub(); + // First call: 401 challenge with DPoP-Nonce header + fetchStub.onFirstCall().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + // Second call: 200 success + fetchStub.onSecondCall().resolves({ + status: 200, + ok: true, + headers: new Headers(), + json: stub().resolves({ access_token: 'test-token' }), + } as unknown as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + expect(fetchStub.callCount).to.equal(2); + expect(result.status).to.equal(200); + expect(accessToken.nonceCache.get(ORIGIN)).to.equal(NONCE); + + // Second request's DPoP proof must include the nonce + const secondInit = fetchStub.secondCall.args[1] as RequestInit; + const secondHeaders = secondInit.headers as Record; + const retryPayload = decodeJwtPayload(secondHeaders['DPoP']); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + const fetchStub = stub().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + // Pre-seed this client's cache with the same nonce the server will return + accessToken.nonceCache.set(ORIGIN, NONCE); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + // No retry — same nonce means we'd loop; return the 401 to the caller + expect(fetchStub.callCount).to.equal(1); + expect(result.status).to.equal(401); + }); +}); + +// ── authTokenDPoPInterceptor nonce retry ──────────────────────────────────── + +describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const REQUEST_URL = `${ORIGIN}/kas.AccessService/Rewrap`; + const NONCE = 'interceptor-nonce-abc'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + function makeInterceptor(nonceCache: DPoPNonceCache) { + return authTokenDPoPInterceptor({ + tokenProvider: async () => 'dummy-access-token', + dpopKeys: Promise.resolve(keyPair), + nonceCache, + }); + } + + function makeMockReq() { + return { header: new Headers(), url: REQUEST_URL } as Parameters< + ReturnType> + >[0]; + } + + it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { + const mockNext = stub(); + // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) + ); + // Second call: success + mockNext.onSecondCall().resolves({ header: { get: () => null } }); + + const nonceCache = new DPoPNonceCache(); + const interceptor = makeInterceptor(nonceCache); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(nonceCache.get(ORIGIN)).to.equal(NONCE); + + // Retry request must have nonce in its DPoP proof + const retryReq = mockNext.secondCall.firstArg as { header: Headers }; + const retryDpopJwt = retryReq.header.get('DPoP')!; + const retryPayload = decodeJwtPayload(retryDpopJwt); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, NONCE); + + const mockNext = stub().callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) + ); + + const interceptor = makeInterceptor(nonceCache); + try { + await interceptor(mockNext as Parameters[0])(makeMockReq()); + expect.fail('should have thrown'); + } catch { + // Expected: interceptor re-throws when nonce unchanged + } + + expect(mockNext.callCount).to.equal(1); + }); +}); diff --git a/lib/tests/web/auth/dpop-rpc-nonce.test.ts b/lib/tests/web/auth/dpop-rpc-nonce.test.ts new file mode 100644 index 000000000..378812b4f --- /dev/null +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -0,0 +1,45 @@ +import { expect } from '@esm-bundle/chai'; +import { clientSecretAuthProvider } from '../../../src/auth/providers.js'; +import { PlatformClient } from '../../../src/platform.js'; +import { generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by the mock server's resource-server (RPC) endpoints. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * Browser-side counterpart to tests/mocha/dpop-rpc-nonce.spec.ts: exercises the + * Connect-RPC DPoP-Nonce challenge retry through the connect-web transport that + * the browser SDK actually uses. See that file for the full rationale. + */ +describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + // The provider owns its per-client nonce cache, so each test is isolated. + + it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 25ba358cb..91db683d1 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -1,5 +1,5 @@ import { expect } from '@esm-bundle/chai'; -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; import type { AuthProvider } from '../../src/auth/auth.js'; import { HttpRequest, withHeaders } from '../../src/auth/auth.js'; import { @@ -10,6 +10,7 @@ import { resolveAuthConfig, isInterceptorConfig, } from '../../src/auth/interceptors.js'; +import { DPoPNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -61,7 +62,7 @@ describe('authTokenDPoPInterceptor', () => { const headers = await captureHeaders(interceptor); - expect(headers.get('Authorization')).to.equal('Bearer dpop-token'); + expect(headers.get('Authorization')).to.equal('DPoP dpop-token'); expect(headers.get('DPoP')).to.be.a('string'); expect(headers.get('DPoP')!.split('.')).to.have.length(3); // JWT format expect(headers.get('X-VirtruPubKey')).to.be.a('string'); @@ -142,6 +143,64 @@ describe('authProviderInterceptor', () => { expect(headers.get('X-Custom')).to.equal('custom-value'); }); + it('passes the full request URL to withCreds (not just the path)', async () => { + // Regression: a DPoP-enabled provider computes the proof `htu` and nonce + // origin via `new URL(req.url)`, which throws on a bare path. The + // interceptor must hand withCreds the absolute URL. + let seenUrl: string | undefined; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (req: HttpRequest) => { + seenUrl = req.url; + // Mimic a DPoP provider that parses the URL; a bare path throws here. + new URL(req.url); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + await captureHeaders(interceptor, 'https://platform.example.com/policy.attributes/Get'); + + expect(seenUrl).to.equal('https://platform.example.com/policy.attributes/Get'); + }); + + it('retries once with the server-issued DPoP-Nonce on an Unauthenticated challenge', async () => { + const origin = 'https://platform.example.com'; + const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; + const nonceCache = new DPoPNonceCache(); + + // Provider records the nonce it sees so we can assert the retry carried it. + const seenNonces: (string | undefined)[] = []; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + nonceCache, + withCreds: async (req: HttpRequest) => { + seenNonces.push(nonceCache.get(new URL(req.url).origin)); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + let attempts = 0; + const mockNext = async () => { + attempts++; + if (attempts === 1) { + // First attempt: server issues a nonce challenge. + throw new ConnectError('unauthenticated', Code.Unauthenticated, { + 'dpop-nonce': 'server-nonce-xyz', + }); + } + return { header: new Headers(), message: {} } as Awaited>>; + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + const mockReq = { header: new Headers(), url } as Parameters>[0]; + await interceptor(mockNext)(mockReq); + + expect(attempts).to.equal(2); + expect(seenNonces).to.deep.equal([undefined, 'server-nonce-xyz']); + expect(nonceCache.get(origin)).to.equal('server-nonce-xyz'); + }); + it('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {}, diff --git a/scripts/config-demo.sh b/scripts/config-demo.sh new file mode 100755 index 000000000..1d5d235aa --- /dev/null +++ b/scripts/config-demo.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Configure Keycloak for the DPoP browser demo and start the dev server. +# +# Mirrors .github/workflows/roundtrip/config-demo-idp.sh but uses the admin +# REST API directly (no kcadm download needed) and targets an otdf-local +# instance rather than the CI docker-compose stack. +# +# Prerequisites: +# - Keycloak is running (docker, via otdf-local): +# uv run otdf-local --instance DSPX-3397 up --services docker +# - Local lib is built and installed in web-app: +# ./scripts/rebuild-local-lib.sh +# +# Usage: +# ./scripts/config-demo.sh # uses defaults +# PLATFORM_URL=http://localhost:9080 ./scripts/config-demo.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +KC_URL="${KC_URL:-http://localhost:8888}" +KC_REALM="${KC_REALM:-opentdf}" +KC_ADMIN_USER="${KC_ADMIN_USER:-admin}" +KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-changeme}" +PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" +APP_URL="${APP_URL:-http://localhost:65432}" + +echo "Configuring Keycloak at ${KC_URL}/auth/realms/${KC_REALM}" + +KC_ADMIN_TOKEN=$(curl -sf "${KC_URL}/auth/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli&username=${KC_ADMIN_USER}&password=${KC_ADMIN_PASSWORD}&grant_type=password" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +_kc() { curl -sf -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" "$@"; } + +# Create browsertest public client with DPoP binding enforced. +# Audience maps to PLATFORM_URL so the platform's auth.audience check passes. +if _kc "${KC_URL}/auth/admin/realms/${KC_REALM}/clients?clientId=browsertest" \ + | python3 -c "import sys,json; exit(0 if json.load(sys.stdin) else 1)" 2>/dev/null; then + echo "browsertest client already exists, skipping creation" +else + _kc -X POST "${KC_URL}/auth/admin/realms/${KC_REALM}/clients" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"browsertest\", + \"enabled\": true, + \"redirectUris\": [\"${APP_URL}/\"], + \"consentRequired\": false, + \"standardFlowEnabled\": true, + \"directAccessGrantsEnabled\": true, + \"serviceAccountsEnabled\": false, + \"publicClient\": true, + \"protocol\": \"openid-connect\", + \"attributes\": {\"dpop.bound.access.tokens\": \"true\"}, + \"protocolMappers\": [{ + \"name\": \"aud\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-audience-mapper\", + \"consentRequired\": false, + \"config\": { + \"access.token.claim\": \"true\", + \"included.custom.audience\": \"${PLATFORM_URL}\" + } + }] + }" + echo "Created browsertest client (DPoP-bound, audience=${PLATFORM_URL})" +fi + +# Create demo user (user1 / testuser123) if not already present. +if _kc "${KC_URL}/auth/admin/realms/${KC_REALM}/users?username=user1" \ + | python3 -c "import sys,json; exit(0 if json.load(sys.stdin) else 1)" 2>/dev/null; then + echo "user1 already exists, skipping creation" +else + USER_ID=$(_kc -X POST "${KC_URL}/auth/admin/realms/${KC_REALM}/users" \ + -H "Content-Type: application/json" \ + -D - \ + -d '{"username":"user1","enabled":true,"firstName":"Alice","lastName":"User"}' \ + | grep -i "^location:" | grep -o '[^/]*$' | tr -d '\r') + _kc -X PUT "${KC_URL}/auth/admin/realms/${KC_REALM}/users/${USER_ID}/reset-password" \ + -H "Content-Type: application/json" \ + -d '{"type":"password","value":"testuser123","temporary":false}' + echo "Created user1 (password: testuser123)" +fi + +echo "" +echo "Keycloak configured. Starting dev server..." +echo "" +exec "${REPO_ROOT}/scripts/dev-local.sh" diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh new file mode 100755 index 000000000..da722774b --- /dev/null +++ b/scripts/dev-local.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Start the web-app dev server pointed at a local otdf-local instance. +# +# Prerequisites: +# - otdf-local backend is running (tests/ dir): +# uv run otdf-local --instance DSPX-3397 up --services platform,kas +# - Local lib is built and installed: +# ./scripts/rebuild-local-lib.sh +# +# DPoP is active by default (the lib sends DPoP tokens on every request). +# To see enforcement (platform rejects non-DPoP tokens), set enforceDPoP: true +# in tests/instances/DSPX-3397/opentdf.yaml, then restart platform: +# uv run otdf-local --instance DSPX-3397 up --services platform --no-provision +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +KC_REALM="${KC_REALM:-opentdf}" +KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" +APP_URL="${APP_URL:-http://localhost:65432}" + +# Use the app origin for the OIDC host so browser requests go through +# Vite's /auth proxy instead of hitting Keycloak on port 8888 directly (CORS). +export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${APP_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${APP_URL}/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" + +echo "Starting web-app dev server with:" +echo " OIDC: ${APP_URL}/auth/realms/${KC_REALM} (proxied) client=${KC_CLIENT_ID}" +echo " KAS: ${APP_URL}/kas (proxied)" +echo "" + +cd "$REPO_ROOT/web-app" +npm run dev diff --git a/scripts/rebuild-local-lib.sh b/scripts/rebuild-local-lib.sh new file mode 100755 index 000000000..e850d25c3 --- /dev/null +++ b/scripts/rebuild-local-lib.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the local lib and install it into the web-app. +# Run this after making changes to lib/ before starting the dev server. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$REPO_ROOT/lib" +npm ci +npm pack + +cd "$REPO_ROOT/web-app" +npm remove @opentdf/sdk +npm ci +npm install ../lib/opentdf-sdk-*.tgz + +echo "Done. Run scripts/dev-local.sh to start the dev server." diff --git a/web-app/src/session.ts b/web-app/src/session.ts index 2bcbe1256..c63d4e669 100644 --- a/web-app/src/session.ts +++ b/web-app/src/session.ts @@ -524,6 +524,6 @@ export class OidcClient implements AuthProvider { accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } } diff --git a/web-app/tests/README.md b/web-app/tests/README.md index 46a5e9e9a..dec7da387 100644 --- a/web-app/tests/README.md +++ b/web-app/tests/README.md @@ -3,6 +3,10 @@ This folder contains playwright, e2e tests for web-app, running against a local or remote backend in proxy mode. +DPoP nonce challenges are enabled for all test clients (`browsertest` and `testclient`), +so the e2e tests and CLI roundtrip tests exercise the full DPoP challenge/retry path. +Keycloak 26.2 is required (configured in `.github/workflows/roundtrip/docker-compose.yaml`). + ## Bring up the platform behind local (vite dev server) proxy Bring up test backend services (identity provider, database, etc.): diff --git a/web-app/tests/tests/dpop-headers.spec.ts b/web-app/tests/tests/dpop-headers.spec.ts new file mode 100644 index 000000000..9af7bf5a4 --- /dev/null +++ b/web-app/tests/tests/dpop-headers.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { authorize, loadFile } from './acts.js'; + +type CapturedRequest = { + url: string; + method: string; + authorization: string | undefined; + dpop: string | undefined; +}; + +test('DPoP headers on token and KAS rewrap requests', async ({ page }) => { + const captured: CapturedRequest[] = []; + + page.on('request', (request) => { + const url = request.url(); + if ( + url.includes('/protocol/openid-connect/token') || + url.includes('/kas.AccessService/Rewrap') || + url.includes('/kas/v2/rewrap') + ) { + const headers = request.headers(); + captured.push({ + url, + method: request.method(), + authorization: headers['authorization'], + dpop: headers['dpop'], + }); + } + }); + + page.on('console', (m) => console.log(m.text())); + + await authorize(page); + await loadFile(page, 'README.md'); + const downloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#encryptButton').click(); + const enc = await downloadPromise; + const cipherTextPath = await enc.path(); + if (!cipherTextPath) throw new Error('no cipher'); + + await page.locator('#clearFile').click(); + await loadFile(page, cipherTextPath); + const plainDownloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#decryptButton').click(); + await plainDownloadPromise; + + console.log('\n=== CAPTURED DPoP-RELEVANT REQUESTS ==='); + for (const r of captured) { + console.log(`\n${r.method} ${r.url}`); + console.log(` Authorization: ${r.authorization ?? '(none)'}`); + console.log(` DPoP: ${r.dpop ? r.dpop.slice(0, 80) + '...' : '(none)'}`); + } + + // We expect at minimum: token exchange + rewrap + expect(captured.length).toBeGreaterThanOrEqual(2); + + for (const r of captured) { + if (r.url.includes('/kas')) { + expect(r.authorization, `${r.url} should carry an Authorization header`).toBeTruthy(); + expect(r.dpop, `${r.url} should carry a DPoP header`).toBeTruthy(); + } + } +});