From 9e14a9eab0784f4389bc850cb306ac637a6b7d1f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 8 Jun 2026 11:12:55 -0400 Subject: [PATCH 01/68] feat(web-sdk): comprehensive DPoP nonce handling and verification (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RFC 9449 DPoP-Nonce support across SDK and CLI: - Add DPoP-Nonce cache manager (dpop-nonce.ts) with per-origin nonce storage - Update authTokenDPoPInterceptor with automatic nonce retry on 401 challenges - Extend OIDC token endpoint and userinfo flows to handle nonce caching/refresh - Add 'supports dpop' CLI command for xtest integration testing detection - Refresh cached nonces from successful response headers per RFC 9449 §8 All DPoP proofs now include cached nonces when available and automatically retry with server-provided nonces on 401 use_dpop_nonce errors. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 21 +++++++++++ lib/src/auth/dpop-nonce.ts | 44 ++++++++++++++++++++++ lib/src/auth/interceptors.ts | 64 ++++++++++++++++++++++++++++++-- lib/src/auth/oidc.ts | 71 ++++++++++++++++++++++++++++++++++-- 4 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 lib/src/auth/dpop-nonce.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0f0a56998..527362343 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -513,6 +513,27 @@ 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 (argv) => { + const feature = argv.feature as string; + if (feature === 'dpop') { + // DPoP is supported - exit 0 + process.exit(0); + } + // Unknown feature - exit 1 + process.exit(1); + } + ) + .command( 'inspect [file]', 'Inspect TDF and extract header information, without decrypting', diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts new file mode 100644 index 000000000..5b6762677 --- /dev/null +++ b/lib/src/auth/dpop-nonce.ts @@ -0,0 +1,44 @@ +/** + * 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); + } + + /** + * Extract DPoP-Nonce from response headers (case-insensitive). + */ + static extractNonce(headers: Headers): string | undefined { + // Headers.get() is case-insensitive per spec + return headers.get('dpop-nonce') || undefined; + } +} + +/** + * Global nonce cache singleton. + * Shared across all instances to maintain nonce state per-origin. + */ +export const globalNonceCache = new DPoPNonceCache(); diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c0a0f7971..c22ffae39 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -5,6 +5,7 @@ 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 { globalNonceCache } from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -86,10 +87,14 @@ 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 = globalNonceCache.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); @@ -98,7 +103,60 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI 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); + + // Extract and cache nonce from successful responses + const responseNonce = response.header.get('dpop-nonce'); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + + return response; + } catch (err) { + // Check if this is a 401 with DPoP-Nonce challenge + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 16 && // Code.Unauthenticated + 'metadata' in err + ) { + const metadata = err.metadata as { get?: (key: string) => string | null }; + const serverNonce = metadata.get?.('dpop-nonce'); + + if (serverNonce && !cachedNonce) { + // Server sent a nonce and we didn't have one cached + // Cache it and retry once + globalNonceCache.set(origin, serverNonce); + + // Regenerate proof with server nonce + const retryDpopProof = await DPoP( + keys, + cryptoService, + httpUri, + 'POST', + serverNonce, + token + ); + req.header.set('DPoP', retryDpopProof); + + const retryResponse = await next(req); + + // Update cache from retry response if present + const retryNonce = retryResponse.header.get('dpop-nonce'); + if (retryNonce) { + globalNonceCache.set(origin, retryNonce); + } + + return retryResponse; + } + } + + // Re-throw if not a nonce challenge or retry failed + throw err; + } }; // Attach dpopKeys to the interceptor function diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index b842890e6..057317d9b 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 { globalNonceCache, DPoPNonceCache } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -145,21 +146,34 @@ 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; if (this.config.dpopEnabled && this.signingKey) { + const cachedNonce = globalNonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, this.userInfoEndpoint, - 'POST' + 'POST', + cachedNonce, + accessToken ); } const response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); + + // Update nonce cache from response + if (this.config.dpopEnabled) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + if (!response.ok) { console.error(await response.text()); throw new TdfError( @@ -171,6 +185,7 @@ 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', @@ -185,13 +200,59 @@ 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'); + + // Get cached nonce for token endpoint + const cachedNonce = globalNonceCache.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 retry on 401 + if (this.config.dpopEnabled && response.status === 401) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + // Cache the server-provided nonce and retry + globalNonceCache.set(origin, responseNonce); + + // Regenerate DPoP proof with nonce + headers.DPoP = await dpopFn( + this.signingKey!, + this.cryptoService, + url, + 'POST', + responseNonce + ); + + const retryResponse = await (this.request || fetch)(url, { + method: 'POST', + headers, + body: qstringify(o), + }); + + // Update cache from retry response + const retryNonce = DPoPNonceCache.extractNonce(retryResponse.headers); + if (retryNonce) { + globalNonceCache.set(origin, retryNonce); + } + + return retryResponse; + } + } + + // Update nonce cache from successful responses + if (this.config.dpopEnabled && response.ok) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + + return response; } async accessTokenLookup(cfg: OIDCCredentials) { @@ -333,12 +394,14 @@ export class AccessToken { } const accessToken = await this.get(); if (this.config.dpopEnabled && this.signingKey) { + const origin = new URL(httpReq.url).origin; + const cachedNonce = globalNonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, httpReq.url, httpReq.method, - /* nonce */ undefined, + cachedNonce, accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? From 431d56b1e7a2e165164536a528df316a49ab8fc0 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:10:46 -0400 Subject: [PATCH 02/68] docs: add DPoP CLI flags design spec (DSPX-3397) Signed-off-by: Dave Mihalcik --- .../specs/2026-06-09-dpop-cli-flags-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md 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 From 544a89731e1f9e00a07d0174a440da4faed615ed Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:16:24 -0400 Subject: [PATCH 03/68] docs: add DPoP CLI flags implementation plan (DSPX-3397) Signed-off-by: Dave Mihalcik --- .../plans/2026-06-09-dpop-cli-flags.md | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-dpop-cli-flags.md 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` ✓ From ad555452a6a486ab0dce978c295c1c2c2799ae4e Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:22:11 -0400 Subject: [PATCH 04/68] feat(cli): add DPoP key pair helpers (DSPX-3397) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 156 +++++++++++++++++++++++++++++++++ cli/tests/dpop-helpers.spec.ts | 69 +++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 cli/src/dpop-helpers.ts create mode 100644 cli/tests/dpop-helpers.spec.ts diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts new file mode 100644 index 000000000..0e1e2a97c --- /dev/null +++ b/cli/src/dpop-helpers.ts @@ -0,0 +1,156 @@ +// 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', +}; + +/** 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: 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 [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; +} diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts new file mode 100644 index 000000000..a298f29ca --- /dev/null +++ b/cli/tests/dpop-helpers.spec.ts @@ -0,0 +1,69 @@ +// 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'); + }); +}); From b53131c8663869b930ce056561941b8db97f6798 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:52:23 -0400 Subject: [PATCH 05/68] fix(cli): guard derToPem empty input, warn on RS384/RS512 downgrade Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index 0e1e2a97c..d4ce738ae 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -17,7 +17,7 @@ const EC_CURVE_MAP: Record = { 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'); + const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64; return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; } @@ -34,6 +34,12 @@ export async function generateEphemeralDPoPKeyPair(alg: string): Promise Date: Tue, 9 Jun 2026 13:54:17 -0400 Subject: [PATCH 06/68] feat(cli): change --dpop to string type, add --dpop-key option (DSPX-3397) Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 527362343..4755c8f01 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -22,6 +22,8 @@ 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'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in Task 4+5 +import { resolveDPoPKeyPair as _resolveDPoPKeyPair } from './dpop-helpers.js'; type AuthToProcess = { auth?: string; @@ -394,8 +396,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') From 3500a8c057a2fccca64901ce7e84ee6870d6cf2a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:56:08 -0400 Subject: [PATCH 07/68] feat(cli): wire --dpop and --dpop-key into encrypt/decrypt commands (DSPX-3397) Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 4755c8f01..1c1f4c9c1 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -593,6 +593,10 @@ export const handleArgs = (args: string[]) => { 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: { @@ -603,7 +607,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, }); @@ -629,14 +634,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(); } @@ -657,12 +662,17 @@ export const handleArgs = (args: string[]) => { 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: !argv.dpop, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, policyEndpoint: guessedPolicyEndpoint, platformUrl: argv.platformUrl || guessedPolicyEndpoint, }); From 4a22391e3f52fc58979ff3e01867790a7f437c6f Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:01:12 +0000 Subject: [PATCH 08/68] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 4 ++-- cli/src/dpop-helpers.ts | 5 ++++- cli/tests/dpop-helpers.spec.ts | 6 +----- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 1c1f4c9c1..410828d30 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -593,7 +593,7 @@ export const handleArgs = (args: string[]) => { 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 dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); @@ -662,7 +662,7 @@ export const handleArgs = (args: string[]) => { log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index d4ce738ae..b0a764a3f 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -125,7 +125,10 @@ export async function loadDPoPKeyPairFromPem(pemPath: string): Promise async function buildKeyPairFromCryptoKey( privatePem: string, privCK: webcrypto.CryptoKey, - algorithm: webcrypto.AlgorithmIdentifier | webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams + 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); diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts index a298f29ca..b046d7af5 100644 --- a/cli/tests/dpop-helpers.spec.ts +++ b/cli/tests/dpop-helpers.spec.ts @@ -1,10 +1,6 @@ // cli/tests/dpop-helpers.spec.ts import { expect } from '@esm-bundle/chai'; -import { - derToPem, - generateEphemeralDPoPKeyPair, - resolveDPoPKeyPair, -} from '../src/dpop-helpers.js'; +import { derToPem, generateEphemeralDPoPKeyPair, resolveDPoPKeyPair } from '../src/dpop-helpers.js'; describe('derToPem', function () { it('wraps DER bytes in PEM armor with the given type', function () { From 4e46c51b49411cda0e70f3d41cd9f0f7452e0034 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 08:34:03 -0400 Subject: [PATCH 09/68] fix(dpop): address code review feedback on nonce retry logic and defensive handling (DSPX-3397) - interceptors.ts: use serverNonce !== cachedNonce to allow retry on nonce rotation, not just first nonce - interceptors.ts: optional-chain err.metadata to prevent TypeError crash on absent metadata - dpop-nonce.ts: guard extractNonce against null/non-standard headers (test-env robustness) - oidc.ts: hoist cachedNonce outside dpopEnabled block; skip retry when server returns same nonce - cli.ts: remove redundant process.exit(0) from supports command handler; yargs choices handles validation Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 10 ++-------- lib/src/auth/dpop-nonce.ts | 5 ++--- lib/src/auth/interceptors.ts | 8 ++++---- lib/src/auth/oidc.ts | 6 +++--- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 410828d30..ba24079f1 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -531,14 +531,8 @@ export const handleArgs = (args: string[]) => { choices: ['dpop'], }); }, - async (argv) => { - const feature = argv.feature as string; - if (feature === 'dpop') { - // DPoP is supported - exit 0 - process.exit(0); - } - // Unknown feature - exit 1 - process.exit(1); + async () => { + // yargs choices validation ensures feature is supported; return naturally exits 0 } ) diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 5b6762677..b1dad54ad 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -31,9 +31,8 @@ export class DPoPNonceCache { /** * Extract DPoP-Nonce from response headers (case-insensitive). */ - static extractNonce(headers: Headers): string | undefined { - // Headers.get() is case-insensitive per spec - return headers.get('dpop-nonce') || undefined; + static extractNonce(headers?: Headers): string | undefined { + return typeof headers?.get === 'function' ? headers.get('dpop-nonce') || undefined : undefined; } } diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c22ffae39..da504238c 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -123,11 +123,11 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI err.code === 16 && // Code.Unauthenticated 'metadata' in err ) { - const metadata = err.metadata as { get?: (key: string) => string | null }; - const serverNonce = metadata.get?.('dpop-nonce'); + const metadata = err.metadata as { get?: (key: string) => string | null } | undefined; + const serverNonce = metadata?.get?.('dpop-nonce'); - if (serverNonce && !cachedNonce) { - // Server sent a nonce and we didn't have one cached + if (serverNonce && serverNonce !== cachedNonce) { + // Server sent a new nonce (or we didn't have one cached) // Cache it and retry once globalNonceCache.set(origin, serverNonce); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 057317d9b..5f4981b91 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -191,6 +191,7 @@ export class AccessToken { 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'); @@ -201,8 +202,7 @@ export class AccessToken { // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - // Get cached nonce for token endpoint - const cachedNonce = globalNonceCache.get(origin); + cachedNonce = globalNonceCache.get(origin); headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); } @@ -215,7 +215,7 @@ export class AccessToken { // Handle DPoP-Nonce retry on 401 if (this.config.dpopEnabled && response.status === 401) { const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { + if (responseNonce && responseNonce !== cachedNonce) { // Cache the server-provided nonce and retry globalNonceCache.set(origin, responseNonce); From 23b04f068fa2059a0da6f2e75f003c5c9b77df0d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 09:20:32 -0400 Subject: [PATCH 10/68] test(dpop): add DPoP nonce challenge to mock server and cover retry logic (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dpop-nonce.ts: add clearAll() to DPoPNonceCache for test teardown - server.ts: add /protocol/openid-connect/token endpoint that issues a DPoP-Nonce challenge (fixed nonce 'dpop-test-nonce-abc') when the incoming DPoP proof has no nonce, accepts on retry with correct nonce - tests/web/auth/dpop-nonce.test.ts: WTR unit tests covering doPost() nonce retry (via mock fetch) and authTokenDPoPInterceptor nonce retry (via mock next), including no-retry-on-same-nonce regression cases - tests/mocha/dpop-nonce.spec.ts: Mocha integration tests hitting the real server — verifies transparent retry, nonce cache population, and pre-cached nonce path Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- lib/src/auth/dpop-nonce.ts | 7 ++ lib/tests/mocha/dpop-nonce.spec.ts | 80 +++++++++++++ lib/tests/server.ts | 24 ++++ lib/tests/web/auth/dpop-nonce.test.ts | 166 ++++++++++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 lib/tests/mocha/dpop-nonce.spec.ts create mode 100644 lib/tests/web/auth/dpop-nonce.test.ts diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index b1dad54ad..16c7b3c61 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -28,6 +28,13 @@ export class DPoPNonceCache { 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). */ diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts new file mode 100644 index 000000000..a464f89e3 --- /dev/null +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -0,0 +1,80 @@ +import { expect } from 'chai'; +import { AccessToken } from '../../src/auth/oidc.js'; +import { globalNonceCache } from '../../src/auth/dpop-nonce.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(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + 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(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('uses cached nonce on the first request after a prior successful challenge', async () => { + // Pre-seed cache as if a prior request already populated it + globalNonceCache.set(SERVER_ORIGIN, SERVER_NONCE); + + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + ); + + // 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); + }); +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index b18308db2..9cc3f3150 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -622,6 +622,30 @@ 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') { + // DPoP nonce challenge test endpoint — simulates a Keycloak token endpoint. + // Always challenges the first request (no nonce in DPoP JWT) with a fixed nonce. + // Accepts the retry once the DPoP proof includes the expected nonce. + const DPOP_TEST_NONCE = 'dpop-test-nonce-abc'; + const dpopHeader = req.headers['dpop'] as string | undefined; + if (!dpopHeader) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' })); + return; + } + const dpopPayload = jose.decodeJwt(dpopHeader); + if (dpopPayload.nonce !== DPOP_TEST_NONCE) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'DPoP-Nonce': DPOP_TEST_NONCE, + }); + res.end(JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 })); + return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); res.statusCode = 404; 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..2f6600c65 --- /dev/null +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -0,0 +1,166 @@ +import { expect } from '@esm-bundle/chai'; +import { stub } from 'sinon'; +import { AccessToken } from '../../../src/auth/oidc.js'; +import { globalNonceCache } 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(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + 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(globalNonceCache.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 () => { + // Pre-seed the cache with the same nonce the server will return + globalNonceCache.set(ORIGIN, NONCE); + + 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); + 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(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + function makeInterceptor() { + return authTokenDPoPInterceptor({ + tokenProvider: async () => 'dummy-access-token', + dpopKeys: Promise.resolve(keyPair), + }); + } + + 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({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) + ); + // Second call: success + mockNext.onSecondCall().resolves({ header: { get: () => null } }); + + const interceptor = makeInterceptor(); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(globalNonceCache.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 () => { + globalNonceCache.set(ORIGIN, NONCE); + + const mockNext = stub().callsFake(() => + Promise.reject({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) + ); + + const interceptor = makeInterceptor(); + try { + await interceptor(mockNext as Parameters[0])(makeMockReq()); + expect.fail('should have thrown'); + } catch (err) { + // Expected: interceptor re-throws when nonce unchanged + } + + expect(mockNext.callCount).to.equal(1); + }); +}); From 4c53ece7d55a371a3311dd0de945231dc44317e2 Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:21:33 +0000 Subject: [PATCH 11/68] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/server.ts | 12 +++++++++--- lib/tests/web/auth/dpop-nonce.test.ts | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 9cc3f3150..21901eb99 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -630,7 +630,9 @@ const kas: RequestListener = async (req, res) => { const dpopHeader = req.headers['dpop'] as string | undefined; if (!dpopHeader) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' })); + res.end( + JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' }) + ); return; } const dpopPayload = jose.decodeJwt(dpopHeader); @@ -639,12 +641,16 @@ const kas: RequestListener = async (req, res) => { 'Content-Type': 'application/json', 'DPoP-Nonce': DPOP_TEST_NONCE, }); - res.end(JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' })); + res.end( + JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' }) + ); return; } res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 })); + res.end( + JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 }) + ); return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 2f6600c65..622e4be4b 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -127,9 +127,14 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { 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({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) - ); + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject({ + code: 16, + metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, + }) + ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); @@ -150,7 +155,10 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { globalNonceCache.set(ORIGIN, NONCE); const mockNext = stub().callsFake(() => - Promise.reject({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) + Promise.reject({ + code: 16, + metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, + }) ); const interceptor = makeInterceptor(); From c93f382f86ad474c1ada5e81436bd8e0f2525e6e Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:11:18 +0000 Subject: [PATCH 12/68] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/web/auth/dpop-nonce.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 622e4be4b..90c862050 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -127,14 +127,12 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { 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({ - code: 16, - metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, - }) - ); + mockNext.onFirstCall().callsFake(() => + Promise.reject({ + code: 16, + metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, + }) + ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); From 8e8997ca6811173fa03b028ef211f9138e437cb6 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 14:28:52 -0400 Subject: [PATCH 13/68] ci(dpop): upgrade Keycloak to 26.2 and enable DPoP nonce challenges in roundtrip tests (DSPX-3397) Upgrade the roundtrip CI Keycloak to 26.2, enable admin-fine-grained-authz:v1, drop the keycloakdb dependency (KC 26.2 uses embedded H2 in dev mode), require dpop.bound.access.tokens on existing clients (opentdf-sdk, testclient), and add --dpop to the CLI encrypt/decrypt invocations so both playwright and CLI roundtrip tests exercise the full nonce challenge/retry path. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .../workflows/roundtrip/config-demo-idp.sh | 5 ++-- .../workflows/roundtrip/docker-compose.yaml | 23 ++----------------- .../workflows/roundtrip/encrypt-decrypt.sh | 3 +++ .../workflows/roundtrip/keycloak_data.yaml | 4 +++- web-app/tests/README.md | 4 ++++ 5 files changed, 15 insertions(+), 24 deletions(-) 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/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.): From c3f1238f24f5ed7c65ec6131ad3dc2b496438dd3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 15:22:22 -0400 Subject: [PATCH 14/68] fix(dpop): add missing CORS headers to mock server for browser test compatibility (DSPX-3397) x-virtrupubkey was missing from Access-Control-Allow-Headers, causing Chrome to block the CORS preflight for DPoP-enabled requests. DPoP-Nonce was missing from Access-Control-Expose-Headers, preventing browser JS from reading the nonce challenge on 401 responses. Node.js fetch ignores CORS so mocha tests passed; Chrome Headless tests failed with TypeError: Failed to fetch. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- lib/tests/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 21901eb99..90a6be27b 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -113,9 +113,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 { From ff4136f2b315536314b9f10befce68c46dfe1ba6 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:37:23 -0400 Subject: [PATCH 15/68] chore(scripts): add local dev helper scripts for DPoP demo dev-local.sh starts the web-app dev server pointed at a local otdf-local instance (PLATFORM_URL/KC_URL/KC_CLIENT_ID overrideable via env). rebuild-local-lib.sh builds lib/ from source and installs it into web-app, replacing the published @opentdf/sdk with the local build. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/dev-local.sh | 30 ++++++++++++++++++++++++++++++ scripts/rebuild-local-lib.sh | 16 ++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100755 scripts/dev-local.sh create mode 100755 scripts/rebuild-local-lib.sh diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh new file mode 100755 index 000000000..f8b0724cf --- /dev/null +++ b/scripts/dev-local.sh @@ -0,0 +1,30 @@ +#!/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)" + +PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" +KC_URL="${KC_URL:-http://localhost:8888}" +KC_REALM="${KC_REALM:-opentdf}" +KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" + +export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${KC_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" + +echo "Starting web-app dev server with:" +echo " OIDC: ${KC_URL}/auth/realms/${KC_REALM} client=${KC_CLIENT_ID}" +echo " KAS: ${PLATFORM_URL}/api/kas" +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." From a06d3892bfdd755a8231a7dc1beb932fc8413084 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:38:04 -0400 Subject: [PATCH 16/68] chore(scripts): add config-demo.sh to provision KC and start dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts .github/workflows/roundtrip/config-demo-idp.sh for local otdf-local use: creates the browsertest public KC client with dpop.bound.access.tokens enforced and an audience mapper pointing at PLATFORM_URL, then execs dev-local.sh to start the Vite dev server. No kcadm install required — uses the KC admin REST API directly. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/config-demo.sh | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100755 scripts/config-demo.sh diff --git a/scripts/config-demo.sh b/scripts/config-demo.sh new file mode 100755 index 000000000..c040e6f9a --- /dev/null +++ b/scripts/config-demo.sh @@ -0,0 +1,71 @@ +#!/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 + +echo "" +echo "Keycloak configured. Starting dev server..." +echo "" +exec "${REPO_ROOT}/scripts/dev-local.sh" From 65161fb7ffbafd8e26c437feb88da5b54f8c423e Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:43:32 -0400 Subject: [PATCH 17/68] fix(scripts): create demo user1 in config-demo.sh Matches the user creation step from config-demo-idp.sh that was omitted in the initial port. user1 / testuser123 is the expected login for the browser demo app. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/config-demo.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/config-demo.sh b/scripts/config-demo.sh index c040e6f9a..1d5d235aa 100755 --- a/scripts/config-demo.sh +++ b/scripts/config-demo.sh @@ -65,6 +65,22 @@ else 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 "" From e13ffbad0eb73964e03859a0dce59f533341c04b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:45:26 -0400 Subject: [PATCH 18/68] fix(scripts): route OIDC through Vite proxy to avoid CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser requests to Keycloak must go through the Vite /auth proxy (localhost:65432/auth → localhost:8888) rather than hitting port 8888 directly. Replaced the hard-coded KC_URL in VITE_TDF_CFG with APP_URL so the oidc.host always matches the app origin. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/dev-local.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index f8b0724cf..d63f09af1 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -15,14 +15,16 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" -KC_URL="${KC_URL:-http://localhost:8888}" KC_REALM="${KC_REALM:-opentdf}" KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" +APP_URL="${APP_URL:-http://localhost:65432}" -export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${KC_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" +# 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\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" echo "Starting web-app dev server with:" -echo " OIDC: ${KC_URL}/auth/realms/${KC_REALM} client=${KC_CLIENT_ID}" +echo " OIDC: ${APP_URL}/auth/realms/${KC_REALM} (proxied) client=${KC_CLIENT_ID}" echo " KAS: ${PLATFORM_URL}/api/kas" echo "" From 9760be2bf8e42a0e790e28a89409274f67c24c80 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:49:52 -0400 Subject: [PATCH 19/68] fix(scripts): route KAS through Vite proxy; drop PLATFORM_URL Both OIDC and KAS endpoints now use APP_URL (localhost:65432) so all browser requests go through Vite's reverse proxy rather than hitting ports 8888 or 8080 directly. Mirrors how the roundtrip scripts pass --kasEndpoint http://localhost:65432/kas. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/dev-local.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index d63f09af1..da722774b 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -14,18 +14,17 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" 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\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" +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: ${PLATFORM_URL}/api/kas" +echo " KAS: ${APP_URL}/kas (proxied)" echo "" cd "$REPO_ROOT/web-app" From 556ec89ec23a5120392c9016a32e7dfee28b899f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 13:12:15 -0400 Subject: [PATCH 20/68] fix(sdk): use DPoP scheme when presenting DPoP-bound tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 9449 §7.1, DPoP-bound access tokens (cnf.jkt claim present) MUST be presented to resource servers under the "DPoP" Authorization scheme, not "Bearer". Three lib paths (oidc.ts withCreds and info, interceptors.ts authTokenDPoPInterceptor) and the web-app sample's OidcClient.withCreds were sending Bearer alongside the DPoP proof header — silently accepted by lenient enforcers, but rejected by spec-compliant ones once enforcement is enabled. Non-DPoP paths continue to send Bearer. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- lib/src/auth/interceptors.ts | 2 +- lib/src/auth/oidc.ts | 6 ++++-- lib/tests/web/interceptors.test.ts | 2 +- web-app/src/session.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index da504238c..a39fa9606 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -99,7 +99,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // 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)); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 5f4981b91..37d9e2ab4 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -149,7 +149,6 @@ export class AccessToken { const origin = new URL(this.userInfoEndpoint).origin; const headers = { ...this.extraHeaders, - Authorization: `Bearer ${accessToken}`, } as Record; if (this.config.dpopEnabled && this.signingKey) { const cachedNonce = globalNonceCache.get(origin); @@ -161,6 +160,9 @@ export class AccessToken { cachedNonce, accessToken ); + headers.Authorization = `DPoP ${accessToken}`; + } else { + headers.Authorization = `Bearer ${accessToken}`; } const response = await (this.request || fetch)(this.userInfoEndpoint, { headers, @@ -405,7 +407,7 @@ export class AccessToken { 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/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 25ba358cb..4c71edd97 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -61,7 +61,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'); 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 }); } } From e9821f8832080bc635574fb53b820f97fc4908ee Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 13:12:15 -0400 Subject: [PATCH 21/68] test(web-app): add Playwright test capturing DPoP-protected headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercepts the OIDC token POST and the KAS Rewrap POST, asserts the DPoP header is present, and surfaces the captured Authorization scheme (DPoP vs Bearer) for visual inspection. Serves as a regression guard for the RFC 9449 §7.1 scheme requirement. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- web-app/tests/tests/dpop-headers.spec.ts | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 web-app/tests/tests/dpop-headers.spec.ts 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(); + } + } +}); From 4f5b4ded19d7184e820d76ab89581aefb0494732 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:47:39 -0400 Subject: [PATCH 22/68] fix(dpop): make nonce challenge handling RFC 9449 compliant (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doPost: trigger nonce retry on any non-OK response carrying a fresh DPoP-Nonce header. The previous status==401 gate never fired against spec-compliant authorization servers (Keycloak 26.2 included), which return HTTP 400 with error=use_dpop_nonce per RFC 9449 §8. - info: fix htm claim mismatch — the proof was generated with 'POST' but the userinfo request is GET, violating RFC 9449 §4.2. - info: add the missing single-shot nonce retry mirroring doPost, so the first userinfo request against a nonce-enforcing resource server succeeds instead of bubbling up as a spurious token-renewal cycle. Signed-off-by: Dave Mihalcik --- lib/src/auth/oidc.ts | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 37d9e2ab4..e06cc4fa8 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -150,13 +150,14 @@ export class AccessToken { const headers = { ...this.extraHeaders, } as Record; + let cachedNonce: string | undefined; if (this.config.dpopEnabled && this.signingKey) { - const cachedNonce = globalNonceCache.get(origin); + cachedNonce = globalNonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, this.userInfoEndpoint, - 'POST', + 'GET', cachedNonce, accessToken ); @@ -164,11 +165,30 @@ export class AccessToken { } else { headers.Authorization = `Bearer ${accessToken}`; } - const response = await (this.request || fetch)(this.userInfoEndpoint, { + let response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); - // Update nonce cache from response + // 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 = DPoPNonceCache.extractNonce(response.headers); + if (challengeNonce && challengeNonce !== cachedNonce) { + globalNonceCache.set(origin, 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) { const responseNonce = DPoPNonceCache.extractNonce(response.headers); if (responseNonce) { @@ -214,8 +234,10 @@ export class AccessToken { body: qstringify(o), }); - // Handle DPoP-Nonce retry on 401 - if (this.config.dpopEnabled && response.status === 401) { + // 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 responseNonce = DPoPNonceCache.extractNonce(response.headers); if (responseNonce && responseNonce !== cachedNonce) { // Cache the server-provided nonce and retry From 26d2202c53c3747ac7f0597fba32e55237201c84 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:47:47 -0400 Subject: [PATCH 23/68] fix(cli): harden DPoP key loading and helper utilities (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadDPoPKeyPairFromPem: narrow the curve-detection catches so that only crypto.subtle.importKey failures are swallowed. SDK-layer errors from buildKeyPairFromCryptoKey now propagate with full context instead of producing the misleading 'expected PKCS8 PEM' message. - loadDPoPKeyPairFromPem: wrap atob() in try/catch so malformed PEM content surfaces as a descriptive CLIError instead of a raw DOMException. - derToPem: replace btoa(String.fromCharCode(...bytes)) with Buffer.from(bytes).toString('base64') — matches the safer pattern already used in lib/src/auth/dpop.ts and avoids spread-on-large-array call-stack risk. - requireImportPrivateKey: guarded accessor that fails loudly with a CLIError if the optional WebCryptoService.importPrivateKey method is missing, replacing the silent ! non-null assertions. - resolveDPoPFromArgs: new exported helper that encapsulates the --dpop / --dpopKey three-way argv parsing (bare --dpop defaults to ES256). Lets cli.ts dedupe and unit tests exercise the helper without triggering yargs side effects. Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 77 +++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index b0a764a3f..b155df444 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -13,10 +13,21 @@ const EC_CURVE_MAP: Record = { 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 = btoa(String.fromCharCode(...bytes)); + 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}-----`; } @@ -52,8 +63,9 @@ export async function generateEphemeralDPoPKeyPair(alg: string): Promise 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)); + 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) + // 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 { - const privCK = await crypto.subtle.importKey( - 'pkcs8', - der, - { name: 'ECDSA', namedCurve }, - true, - ['sign'] - ); - return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + 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) + // Try RSA (PKCS1-v1_5 SHA-256). Same narrowing rationale as above. + let rsaCK: webcrypto.CryptoKey | undefined; try { - const privCK = await crypto.subtle.importKey( + rsaCK = await crypto.subtle.importKey( 'pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, true, ['sign'] ); - return await buildKeyPairFromCryptoKey(privatePem, privCK, { + } catch { + // not RSA either + } + if (rsaCK) { + return await buildKeyPairFromCryptoKey(privatePem, rsaCK, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256', }); - } catch { - // not RSA either } throw new CLIError( @@ -140,8 +164,9 @@ async function buildKeyPairFromCryptoKey( const pubDer = await crypto.subtle.exportKey('spki', pubCK); const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + const importPriv = requireImportPrivateKey(); const [privateKey, publicKey] = await Promise.all([ - WebCryptoService.importPrivateKey!(privatePem, { usage: 'sign', extractable: true }), + importPriv(privatePem, { usage: 'sign', extractable: true }), WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), ]); return { publicKey, privateKey }; @@ -163,3 +188,17 @@ export async function resolveDPoPKeyPair( } 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 }; +} From 62ebd72fcf70a7fdd6bee7ffbec65baec5aa07b4 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:47:53 -0400 Subject: [PATCH 24/68] refactor(dpop): use ConnectError type and dedupe CLI argv handling (DSPX-3397) - interceptors.ts: replace the duck-typed metadata cast with a real 'instanceof ConnectError && err.code === Code.Unauthenticated' check. ConnectError.metadata is typed as Headers so .get() is non-optional, and instanceof catches actual production errors rather than a hand-rolled shape. - dpop-nonce.test.ts: update the rejection stubs to throw real ConnectError instances (matches what Connect actually surfaces to interceptors in production; the prior plain objects only worked because of the duck-typing that we just removed). - cli.ts: replace the stale eslint-disable + _resolveDPoPKeyPair alias with a clean import. Replace the copy-pasted three-line DPoP resolution block in the encrypt and decrypt handlers with the new resolveDPoPFromArgs helper. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 11 +++-------- lib/src/auth/interceptors.ts | 15 ++++----------- lib/tests/web/auth/dpop-nonce.test.ts | 23 +++++++++++++++-------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index ba24079f1..ba6b56889 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -22,8 +22,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'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in Task 4+5 -import { resolveDPoPKeyPair as _resolveDPoPKeyPair } from './dpop-helpers.js'; +import { resolveDPoPFromArgs } from './dpop-helpers.js'; type AuthToProcess = { auth?: string; @@ -587,9 +586,7 @@ export const handleArgs = (args: string[]) => { 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 { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); const client = new OpenTDF({ authProvider, @@ -656,9 +653,7 @@ export const handleArgs = (args: string[]) => { 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 { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); const client = new OpenTDF({ authProvider, diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index a39fa9606..8c68e17b1 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -1,4 +1,4 @@ -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'; @@ -115,16 +115,9 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI return response; } catch (err) { - // Check if this is a 401 with DPoP-Nonce challenge - if ( - err && - typeof err === 'object' && - 'code' in err && - err.code === 16 && // Code.Unauthenticated - 'metadata' in err - ) { - const metadata = err.metadata as { get?: (key: string) => string | null } | undefined; - const serverNonce = metadata?.get?.('dpop-nonce'); + // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge + if (err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = err.metadata.get('dpop-nonce'); if (serverNonce && serverNonce !== cachedNonce) { // Server sent a new nonce (or we didn't have one cached) diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 90c862050..451027f1f 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -1,4 +1,5 @@ import { expect } from '@esm-bundle/chai'; +import { Code, ConnectError } from '@connectrpc/connect'; import { stub } from 'sinon'; import { AccessToken } from '../../../src/auth/oidc.js'; import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; @@ -128,10 +129,13 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { const mockNext = stub(); // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata mockNext.onFirstCall().callsFake(() => - Promise.reject({ - code: 16, - metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, - }) + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); @@ -153,10 +157,13 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { globalNonceCache.set(ORIGIN, NONCE); const mockNext = stub().callsFake(() => - Promise.reject({ - code: 16, - metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, - }) + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) ); const interceptor = makeInterceptor(); From 05c7775354340b0f0a542ca49e3fbc2d3fe81588 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:48:00 -0400 Subject: [PATCH 25/68] test(cli): cover PEM loading, key-path resolution, and CLI argv shape (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing coverage flagged during PR review: - loadDPoPKeyPairFromPem: round-trip tests for P-256, P-384, P-521, and RSA-2048 PEMs (generated in-memory via WebCrypto + derToPem), plus error paths for missing file, invalid base64, and valid base64 whose bytes are not a recognized key type. - resolveDPoPKeyPair: keyPath-only and keyPath-overrides-alg branches (previously the keyPath path was completely uncovered). - resolveDPoPFromArgs: full argv matrix — no flags, bare --dpop defaults to ES256, explicit algorithm, --dpopKey alone, and the CLIError propagation for an unknown algorithm. Signed-off-by: Dave Mihalcik --- cli/tests/dpop-helpers.spec.ts | 191 ++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts index b046d7af5..444a318af 100644 --- a/cli/tests/dpop-helpers.spec.ts +++ b/cli/tests/dpop-helpers.spec.ts @@ -1,6 +1,16 @@ // cli/tests/dpop-helpers.spec.ts import { expect } from '@esm-bundle/chai'; -import { derToPem, generateEphemeralDPoPKeyPair, resolveDPoPKeyPair } from '../src/dpop-helpers.js'; +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 () { @@ -51,7 +61,122 @@ describe('generateEphemeralDPoPKeyPair', function () { }); }); +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; @@ -62,4 +187,68 @@ describe('resolveDPoPKeyPair', function () { 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'); + } + }); }); From a2328d0a781211f65412804c869ac5ef2369276d Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:49:20 +0000 Subject: [PATCH 26/68] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/web/auth/dpop-nonce.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 451027f1f..055faee80 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -128,15 +128,17 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { 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 }) + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) ) - ) - ); + ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); From 7c9ceefdf297942f1eb0f77c90969b4214783a3a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 10:58:45 -0400 Subject: [PATCH 27/68] fix(dpop): send DPoP proof on initial Keycloak token request (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak clients with dpop_bound_access_tokens=true reject the client_credentials POST /token unless it carries a DPoP proof header (RFC 9449 §5). The SDK was minting the proof only for KAS calls — the initial token exchange went out bare and Keycloak responded with 400 invalid_request 'DPoP proof is missing'. Two wiring gaps caused this: 1. AccessToken.refreshTokenClaimsWithClientPubkeyIfNeeded set this.signingKey but left config.dpopEnabled false, so doPost skipped the DPoP branch even after the key was bound. Now it also flips dpopEnabled and clears the cached token (a pre-binding token would lack cnf.jkt and immediately fail downstream KAS calls). 2. CLI processAuth performed a warm-up oidcAuth.get() before OpenTDF constructed, so the first token fetch pre-dated key binding regardless of (1). processAuth now accepts the resolved dpopKeyPair and binds it via updateClientPublicKey before the warm-up call; encrypt/decrypt handlers resolve DPoP first. Existing nonce-retry path in doPost (RFC 9449 §8) is unchanged and exercised by the new regression test. Proof is minted without ath on the token endpoint; ath remains scoped to resource requests. Regression test in dpop-nonce.spec.ts drives the full provider path (clientSecretAuthProvider -> updateClientPublicKey -> get) and reproduces the bug's exact 400 error when the fix is reverted. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 33 ++++++++++++++++++------------ lib/src/auth/oidc.ts | 5 +++++ lib/tests/mocha/dpop-nonce.spec.ts | 19 +++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index ba6b56889..9a06905a3 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -54,14 +54,17 @@ 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'); @@ -86,6 +89,11 @@ async function processAuth({ exchange: 'client', clientSecret, }); + // Bind DPoP key before any token fetch so the initial POST /token carries a + // DPoP proof (required by clients with dpop_bound_access_tokens=true). + if (dpopKeyPair) { + await actual.updateClientPublicKey(dpopKeyPair); + } if (concurrencyLimit !== 1) { await actual.oidcAuth.get(); } @@ -583,10 +591,10 @@ 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 { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); const client = new OpenTDF({ authProvider, @@ -649,12 +657,11 @@ 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); - const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); - const client = new OpenTDF({ authProvider, defaultCreateOptions: { diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index e06cc4fa8..f0a541213 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -381,6 +381,11 @@ export class AccessToken { delete this.cachedExpiry; delete this.inFlight; this.signingKey = signingKey; + // Binding a signing key implies DPoP. Enable it on the config so the + // next token request includes a DPoP proof (RFC 9449 §5), and drop any + // cached token from a prior non-DPoP fetch since it won't carry cnf.jkt. + this.config = { ...this.config, dpopEnabled: true, signingKey }; + delete this.data; } /** diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index a464f89e3..22d7b26ef 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { AccessToken } from '../../src/auth/oidc.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -77,4 +78,22 @@ describe('DPoP nonce challenge — integration with mock server', function (this expect(response.status).to.equal(200); }); + + it('initial token fetch via clientSecretAuthProvider includes DPoP proof after updateClientPublicKey', async () => { + // Mirrors the CLI path: provider is created without DPoP awareness, then a + // DPoP key is bound via updateClientPublicKey. The very next token POST + // must carry a DPoP header (RFC 9449 §5) and survive the nonce challenge. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + }); + await provider.updateClientPublicKey(keyPair); + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('test-dpop-token'); + expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); }); From d43c19ccb99117f55ba311e49bb2673b648064db Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:59:48 +0000 Subject: [PATCH 28/68] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 9a06905a3..3aa26276d 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -55,14 +55,7 @@ const parseJwtComplete = (jwt: string) => { }; async function processAuth( - { - auth, - clientId, - clientSecret, - concurrencyLimit, - oidcEndpoint, - userId, - }: AuthToProcess, + { auth, clientId, clientSecret, concurrencyLimit, oidcEndpoint, userId }: AuthToProcess, dpopKeyPair?: KeyPair ): Promise { log('DEBUG', 'Processing auth params'); From 5d8bf86dcd900ae7f8a309d4b4093318d510cc1d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 17:15:12 -0400 Subject: [PATCH 29/68] fix(dpop): scope DPoP enablement to providers configured with a key (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e693605b enabled DPoP unconditionally inside AccessToken.refreshTokenClaimsWithClientPubkeyIfNeeded. But TDF3Client.createSessionKeys always calls updateClientPublicKey — even for non-DPoP flows — because the same key is reused for TDF body signing. The unconditional flip therefore turned DPoP on for every CLI invocation, including 'opentdf:secret' (non-DPoP client). Keycloak then issued a DPoP-bound token (cnf.jkt set), but the SDK's Connect-RPC interceptors still presented it as plain Bearer to the platform, producing 401 on /key-access-servers and breaking test_legacy.py::test_decrypt_* in xtest. Plumb dpopEnabled and signingKey through clientSecretAuthProvider, refreshAuthProvider, externalAuthProvider and their provider classes into the AccessToken constructor (the AccessToken type already accepted these fields). The CLI now passes them at construction time so DPoP is on iff --dpop was requested. refreshTokenClaimsWithClientPubkeyIfNeeded no longer flips dpopEnabled. It still drops the cached token when DPoP is on (rotating the key invalidates cnf.jkt) but leaves cached non-DPoP Bearer tokens alone — they're key-independent. dpop-nonce.spec.ts: updated the DPoP-path test to use the new config-time wiring, and added a non-DPoP test asserting that updateClientPublicKey (the call TDF3Client.createSessionKeys makes unconditionally) does NOT flip dpopEnabled. Verified it fails on e693605b and passes here. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 13 ++++---- .../auth/oidc-clientcredentials-provider.ts | 4 +++ lib/src/auth/oidc-externaljwt-provider.ts | 4 +++ lib/src/auth/oidc-refreshtoken-provider.ts | 4 +++ lib/src/auth/oidc.ts | 11 +++---- lib/src/auth/providers.ts | 6 ++++ lib/tests/mocha/dpop-nonce.spec.ts | 30 +++++++++++++++---- 7 files changed, 57 insertions(+), 15 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 3aa26276d..c79845fd6 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -76,17 +76,20 @@ 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, }); - // Bind DPoP key before any token fetch so the initial POST /token carries a - // DPoP proof (required by clients with dpop_bound_access_tokens=true). - if (dpopKeyPair) { - await actual.updateClientPublicKey(dpopKeyPair); - } if (concurrencyLimit !== 1) { await actual.oidcAuth.get(); } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 8d3629bae..6a4ba83bb 100644 --- a/lib/src/auth/oidc-clientcredentials-provider.ts +++ b/lib/src/auth/oidc-clientcredentials-provider.ts @@ -14,6 +14,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -29,6 +31,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index 2a7266882..cfe66bff2 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -15,6 +15,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -30,6 +32,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index 9f7bac2d9..c23a25b04 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -29,6 +29,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -44,6 +46,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index f0a541213..aaafcbef2 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -381,11 +381,12 @@ export class AccessToken { delete this.cachedExpiry; delete this.inFlight; this.signingKey = signingKey; - // Binding a signing key implies DPoP. Enable it on the config so the - // next token request includes a DPoP proof (RFC 9449 §5), and drop any - // cached token from a prior non-DPoP fetch since it won't carry cnf.jkt. - this.config = { ...this.config, dpopEnabled: true, signingKey }; - delete this.data; + // 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; + } } /** 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/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index 22d7b26ef..c01d9562d 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -79,21 +79,41 @@ describe('DPoP nonce challenge — integration with mock server', function (this expect(response.status).to.equal(200); }); - it('initial token fetch via clientSecretAuthProvider includes DPoP proof after updateClientPublicKey', async () => { - // Mirrors the CLI path: provider is created without DPoP awareness, then a - // DPoP key is bound via updateClientPublicKey. The very next token POST - // must carry a DPoP header (RFC 9449 §5) and survive the nonce challenge. + 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, }); - await provider.updateClientPublicKey(keyPair); const token = await provider.oidcAuth.get(false); expect(token).to.equal('test-dpop-token'); expect(globalNonceCache.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); + }); }); From bb482371155660ead4020418727a1e7e106e5236 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 18:19:55 -0400 Subject: [PATCH 30/68] fix(dpop): emit raw IEEE P1363 ECDSA sigs in DPoP proofs; strict mock verifier (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak rejected our DPoP proofs with TokenSignatureInvalidException (Invalid token signature) because cryptoService.sign re-encodes ECDSA signatures in DER, while RFC 7518 §3.4 (the JWS spec DPoP inherits via RFC 9449) mandates raw R||S concatenation. crypto.subtle already returns that raw form; the DER re-encode in signing.ts:189-192 is the bug. Scope this change to DPoP only: - Export derToIeeeP1363 from lib/tdf3/src/crypto/core/signing.ts. - In lib/src/auth/dpop.ts, convert DER → raw at the JWS call site for ES* algs before base64url-encoding. RSA/EdDSA pass through unchanged. - Do NOT touch cryptoService.sign/verify or jwt.ts::signJwt — those back TDF assertion signing, and flipping their format would break reading existing ES256-signed assertions from older SDK versions. Tracked separately as DSPX-3634. Harden the mock test server so this regression is caught locally: - lib/tests/server.ts token endpoint: replace jose.decodeJwt with a full RFC 9449 verifier — decodeProtectedHeader (typ/alg/jwk no-priv checks), importJWK + jwtVerify (catches DER), htm/htu/iat/jti claim validation. Fix existing wrong 401 → 400 on use_dpop_nonce per §8. - Add a resource-server DPoP block to the rewrap handler: ath + cnf.jkt binding (Map tracked across token mint and rewrap). 401 + WWW-Authenticate: DPoP for nonce challenge. New regression tests in lib/tests/mocha/dpop-proof.spec.ts: - ES256/ES384/ES512 round-trip proofs minted by dpopFn through jose.jwtVerify (the verifier inside real Keycloak). The pre-fix dpop.js produces DER and fails all three with JWSSignatureVerificationFailed — verified by adversarial revert. - Negative: flipped signature byte → rejected. - Negative: forged proof with swapped jwk header → rejected. Verification: lib 346 mocha pass (+9 new); cli 25 pass; assertion and crypto-service unit tests unaffected (Part A scoped only to DPoP). Signed-off-by: Dave Mihalcik --- lib/src/auth/dpop.ts | 16 +- lib/tdf3/src/crypto/core/signing.ts | 13 +- lib/tests/mocha/dpop-proof.spec.ts | 169 +++++++++++++++ lib/tests/server.ts | 310 ++++++++++++++++++++++++++-- 4 files changed, 481 insertions(+), 27 deletions(-) create mode 100644 lib/tests/mocha/dpop-proof.spec.ts diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index 4801fee83..19cc6996d 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -9,6 +9,7 @@ import type { KeyAlgorithm, } from '../../tdf3/src/crypto/declarations.js'; import { isRsaKeyAlgorithm } 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[]; @@ -37,11 +38,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/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c3b824f9d..c1dffc604 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -94,10 +94,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/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts new file mode 100644 index 000000000..58a04121f --- /dev/null +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -0,0 +1,169 @@ +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/server.ts b/lib/tests/server.ts index 90a6be27b..808c72d4f 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -64,6 +64,234 @@ const KAS_RSA_PRIVATE_KEY = DefaultCryptoService.importPrivateKey!(Mocks.kasPriv usage: 'encrypt', }); +// ============================================================================= +// 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}`; +} + function range(start: number, end: number): Uint8Array { const result = []; for (let i = start; i <= end; i++) { @@ -244,6 +472,45 @@ 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. + const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; + const dpopMatch = /^DPoP\s+(.+)$/.exec(authHeader); + if (dpopMatch) { + const accessToken = dpopMatch[1]; + const boundJkt = dpopBoundJkts.get(accessToken); + const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { + htm: 'POST', + htu: requestHtu(req), + requireAth: { accessToken }, + requireBoundJkt: boundJkt, + requireNonce: DPOP_RS_NONCE, + }); + if (!proofCheck.ok) { + // RFC 9449 §8: RS uses 401 + WWW-Authenticate: DPoP error="..." for + // nonce challenges (vs AS which uses 400). Other proof failures get + // 401 invalid_token (downstream platforms vary; tests can match on the + // error code rather than status). + const status = proofCheck.error === 'use_dpop_nonce' ? 401 : proofCheck.status || 401; + const headers: Record = { 'Content-Type': 'application/json' }; + if (proofCheck.challengeNonce) { + headers['DPoP-Nonce'] = proofCheck.challengeNonce; + headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; + } + res.writeHead(status, headers); + res.end( + JSON.stringify({ + error: proofCheck.error, + error_description: proofCheck.error_description, + }) + ); + return; + } + } + const body = await getBody(req); const bodyText = new TextDecoder().decode(body); const { signedRequestToken } = JSON.parse(bodyText); @@ -625,33 +892,38 @@ const kas: RequestListener = async (req, res) => { res.end(JSON.stringify({ status: 'ok' })); return; } else if (url.pathname === '/protocol/openid-connect/token') { - // DPoP nonce challenge test endpoint — simulates a Keycloak token endpoint. - // Always challenges the first request (no nonce in DPoP JWT) with a fixed nonce. - // Accepts the retry once the DPoP proof includes the expected nonce. - const DPOP_TEST_NONCE = 'dpop-test-nonce-abc'; + // 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; - if (!dpopHeader) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' }) - ); - return; - } - const dpopPayload = jose.decodeJwt(dpopHeader); - if (dpopPayload.nonce !== DPOP_TEST_NONCE) { - res.writeHead(401, { - 'Content-Type': 'application/json', - 'DPoP-Nonce': DPOP_TEST_NONCE, - }); + 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: 'use_dpop_nonce', error_description: 'DPoP nonce required' }) + 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: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 }) + JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 }) ); return; } else { From f4a076e905c9a1c0aa6c360d379d80561442204a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 24 Jun 2026 13:11:09 -0400 Subject: [PATCH 31/68] fix(dpop): pass full request URL to AuthProvider.withCreds (DSPX-3397) The authProviderInterceptor handed withCreds only the URL pathname. A DPoP-enabled AuthProvider computes the proof's htu claim and the nonce cache origin via new URL(req.url), which throws "Invalid URL" on a bare path. This surfaced as a CRITICAL [GetAttributeValuesByFqns] [unknown] Invalid URL during encrypt, and as the masked v2 request error in the KAS-list fallback during decrypt. Pass the absolute req.url instead. Non-DPoP providers ignore the URL (they only add a Bearer header), so legacy AuthProviders are unaffected. Signed-off-by: Dave Mihalcik --- lib/src/auth/interceptors.ts | 10 ++++++---- lib/tests/web/interceptors.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 8c68e17b1..7b634d361 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -172,13 +172,15 @@ 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 + // 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. let token; try { token = await authProvider.withCreds({ - url: pathOnly, + url: req.url, method: 'POST', // Start with any headers Connect already has headers: { diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 4c71edd97..932fb1633 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -142,6 +142,27 @@ 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('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {}, From 7511de4c9432310039e2ec11d23758eaa8405061 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 24 Jun 2026 13:23:27 -0400 Subject: [PATCH 32/68] fix(dpop): nonce-challenge retry on legacy fetch path; strip query from htu (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy resource-server fetch helpers (fetchKeyAccessServers, fetchWrappedKey) signed once via AuthProvider.withCreds and fetched once, with no DPoP-Nonce challenge handling. On the first request to a platform origin there is no cached nonce, so the server replies 401 + use_dpop_nonce + DPoP-Nonce and the helper gives up — the "unable to fetch kas list ... status: 401" seen during decrypt. Add fetchWithCredsAndNonceRetry: on a non-ok response carrying a fresh DPoP-Nonce, cache it by origin and retry once so withCreds can mint a proof bound to it. Non-DPoP providers/servers never emit DPoP-Nonce, so they keep the single-request path (legacy users unaffected). Also fix AccessToken.withCreds to strip query/fragment from the proof's htu claim per RFC 9449 §4.2; the kas-list URL carries ?pagination.offset=0, which an RFC-conformant verifier rejects. Signed-off-by: Dave Mihalcik --- lib/src/access/access-fetch.ts | 145 +++++++++++++++------- lib/src/auth/oidc.ts | 9 +- lib/tests/web/access/access-fetch.test.ts | 86 +++++++++++++ lib/tests/web/auth/auth.test.ts | 33 +++++ 4 files changed, 223 insertions(+), 50 deletions(-) diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index f25627e29..fb5e6c4ec 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,5 +1,6 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; -import { type AuthProvider } from '../auth/auth.js'; +import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; +import { DPoPNonceCache, globalNonceCache } from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -10,6 +11,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); + } + }; + + 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 challengeNonce = DPoPNonceCache.extractNonce(response.headers); + if (challengeNonce && challengeNonce !== globalNonceCache.get(origin)) { + globalNonceCache.set(origin, challengeNonce); + response = await send(); + } + } + + // Keep the cache warm from whichever response we end on. + if (origin) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + + return response; +} + export type RewrapRequest = { signedRequestToken: string; }; @@ -33,53 +95,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 +145,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/auth/oidc.ts b/lib/src/auth/oidc.ts index aaafcbef2..bf81856ef 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -424,12 +424,17 @@ export class AccessToken { } const accessToken = await this.get(); if (this.config.dpopEnabled && this.signingKey) { - const origin = new URL(httpReq.url).origin; + 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 = globalNonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, - httpReq.url, + htu, httpReq.method, cachedNonce, accessToken diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index abdba1c86..27c73e880 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 { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -230,6 +231,91 @@ 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)[] = []; + const dpopAuthProvider: AuthProvider = { + withCreds: sinon.stub().callsFake(async (req) => { + noncesSeen.push(globalNonceCache.get(origin)); + return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; + }), + } as unknown as AuthProvider; + + beforeEach(() => { + noncesSeen.length = 0; + globalNonceCache.clear(origin); + // @ts-expect-error stub + dpopAuthProvider.withCreds.resetHistory(); + }); + + afterEach(() => { + globalNonceCache.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'); + }); }); }); From 62edbc5e622a0d2f3a91ec1347eede9b4234dac4 Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:25:07 +0000 Subject: [PATCH 33/68] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/mocha/dpop-proof.spec.ts | 19 +++++++------------ lib/tests/server.ts | 15 +++------------ lib/tests/web/access/access-fetch.test.ts | 16 +++++++++------- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts index 58a04121f..53dc7d3b5 100644 --- a/lib/tests/mocha/dpop-proof.spec.ts +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -50,7 +50,10 @@ async function ecdsaKeyPair(namedCurve: 'P-256' | 'P-384' | 'P-521'): Promise).d; delete (fakeJwk as Record).key_ops; @@ -147,10 +145,7 @@ describe('DPoP proof — JWS conformance vs jose.jwtVerify (RFC 9449 + RFC 7518 * 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 { +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 diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 808c72d4f..c920888ed 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -137,12 +137,7 @@ async function verifyDpopProof( }; } const alg = protectedHeader.alg; - if ( - !alg || - alg === 'none' || - alg.startsWith('HS') || - !/^(ES|RS|PS|EdDSA)/.test(alg) - ) { + if (!alg || alg === 'none' || alg.startsWith('HS') || !/^(ES|RS|PS|EdDSA)/.test(alg)) { return { ok: false, status: 400, @@ -909,9 +904,7 @@ const kas: RequestListener = async (req, res) => { 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 }) - ); + res.end(JSON.stringify({ error: check.error, error_description: check.error_description })); return; } @@ -922,9 +915,7 @@ const kas: RequestListener = async (req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 }) - ); + res.end(JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 })); return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index 27c73e880..a92b1b83f 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -274,13 +274,15 @@ describe('access-fetch.js', () => { 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 - ) - ); + fetchStub + .onCall(1) + .returns( + responseWithNonce( + { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, + true, + 200 + ) + ); const result = await fetchKeyAccessServers(platformUrl, dpopAuthProvider); From d928f73e345f829aefaf34697e8e8661a6e1847e Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 24 Jun 2026 14:44:33 -0400 Subject: [PATCH 34/68] test(dpop): enforce RFC 9449 on RPC endpoints + retry on AuthProvider path (DSPX-3397) The authProviderInterceptor (used by the CLI's --auth opentdf-dpop) lacked DPoP-Nonce challenge retry on the Connect-RPC path, so a server nonce challenge on ListKeyAccessServers failed instead of retrying. This reached xtest because the mock server never challenged that endpoint. - interceptors.ts: add nonce-challenge retry to authProviderInterceptor, mirroring authTokenDPoPInterceptor and the legacy fetch path. - tests/server.ts: add shared enforceRsDpop() gate (gated on Authorization: DPoP) and wire it into ListKeyAccessServers, GetAttributeValuesByFqns, ListAttributes; refactor the rewrap DPoP block to use it and return Connect-correct {code,message} 401s. - add node + browser regression tests driving PlatformClient through a DPoP provider so the RPC nonce-retry path is exercised end to end. --- lib/src/auth/interceptors.ts | 93 +++++++++++++++----- lib/tests/mocha/dpop-rpc-nonce.spec.ts | 61 +++++++++++++ lib/tests/server.ts | 102 ++++++++++++++-------- lib/tests/web/auth/dpop-rpc-nonce.test.ts | 48 ++++++++++ lib/tests/web/interceptors.test.ts | 40 ++++++++- 5 files changed, 283 insertions(+), 61 deletions(-) create mode 100644 lib/tests/mocha/dpop-rpc-nonce.spec.ts create mode 100644 lib/tests/web/auth/dpop-rpc-nonce.test.ts diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 7b634d361..1d76affc9 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -177,35 +177,80 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // 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. - 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', - }, + + // 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 globalNonceCache). + 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); }); + }; + + 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(); + + try { + const response = await next(req); + // Keep the nonce cache warm from successful responses (RFC 9449 §8). + if (origin) { + const responseNonce = response.header.get('dpop-nonce'); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + 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`. Cache the + // nonce, re-sign so withCreds embeds it, 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 = err.metadata.get('dpop-nonce'); + if (serverNonce && serverNonce !== globalNonceCache.get(origin)) { + globalNonceCache.set(origin, serverNonce); + await sign(); + const retryResponse = await next(req); + const retryNonce = retryResponse.header.get('dpop-nonce'); + if (retryNonce) { + globalNonceCache.set(origin, retryNonce); + } + return retryResponse; + } } throw err; } - - Object.entries(token.headers).forEach(([key, value]) => { - req.header.set(key, value); - }); - - return await next(req); }; } 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..6a2a588b0 --- /dev/null +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { globalNonceCache } from '../../src/auth/dpop-nonce.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(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + 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(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index c920888ed..1fafaf1f6 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 { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'; import { base64 } from '../src/encodings/index.js'; @@ -287,6 +287,61 @@ function requestHtu(req: IncomingMessage): string { 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 scheme = 'DPoP'; + if (!authHeader.startsWith(scheme)) return true; // non-DPoP request → unchanged behavior + + // HTTP optional whitespace is limited to SP / HTAB. Parse it directly instead + // of using a backtracking expression over the user-controlled header value. + let tokenStart = scheme.length; + if (authHeader[tokenStart] !== ' ' && authHeader[tokenStart] !== '\t') return true; + while (authHeader[tokenStart] === ' ' || authHeader[tokenStart] === '\t') tokenStart += 1; + + const accessToken = authHeader.slice(tokenStart); + if (!accessToken) return true; + + 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++) { @@ -472,39 +527,7 @@ const kas: RequestListener = async (req, res) => { // request actually carries `Authorization: DPoP `; non-DPoP // (Bearer or unauthenticated) callers pass through unchanged so the // many non-DPoP rewrap tests keep working. - const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; - const dpopMatch = /^DPoP\s+(.+)$/.exec(authHeader); - if (dpopMatch) { - const accessToken = dpopMatch[1]; - const boundJkt = dpopBoundJkts.get(accessToken); - const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { - htm: 'POST', - htu: requestHtu(req), - requireAth: { accessToken }, - requireBoundJkt: boundJkt, - requireNonce: DPOP_RS_NONCE, - }); - if (!proofCheck.ok) { - // RFC 9449 §8: RS uses 401 + WWW-Authenticate: DPoP error="..." for - // nonce challenges (vs AS which uses 400). Other proof failures get - // 401 invalid_token (downstream platforms vary; tests can match on the - // error code rather than status). - const status = proofCheck.error === 'use_dpop_nonce' ? 401 : proofCheck.status || 401; - const headers: Record = { 'Content-Type': 'application/json' }; - if (proofCheck.challengeNonce) { - headers['DPoP-Nonce'] = proofCheck.challengeNonce; - headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; - } - res.writeHead(status, headers); - res.end( - JSON.stringify({ - error: proofCheck.error, - error_description: proofCheck.error_description, - }) - ); - return; - } - } + if (!(await enforceRsDpop(req, res))) return; const body = await getBody(req); const bodyText = new TextDecoder().decode(body); @@ -787,9 +810,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; @@ -837,6 +863,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( @@ -875,8 +902,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' })); 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..03c6cd04f --- /dev/null +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -0,0 +1,48 @@ +import { expect } from '@esm-bundle/chai'; +import { clientSecretAuthProvider } from '../../../src/auth/providers.js'; +import { globalNonceCache } from '../../../src/auth/dpop-nonce.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(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + 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(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 932fb1633..c72af2691 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 { globalNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -163,6 +164,43 @@ describe('authProviderInterceptor', () => { 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`; + globalNonceCache.clear(origin); + + // Provider records the nonce it sees so we can assert the retry carried it. + const seenNonces: (string | undefined)[] = []; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (req: HttpRequest) => { + seenNonces.push(globalNonceCache.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(globalNonceCache.get(origin)).to.equal('server-nonce-xyz'); + globalNonceCache.clear(origin); + }); + it('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {}, From e7d523c668f0b1cae82b01322b53b35effc3cb2d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 06:14:38 -0400 Subject: [PATCH 35/68] fix(dpop): sign rewrap request token with the dpop key's algorithm (DSPX-3397) The KAS rewrap request token was always signed with RS256, so an EC dpop key (e.g. --dpop ES256) made WebCrypto throw 'Unable to use this key to sign', surfacing as 'unable to unwrap key from kas'. Derive the JWS alg from the dpop private key's algorithm instead. Adds a regression test decrypting with EC dpop keys. --- lib/tdf3/src/tdf.ts | 30 +++++++++++++++- lib/tests/mocha/encrypt-decrypt.spec.ts | 47 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 750ad0344..bcfa06b23 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -38,9 +38,11 @@ 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, isMlKemKeyAlgorithm, + type KeyAlgorithm, type KeyPair, mlKemAlgorithmToLevel, type SymmetricKey, @@ -757,6 +759,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, @@ -855,7 +878,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/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 9677746cc..eac97d5b6 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -420,6 +420,53 @@ describe('encrypt decrypt test', async function () { assert.equal(new TextDecoder().decode(decryptedText), expectedVal); }); + 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); From baa3df2209f73889f79d72ad25f9e21a57c96ba9 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 11:12:46 -0400 Subject: [PATCH 36/68] fix(dpop): surface RPC rewrap error instead of legacy 404 mask (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy REST rewrap fallback 404s on Connect-only KAS and masked the real RPC error (e.g. a post-nonce-challenge 401) in tryPromisesUntilFirstSuccess. Now auth/validation errors (401/403/400) surface immediately without a legacy attempt, and for other errors the legacy fallback is still tried but the original RPC error is surfaced if it also fails. Add an integration test driving a full encrypt->decrypt roundtrip through a DPoP auth provider so the rewrap trips the mock KAS RS nonce gate and the interceptor must retry once (RFC 9449 §9). Update rewrap error-case tests to assert the real RPC error now surfaces instead of the masked 404. --- lib/src/access.ts | 44 +++++++++-- lib/tests/mocha/dpop-rewrap-nonce.spec.ts | 94 +++++++++++++++++++++++ lib/tests/mocha/encrypt-decrypt.spec.ts | 25 ++++-- 3 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 lib/tests/mocha/dpop-rewrap-nonce.spec.ts diff --git a/lib/src/access.ts b/lib/src/access.ts index 29cb2fb66..43ca84637 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -7,6 +7,7 @@ import { type KeyAlgorithm, isKeyAlgorithm, } from '../tdf3/src/crypto/declarations.js'; +import { InvalidFileError, PermissionDeniedError, UnauthenticatedError } from './errors.js'; import { fetchKasBasePubKey, @@ -57,21 +58,52 @@ export async function fetchWrappedKey( ); // 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 { + 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 ); } 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..8eb09f14d --- /dev/null +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -0,0 +1,94 @@ +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 { globalNonceCache } from '../../src/auth/dpop-nonce.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(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + 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(globalNonceCache.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 eac97d5b6..2b82721cd 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'); } }); }); From b35108a1b17e24530a31bdbaeb73e8504f03ba01 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 12:46:39 -0400 Subject: [PATCH 37/68] fix(dpop): capture DPoP-Nonce at the Connect transport for rewrap retry (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Against a real require_nonce KAS, the rewrap 401 challenge carries DPoP-Nonce as a raw HTTP response header that connect-web does not surface on ConnectError. metadata, so the interceptor never saw the nonce and could not retry — the 401 propagated (xtest test_dpop_* failing with js as decrypt SDK). Wrap the Connect transport's fetch (platform.ts) to record DPoP-Nonce from the raw Response into the per-origin globalNonceCache via a new captureNonce helper (dpop-nonce.ts). Both DPoP interceptors now source the challenge nonce from the cache (populated by that wrapper) and fall back to error metadata, then re-mint a nonce-bearing proof and retry once (RFC 9449 §9). --- lib/src/auth/dpop-nonce.ts | 23 +++++++++++++++++++++++ lib/src/auth/interceptors.ts | 26 ++++++++++++++++++-------- lib/src/platform.ts | 17 +++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 16c7b3c61..288f58fcc 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -48,3 +48,26 @@ export class DPoPNonceCache { * Shared across all instances to maintain nonce state per-origin. */ export const globalNonceCache = new DPoPNonceCache(); + +/** + * Record a `DPoP-Nonce` response header into {@link globalNonceCache}, 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(requestUrl: string, headers?: Headers): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (!nonce) { + return; + } + try { + globalNonceCache.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/interceptors.ts b/lib/src/auth/interceptors.ts index 1d76affc9..97e849b08 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -115,9 +115,13 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI return response; } catch (err) { - // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge + // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge. + // The transport's fetch wrapper captures 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 = err.metadata.get('dpop-nonce'); + const serverNonce = + globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; if (serverNonce && serverNonce !== cachedNonce) { // Server sent a new nonce (or we didn't have one cached) @@ -219,6 +223,9 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor } 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 ? globalNonceCache.get(origin) : undefined; try { const response = await next(req); @@ -232,13 +239,16 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor return response; } catch (err) { // A DPoP resource server rejects a proof minted without (or with a stale) - // nonce by returning Unauthenticated with a fresh `DPoP-Nonce`. Cache the - // nonce, re-sign so withCreds embeds it, and retry once (RFC 9449 §9). - // Non-DPoP providers/servers never emit a DPoP-Nonce, so this is a no-op - // for them. + // 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 = err.metadata.get('dpop-nonce'); - if (serverNonce && serverNonce !== globalNonceCache.get(origin)) { + const serverNonce = + globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; + if (serverNonce && serverNonce !== sentNonce) { globalNonceCache.set(origin, serverNonce); await sign(); const retryResponse = await next(req); diff --git a/lib/src/platform.ts b/lib/src/platform.ts index fdff544eb..dc09fc42a 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,6 +5,22 @@ 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 } from './auth/dpop-nonce.js'; + +/** + * A `fetch` wrapper that records any `DPoP-Nonce` response header into the global + * nonce cache 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. + */ +const nonceCapturingFetch: typeof globalThis.fetch = 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(requestUrl, response.headers); + return response; +}; import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; @@ -99,6 +115,7 @@ export class PlatformClient { const transport = createConnectTransport({ baseUrl: options.platformUrl, interceptors, + fetch: nonceCapturingFetch, }); this.v1 = { From 583a29989569447cad57d9bdc4dcb9f14420a139 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 13:50:58 -0400 Subject: [PATCH 38/68] debug(dpop): temporary fetch-layer header dump for 401/400 (DSPX-3397) --- lib/src/platform.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/src/platform.ts b/lib/src/platform.ts index dc09fc42a..7f558a6ef 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -18,6 +18,20 @@ const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { const response = await fetch(input, init); const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + // TEMP DSPX-3397 DEBUG: dump what the fetch layer sees on auth failures. + if (response.status === 401 || response.status === 400) { + try { + const hdrs: Record = {}; + response.headers.forEach((v, k) => { + hdrs[k] = v; + }); + console.error( + `[DPOP-DEBUG] ${response.status} ${requestUrl} dpop-nonce=[${response.headers.get('dpop-nonce')}] headers=${JSON.stringify(hdrs)}` + ); + } catch (e) { + console.error('[DPOP-DEBUG] header dump failed', e); + } + } captureNonce(requestUrl, response.headers); return response; }; From 243aba6847d2029af55e7f7be0363883427b002c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 14:19:38 -0400 Subject: [PATCH 39/68] debug(dpop): dump rewrap proof claims + 401 body (DSPX-3397) --- lib/src/platform.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/src/platform.ts b/lib/src/platform.ts index 7f558a6ef..ee4bc0583 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -18,18 +18,36 @@ const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { const response = await fetch(input, init); const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; - // TEMP DSPX-3397 DEBUG: dump what the fetch layer sees on auth failures. - if (response.status === 401 || response.status === 400) { + // TEMP DSPX-3397 DEBUG: dump request proof + response body for rewrap calls. + if (requestUrl.includes('Rewrap')) { try { - const hdrs: Record = {}; - response.headers.forEach((v, k) => { - hdrs[k] = v; - }); + const h = init?.headers; + let dpopHdr: string | undefined; + let authHdr: string | undefined; + if (h instanceof Headers) { + dpopHdr = h.get('dpop') ?? undefined; + authHdr = h.get('authorization') ?? undefined; + } else if (h && typeof h === 'object') { + const rec = h as Record; + dpopHdr = rec['dpop'] ?? rec['DPoP']; + authHdr = rec['authorization'] ?? rec['Authorization']; + } + let proof = ''; + if (dpopHdr) { + const parts = dpopHdr.split('.'); + if (parts.length === 3) { + proof = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); + } + } + let body = ''; + if (response.status === 401 || response.status === 400) { + body = await response.clone().text(); + } console.error( - `[DPOP-DEBUG] ${response.status} ${requestUrl} dpop-nonce=[${response.headers.get('dpop-nonce')}] headers=${JSON.stringify(hdrs)}` + `[DPOP-DEBUG2] ${response.status} ${requestUrl} authScheme=[${authHdr?.slice(0, 12)}] proof=${proof} respNonce=[${response.headers.get('dpop-nonce')}] body=${body}` ); } catch (e) { - console.error('[DPOP-DEBUG] header dump failed', e); + console.error('[DPOP-DEBUG2] dump failed', e); } } captureNonce(requestUrl, response.headers); From a978a6ca6a590058ac7c1cde641c95a20ebb007b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 15:15:41 -0400 Subject: [PATCH 40/68] fix(dpop): emit raw IEEE P1363 ECDSA sigs in rewrap request token (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KAS rewrap request token (SRT) is signed via reqSignature -> signJwt, but signJwt used cryptoService.sign's output directly, which is DER-encoded for ECDSA. JWS (RFC 7518 §3.4) requires raw IEEE P1363 (R||S), so a real KAS rejected the ES256-signed SRT with 'unable to verify request token' (a 401 that surfaced only after the DPoP nonce challenge was satisfied). The mock test server only decodeJwt's the SRT, so this was invisible locally. signJwt now converts ECDSA signatures DER->P1363 (mirroring src/auth/dpop.ts), and verifyJwt converts P1363->DER before cryptoService.verify so ES* assertions still round-trip. Export ieeeP1363ToDer for the verify path. Add a spec that verifies reqSignature/signJwt ES256/384/512 output against jose.jwtVerify (RFC-conformant), which the in-SDK round-trip and the mock server cannot catch. Also removes the temporary fetch-layer debug logging. --- lib/src/platform.ts | 32 --------- lib/tdf3/src/crypto/core/signing.ts | 7 +- lib/tdf3/src/crypto/jwt.ts | 28 +++++--- lib/tests/mocha/reqsignature-jws.spec.ts | 92 ++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 45 deletions(-) create mode 100644 lib/tests/mocha/reqsignature-jws.spec.ts diff --git a/lib/src/platform.ts b/lib/src/platform.ts index ee4bc0583..dc09fc42a 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -18,38 +18,6 @@ const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { const response = await fetch(input, init); const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; - // TEMP DSPX-3397 DEBUG: dump request proof + response body for rewrap calls. - if (requestUrl.includes('Rewrap')) { - try { - const h = init?.headers; - let dpopHdr: string | undefined; - let authHdr: string | undefined; - if (h instanceof Headers) { - dpopHdr = h.get('dpop') ?? undefined; - authHdr = h.get('authorization') ?? undefined; - } else if (h && typeof h === 'object') { - const rec = h as Record; - dpopHdr = rec['dpop'] ?? rec['DPoP']; - authHdr = rec['authorization'] ?? rec['Authorization']; - } - let proof = ''; - if (dpopHdr) { - const parts = dpopHdr.split('.'); - if (parts.length === 3) { - proof = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); - } - } - let body = ''; - if (response.status === 401 || response.status === 400) { - body = await response.clone().text(); - } - console.error( - `[DPOP-DEBUG2] ${response.status} ${requestUrl} authScheme=[${authHdr?.slice(0, 12)}] proof=${proof} respNonce=[${response.headers.get('dpop-nonce')}] body=${body}` - ); - } catch (e) { - console.error('[DPOP-DEBUG2] dump failed', e); - } - } captureNonce(requestUrl, response.headers); return response; }; diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c1dffc604..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; } 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/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'); + }); + } +}); From b6a05e6a652433947a78e3a95955f37aeff8a91b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 15:11:31 -0400 Subject: [PATCH 41/68] refactor(dpop): extract shared nonce-challenge helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DPoP-Nonce challenge/retry bookkeeping (RFC 9449 §8/§9) was duplicated across six call sites in two transport families. Extract three helpers in dpop-nonce.ts: - adoptChallengeNonce: fresh challenge nonce from a Response (raw-fetch family) - adoptChallengeNonceFromConnectError: same for a Connect Unauthenticated error - warmNonceFromResponse: keep the cache warm from any response nonce Each call site now delegates the cache bookkeeping and keeps only its transport-specific re-sign/resend inline. Still routed through globalNonceCache; no behavior change (DPoP mocha specs green). --- lib/src/access/access-fetch.ts | 16 ++++----- lib/src/auth/dpop-nonce.ts | 61 ++++++++++++++++++++++++++++++++ lib/src/auth/interceptors.ts | 64 ++++++++++++++-------------------- lib/src/auth/oidc.ts | 45 +++++++++++------------- 4 files changed, 115 insertions(+), 71 deletions(-) diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index fb5e6c4ec..bc9d34db7 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,6 +1,10 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; -import { DPoPNonceCache, globalNonceCache } from '../auth/dpop-nonce.js'; +import { + adoptChallengeNonce, + globalNonceCache, + warmNonceFromResponse, +} from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -54,19 +58,15 @@ async function fetchWithCredsAndNonceRetry( let response = await send(); if (!response.ok && origin) { - const challengeNonce = DPoPNonceCache.extractNonce(response.headers); - if (challengeNonce && challengeNonce !== globalNonceCache.get(origin)) { - globalNonceCache.set(origin, challengeNonce); + const sentNonce = globalNonceCache.get(origin); + if (adoptChallengeNonce(globalNonceCache, origin, response.headers, sentNonce)) { response = await send(); } } // Keep the cache warm from whichever response we end on. if (origin) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.headers); } return response; diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 288f58fcc..9d8cbf961 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -43,6 +43,67 @@ export class DPoPNonceCache { } } +/** + * 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); + } +} + /** * Global nonce cache singleton. * Shared across all instances to maintain nonce state per-origin. diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 97e849b08..7c7bddd2a 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -5,7 +5,11 @@ 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 { globalNonceCache } from './dpop-nonce.js'; +import { + adoptChallengeNonceFromConnectError, + globalNonceCache, + warmNonceFromResponse, +} from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -106,29 +110,22 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // Call next and handle DPoP-Nonce retry try { const response = await next(req); - - // Extract and cache nonce from successful responses - const responseNonce = response.header.get('dpop-nonce'); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } - + warmNonceFromResponse(globalNonceCache, origin, response.header); return response; } catch (err) { - // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge. - // The transport's fetch wrapper captures the nonce from the raw 401 response + // 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 = - globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; - - if (serverNonce && serverNonce !== cachedNonce) { - // Server sent a new nonce (or we didn't have one cached) - // Cache it and retry once - globalNonceCache.set(origin, serverNonce); - - // Regenerate proof with server nonce + const serverNonce = adoptChallengeNonceFromConnectError( + globalNonceCache, + origin, + err.metadata, + cachedNonce + ); + if (serverNonce) { + // Regenerate proof with the server nonce and retry once. const retryDpopProof = await DPoP( keys, cryptoService, @@ -140,13 +137,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI req.header.set('DPoP', retryDpopProof); const retryResponse = await next(req); - - // Update cache from retry response if present - const retryNonce = retryResponse.header.get('dpop-nonce'); - if (retryNonce) { - globalNonceCache.set(origin, retryNonce); - } - + warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); return retryResponse; } } @@ -231,10 +222,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor const response = await next(req); // Keep the nonce cache warm from successful responses (RFC 9449 §8). if (origin) { - const responseNonce = response.header.get('dpop-nonce'); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.header); } return response; } catch (err) { @@ -246,16 +234,16 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // 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 = - globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; - if (serverNonce && serverNonce !== sentNonce) { - globalNonceCache.set(origin, serverNonce); + const serverNonce = adoptChallengeNonceFromConnectError( + globalNonceCache, + origin, + err.metadata, + sentNonce + ); + if (serverNonce) { await sign(); const retryResponse = await next(req); - const retryNonce = retryResponse.header.get('dpop-nonce'); - if (retryNonce) { - globalNonceCache.set(origin, retryNonce); - } + warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); return retryResponse; } } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index bf81856ef..bf521bff5 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,7 +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 { globalNonceCache, DPoPNonceCache } from './dpop-nonce.js'; +import { adoptChallengeNonce, globalNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -171,9 +171,13 @@ export class AccessToken { // 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 = DPoPNonceCache.extractNonce(response.headers); - if (challengeNonce && challengeNonce !== cachedNonce) { - globalNonceCache.set(origin, challengeNonce); + const challengeNonce = adoptChallengeNonce( + globalNonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, @@ -190,10 +194,7 @@ export class AccessToken { // Update nonce cache from final response if (this.config.dpopEnabled) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.headers); } if (!response.ok) { @@ -238,18 +239,20 @@ export class AccessToken { // 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 responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce && responseNonce !== cachedNonce) { - // Cache the server-provided nonce and retry - globalNonceCache.set(origin, responseNonce); - - // Regenerate DPoP proof with nonce + const challengeNonce = adoptChallengeNonce( + globalNonceCache, + 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', - responseNonce + challengeNonce ); const retryResponse = await (this.request || fetch)(url, { @@ -258,22 +261,14 @@ export class AccessToken { body: qstringify(o), }); - // Update cache from retry response - const retryNonce = DPoPNonceCache.extractNonce(retryResponse.headers); - if (retryNonce) { - globalNonceCache.set(origin, retryNonce); - } - + warmNonceFromResponse(globalNonceCache, origin, retryResponse.headers); return retryResponse; } } // Update nonce cache from successful responses if (this.config.dpopEnabled && response.ok) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.headers); } return response; From b8c0207dd2593a9979afb87c3ec4c59c43d0444d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 17:22:04 -0400 Subject: [PATCH 42/68] refactor(dpop): make DPoP nonce cache injectable per-client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the process-wide global nonce singleton with a per-client cache so nonces no longer leak across independent SDK clients (RFC 9449 §8). - AccessToken owns a DPoPNonceCache; each OIDC provider exposes it via a nonceCache getter, so its interceptor and legacy-fetch retry read the same instance its withCreds writes. - AuthProvider and DPoPInterceptorOptions/PlatformClientOptions gain an optional nonceCache; authProviderInterceptor and fetchWithCredsAndNonceRetry resolve authProvider.nonceCache, authTokenDPoPInterceptor and PlatformClient fall back to defaultNonceCache (also exposed as the deprecated globalNonceCache alias for back-compat). - captureNonce and the transport fetch wrapper (makeNonceCapturingFetch) take the cache explicitly; access-rpc/access.ts thread the provider's cache so the transport's nonce capture and the interceptor retry stay on one instance (the KAS rewrap/RPC nonce-challenge guardrail). Tests migrated to observe the per-client cache. Full suite green: mocha 354, web-test-runner 260. --- lib/src/access.ts | 13 ++++-- lib/src/access/access-fetch.ts | 12 ++++-- lib/src/access/access-rpc.ts | 18 +++++++- lib/src/auth/auth.ts | 10 +++++ lib/src/auth/dpop-nonce.ts | 22 ++++++---- lib/src/auth/interceptors.ts | 33 ++++++++++----- .../auth/oidc-clientcredentials-provider.ts | 6 +++ lib/src/auth/oidc-externaljwt-provider.ts | 6 +++ lib/src/auth/oidc-refreshtoken-provider.ts | 6 +++ lib/src/auth/oidc.ts | 32 +++++++++----- lib/src/platform.ts | 42 +++++++++++++------ lib/tests/mocha/dpop-nonce.spec.ts | 16 ++++--- lib/tests/mocha/dpop-rewrap-nonce.spec.ts | 7 +--- lib/tests/mocha/dpop-rpc-nonce.spec.ts | 7 +--- lib/tests/web/access/access-fetch.test.ts | 11 +++-- lib/tests/web/auth/dpop-nonce.test.ts | 32 +++++++------- lib/tests/web/auth/dpop-rpc-nonce.test.ts | 7 +--- lib/tests/web/interceptors.test.ts | 10 ++--- 18 files changed, 190 insertions(+), 100 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 43ca84637..13618553a 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -47,13 +47,16 @@ 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) ); @@ -214,9 +217,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 bc9d34db7..6c6b8c01e 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -2,7 +2,7 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../acc import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; import { adoptChallengeNonce, - globalNonceCache, + defaultNonceCache, warmNonceFromResponse, } from '../auth/dpop-nonce.js'; import { @@ -48,6 +48,10 @@ async function fetchWithCredsAndNonceRetry( } }; + // 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; @@ -58,15 +62,15 @@ async function fetchWithCredsAndNonceRetry( let response = await send(); if (!response.ok && origin) { - const sentNonce = globalNonceCache.get(origin); - if (adoptChallengeNonce(globalNonceCache, origin, response.headers, sentNonce)) { + 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(globalNonceCache, origin, response.headers); + warmNonceFromResponse(nonceCache, origin, response.headers); } return response; 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 index 9d8cbf961..4d554f316 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -105,14 +105,22 @@ export function warmNonceFromResponse( } /** - * Global nonce cache singleton. - * Shared across all instances to maintain nonce state per-origin. + * 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 globalNonceCache = new DPoPNonceCache(); +export const defaultNonceCache = new DPoPNonceCache(); /** - * Record a `DPoP-Nonce` response header into {@link globalNonceCache}, keyed by - * the request's origin. + * @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 @@ -121,13 +129,13 @@ export const globalNonceCache = new DPoPNonceCache(); * (RFC 9449 §9); capturing here lets the auth layer mint a nonce-bearing proof on * retry. */ -export function captureNonce(requestUrl: string, headers?: Headers): void { +export function captureNonce(cache: DPoPNonceCache, requestUrl: string, headers?: Headers): void { const nonce = DPoPNonceCache.extractNonce(headers); if (!nonce) { return; } try { - globalNonceCache.set(new URL(requestUrl).origin, nonce); + 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/interceptors.ts b/lib/src/auth/interceptors.ts index 7c7bddd2a..8791b3aac 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -7,7 +7,8 @@ import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; import { adoptChallengeNonceFromConnectError, - globalNonceCache, + DPoPNonceCache, + defaultNonceCache, warmNonceFromResponse, } from './dpop-nonce.js'; @@ -27,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; }; /** @@ -83,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(); @@ -95,7 +103,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const httpUri = `${origin}${url.pathname}`; // Check for cached nonce - const cachedNonce = globalNonceCache.get(origin); + const cachedNonce = nonceCache.get(origin); // Generate DPoP proof JWT for this request const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST', cachedNonce, token); @@ -110,7 +118,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // Call next and handle DPoP-Nonce retry try { const response = await next(req); - warmNonceFromResponse(globalNonceCache, origin, response.header); + warmNonceFromResponse(nonceCache, origin, response.header); return response; } catch (err) { // A Connect Unauthenticated error may carry a DPoP-Nonce challenge. The @@ -119,7 +127,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // error metadata is a fallback for transports that do expose it. if (err instanceof ConnectError && err.code === Code.Unauthenticated) { const serverNonce = adoptChallengeNonceFromConnectError( - globalNonceCache, + nonceCache, origin, err.metadata, cachedNonce @@ -137,7 +145,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI req.header.set('DPoP', retryDpopProof); const retryResponse = await next(req); - warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); return retryResponse; } } @@ -175,7 +183,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // 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 globalNonceCache). + // fresh proof carrying the server-issued nonce (read from nonceCache). const sign = async (): Promise => { let token; try { @@ -206,6 +214,11 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor }); }; + // 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; @@ -216,13 +229,13 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor 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 ? globalNonceCache.get(origin) : undefined; + 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(globalNonceCache, origin, response.header); + warmNonceFromResponse(nonceCache, origin, response.header); } return response; } catch (err) { @@ -235,7 +248,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // DPoP-Nonce, so this is a no-op for them. if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { const serverNonce = adoptChallengeNonceFromConnectError( - globalNonceCache, + nonceCache, origin, err.metadata, sentNonce @@ -243,7 +256,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor if (serverNonce) { await sign(); const retryResponse = await next(req); - warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); return retryResponse; } } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 6a4ba83bb..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'; @@ -45,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 cfe66bff2..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'; @@ -54,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 c23a25b04..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'; @@ -68,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 bf521bff5..6490f4b0d 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,7 +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, globalNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; +import { adoptChallengeNonce, DPoPNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -110,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' @@ -138,6 +149,7 @@ export class AccessToken { this.userInfoEndpoint = cfg.oidcUserInfoEndpoint || `${this.baseUrl}/protocol/openid-connect/userinfo`; this.signingKey = cfg.signingKey; + this.nonceCache = nonceCache; } /** @@ -152,7 +164,7 @@ export class AccessToken { } as Record; let cachedNonce: string | undefined; if (this.config.dpopEnabled && this.signingKey) { - cachedNonce = globalNonceCache.get(origin); + cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, @@ -172,7 +184,7 @@ export class AccessToken { // 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( - globalNonceCache, + this.nonceCache, origin, response.headers, cachedNonce @@ -194,7 +206,7 @@ export class AccessToken { // Update nonce cache from final response if (this.config.dpopEnabled) { - warmNonceFromResponse(globalNonceCache, origin, response.headers); + warmNonceFromResponse(this.nonceCache, origin, response.headers); } if (!response.ok) { @@ -225,7 +237,7 @@ export class AccessToken { // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - cachedNonce = globalNonceCache.get(origin); + cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); } @@ -240,7 +252,7 @@ export class AccessToken { // Trigger on any non-OK response that carries a fresh DPoP-Nonce header. if (this.config.dpopEnabled && !response.ok) { const challengeNonce = adoptChallengeNonce( - globalNonceCache, + this.nonceCache, origin, response.headers, cachedNonce @@ -261,14 +273,14 @@ export class AccessToken { body: qstringify(o), }); - warmNonceFromResponse(globalNonceCache, origin, retryResponse.headers); + warmNonceFromResponse(this.nonceCache, origin, retryResponse.headers); return retryResponse; } } // Update nonce cache from successful responses if (this.config.dpopEnabled && response.ok) { - warmNonceFromResponse(globalNonceCache, origin, response.headers); + warmNonceFromResponse(this.nonceCache, origin, response.headers); } return response; @@ -425,7 +437,7 @@ export class AccessToken { // 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 = globalNonceCache.get(origin); + const cachedNonce = this.nonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, diff --git a/lib/src/platform.ts b/lib/src/platform.ts index dc09fc42a..afab6f8f5 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,22 +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 } from './auth/dpop-nonce.js'; +import { captureNonce, DPoPNonceCache, defaultNonceCache } from './auth/dpop-nonce.js'; /** - * A `fetch` wrapper that records any `DPoP-Nonce` response header into the global - * nonce cache before handing the response back to the Connect transport. The + * 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. + * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. `nonceCache` + * must be the same instance the auth interceptor reads. */ -const nonceCapturingFetch: typeof globalThis.fetch = 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(requestUrl, response.headers); - return response; -}; +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'; @@ -70,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; } /** @@ -112,10 +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: nonceCapturingFetch, + fetch: makeNonceCapturingFetch(nonceCache), }); this.v1 = { diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index c01d9562d..e601153e4 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import { AccessToken } from '../../src/auth/oidc.js'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -19,9 +18,8 @@ describe('DPoP nonce challenge — integration with mock server', function (this keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // 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( @@ -50,13 +48,10 @@ describe('DPoP nonce challenge — integration with mock server', function (this expect(body.access_token).to.equal('test-dpop-token'); // Cache must be populated with the server's nonce after the round-trip - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + expect(accessToken.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); }); it('uses cached nonce on the first request after a prior successful challenge', async () => { - // Pre-seed cache as if a prior request already populated it - globalNonceCache.set(SERVER_ORIGIN, SERVER_NONCE); - const accessToken = new AccessToken( { clientId: 'test-client', @@ -69,6 +64,9 @@ describe('DPoP nonce challenge — integration with mock server', function (this 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', @@ -95,7 +93,7 @@ describe('DPoP nonce challenge — integration with mock server', function (this const token = await provider.oidcAuth.get(false); expect(token).to.equal('test-dpop-token'); - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + 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 () => { diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts index 8eb09f14d..81ef6b8fe 100644 --- a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -3,7 +3,6 @@ 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 { globalNonceCache } from '../../src/auth/dpop-nonce.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'; @@ -37,9 +36,7 @@ describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock s dpopKeyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // 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'; @@ -89,6 +86,6 @@ describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock s // A successful decrypt proves the rewrap survived the challenge; the cached // RS nonce proves a challenge actually happened and the retry adopted it. - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + 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 index 6a2a588b0..0ae030c34 100644 --- a/lib/tests/mocha/dpop-rpc-nonce.spec.ts +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -1,6 +1,5 @@ import { expect } from 'chai'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { PlatformClient } from '../../src/platform.js'; import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -29,9 +28,7 @@ describe('DPoP RS nonce retry over Connect-RPC — integration with mock server' keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // 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({ @@ -56,6 +53,6 @@ describe('DPoP RS nonce retry over Connect-RPC — integration with mock server' // The consumed challenge leaves the RS nonce cached for the origin, proving // a challenge happened and the retry adopted it. - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); }); }); diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index a92b1b83f..ab3bb23cd 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -16,7 +16,7 @@ import { UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; -import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -252,22 +252,25 @@ describe('access-fetch.js', () => { // 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(globalNonceCache.get(origin)); + noncesSeen.push(nonceCache.get(origin)); return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; }), } as unknown as AuthProvider; beforeEach(() => { noncesSeen.length = 0; - globalNonceCache.clear(origin); + nonceCache.clear(origin); // @ts-expect-error stub dpopAuthProvider.withCreds.resetHistory(); }); afterEach(() => { - globalNonceCache.clear(origin); + nonceCache.clear(origin); }); it('retries once with the server nonce and succeeds', async () => { diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 055faee80..c695dc654 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -2,7 +2,7 @@ import { expect } from '@esm-bundle/chai'; import { Code, ConnectError } from '@connectrpc/connect'; import { stub } from 'sinon'; import { AccessToken } from '../../../src/auth/oidc.js'; -import { globalNonceCache } from '../../../src/auth/dpop-nonce.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'; @@ -27,9 +27,7 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // Each AccessToken owns its own per-client nonce cache — tests are isolated. function makeAccessToken(fetchStub: typeof fetch) { return new AccessToken( @@ -67,7 +65,7 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { expect(fetchStub.callCount).to.equal(2); expect(result.status).to.equal(200); - expect(globalNonceCache.get(ORIGIN)).to.equal(NONCE); + 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; @@ -77,9 +75,6 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { }); it('does not retry when server returns the same nonce already cached', async () => { - // Pre-seed the cache with the same nonce the server will return - globalNonceCache.set(ORIGIN, NONCE); - const fetchStub = stub().resolves({ status: 401, ok: false, @@ -87,6 +82,8 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { } 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 @@ -108,14 +105,11 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); - - function makeInterceptor() { + function makeInterceptor(nonceCache: DPoPNonceCache) { return authTokenDPoPInterceptor({ tokenProvider: async () => 'dummy-access-token', dpopKeys: Promise.resolve(keyPair), + nonceCache, }); } @@ -142,11 +136,12 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); - const interceptor = makeInterceptor(); + const nonceCache = new DPoPNonceCache(); + const interceptor = makeInterceptor(nonceCache); await interceptor(mockNext as Parameters[0])(makeMockReq()); expect(mockNext.callCount).to.equal(2); - expect(globalNonceCache.get(ORIGIN)).to.equal(NONCE); + expect(nonceCache.get(ORIGIN)).to.equal(NONCE); // Retry request must have nonce in its DPoP proof const retryReq = mockNext.secondCall.firstArg as { header: Headers }; @@ -156,7 +151,8 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { }); it('does not retry when server returns the same nonce already cached', async () => { - globalNonceCache.set(ORIGIN, NONCE); + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, NONCE); const mockNext = stub().callsFake(() => Promise.reject( @@ -168,11 +164,11 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { ) ); - const interceptor = makeInterceptor(); + const interceptor = makeInterceptor(nonceCache); try { await interceptor(mockNext as Parameters[0])(makeMockReq()); expect.fail('should have thrown'); - } catch (err) { + } catch { // Expected: interceptor re-throws when nonce unchanged } diff --git a/lib/tests/web/auth/dpop-rpc-nonce.test.ts b/lib/tests/web/auth/dpop-rpc-nonce.test.ts index 03c6cd04f..378812b4f 100644 --- a/lib/tests/web/auth/dpop-rpc-nonce.test.ts +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -1,6 +1,5 @@ import { expect } from '@esm-bundle/chai'; import { clientSecretAuthProvider } from '../../../src/auth/providers.js'; -import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; import { PlatformClient } from '../../../src/platform.js'; import { generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; @@ -22,9 +21,7 @@ describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // 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({ @@ -43,6 +40,6 @@ describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + 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 c72af2691..91db683d1 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -10,7 +10,7 @@ import { resolveAuthConfig, isInterceptorConfig, } from '../../src/auth/interceptors.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; +import { DPoPNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -167,14 +167,15 @@ describe('authProviderInterceptor', () => { 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`; - globalNonceCache.clear(origin); + 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(globalNonceCache.get(new URL(req.url).origin)); + seenNonces.push(nonceCache.get(new URL(req.url).origin)); return withHeaders(req, { Authorization: 'DPoP token' }); }, }; @@ -197,8 +198,7 @@ describe('authProviderInterceptor', () => { expect(attempts).to.equal(2); expect(seenNonces).to.deep.equal([undefined, 'server-nonce-xyz']); - expect(globalNonceCache.get(origin)).to.equal('server-nonce-xyz'); - globalNonceCache.clear(origin); + expect(nonceCache.get(origin)).to.equal('server-nonce-xyz'); }); it('wraps updateClientPublicKey errors with helpful message', async () => { From 68db023b4f1ee059ce1e83fa396b7d6c8e28e4fa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 18:03:49 -0400 Subject: [PATCH 43/68] fix(access): surface swallowed legacy rewrap fallback error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the legacy REST rewrap fallback also fails, we still (correctly) rethrow the more meaningful RPC error — but the legacy failure was dropped entirely. Log it via console.info (matching the sibling 'v2 rewrap request error' line) so the fallback path is debuggable. Behavior is otherwise unchanged. --- lib/src/access.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 13618553a..839c0818a 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -90,7 +90,10 @@ export async function fetchWrappedKey( { signedRequestToken }, authProvider )) as unknown as RewrapResponse; - } catch { + } 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; } } From 2aa3a74468395fda18dc7fb3e29637b92c863eb0 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 8 Jul 2026 10:10:58 -0400 Subject: [PATCH 44/68] fix(cli): forward per-client DPoP nonce cache through LoggedAuthProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI wraps the OIDC provider in a LoggedAuthProvider decorator that exposed withCreds/updateClientPublicKey but not nonceCache. After the per-client cache change, the auth interceptor and Connect transport resolved `authProvider.nonceCache ?? defaultNonceCache` (=> default, since the wrapper hid it) while withCreds — delegated to the wrapped AccessToken — minted proofs from the AccessToken's own cache. The two caches diverged, so the DPoP-Nonce challenge retry (RFC 9449 §9) never carried the server nonce and js decrypt failed at ListKeyAccessServers with 401 (xtest test_dpop_happy_path_roundtrip, test_dpop_server_issued_nonce_retry). Forward nonceCache so the wrapper, its interceptor, and withCreds share one instance. --- cli/src/cli.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index c79845fd6..575ac63f6 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -96,6 +96,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}]`); From 084baf6fa001320c4b8e876e2339bbbdc6bab692 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 8 Jul 2026 15:24:15 -0400 Subject: [PATCH 45/68] refactor(dpop): default nonce cache to shared defaultNonceCache Per-client-by-default proved fragile: an AuthProvider decorator that doesn't forward nonceCache (the CLI's LoggedAuthProvider) split the interceptor/transport cache from the one AccessToken.withCreds mints against, breaking the DPoP-Nonce retry. Make the shared defaultNonceCache the default for AccessToken so every DPoP path converges on one instance even through non-forwarding wrappers. Per-client isolation stays opt-in via the existing injection points (AccessToken constructor, PlatformClientOptions.nonceCache, DPoPInterceptorOptions.nonceCache). The CLI nonceCache forward (previous commit) is now redundant but kept as future-proofing. Provider/AccessToken-based specs restore afterEach teardown of the shared cache to stay isolated. Full suite green: mocha 354, web-test-runner all pass. --- lib/src/auth/auth.ts | 11 ++++++----- lib/src/auth/dpop-nonce.ts | 15 +++++++++------ lib/src/auth/oidc.ts | 16 ++++++++++++---- lib/tests/mocha/dpop-nonce.spec.ts | 8 ++++++-- lib/tests/mocha/dpop-rewrap-nonce.spec.ts | 6 +++++- lib/tests/mocha/dpop-rpc-nonce.spec.ts | 6 +++++- lib/tests/web/auth/dpop-nonce.test.ts | 7 +++++-- lib/tests/web/auth/dpop-rpc-nonce.test.ts | 6 +++++- 8 files changed, 53 insertions(+), 22 deletions(-) diff --git a/lib/src/auth/auth.ts b/lib/src/auth/auth.ts index 6482a6c06..838e9acfb 100644 --- a/lib/src/auth/auth.ts +++ b/lib/src/auth/auth.ts @@ -113,11 +113,12 @@ export type AuthProvider = { 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. + * DPoP-Nonce cache (RFC 9449 §8), keyed by origin. Optional: consumers fall + * back to the shared `defaultNonceCache` when it is absent, so custom/legacy + * providers keep working. SDK providers expose the cache their own proofs read + * and write (the shared default unless a dedicated cache was injected for + * per-client isolation), so the auth interceptor and the transport read the + * same instance. Decorators that wrap a provider should forward this. */ nonceCache?: DPoPNonceCache; }; diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 4d554f316..268e5e95c 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -105,16 +105,19 @@ export function warmNonceFromResponse( } /** - * 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. + * Shared, process-wide nonce cache — the default for every DPoP path (the + * `AccessToken` cache, the auth interceptor, the Connect transport, and the + * legacy fetch retry) unless a dedicated cache is injected. Keeping one default + * instance means those layers stay consistent even when a provider is wrapped by + * a decorator that doesn't forward `nonceCache`. For per-client isolation, pass a + * dedicated {@link DPoPNonceCache} to the `AccessToken` constructor, + * `PlatformClientOptions.nonceCache`, or `DPoPInterceptorOptions.nonceCache`. */ 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 + * @deprecated Prefer {@link defaultNonceCache} (or an injected per-client + * {@link DPoPNonceCache}). Retained as an alias of {@link defaultNonceCache} for * backwards compatibility — it is the *same object*, not a second cache. */ export const globalNonceCache = defaultNonceCache; diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 6490f4b0d..9e8c60aa0 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,7 +5,12 @@ 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'; +import { + adoptChallengeNonce, + defaultNonceCache, + DPoPNonceCache, + warmNonceFromResponse, +} from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -111,8 +116,11 @@ export class AccessToken { cryptoService: CryptoService; /** - * 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`. + * DPoP-Nonce cache (RFC 9449 §8). Defaults to the shared {@link defaultNonceCache} + * so the interceptor, transport, and `withCreds` stay consistent even when a + * provider is wrapped by a decorator that doesn't forward `nonceCache`. Pass a + * dedicated {@link DPoPNonceCache} to the constructor for per-client isolation; + * it is exposed on providers via `nonceCache` so the auth layer reads the same instance. */ readonly nonceCache: DPoPNonceCache; @@ -120,7 +128,7 @@ export class AccessToken { cfg: OIDCCredentials, cryptoService: CryptoService, request?: typeof fetch, - nonceCache: DPoPNonceCache = new DPoPNonceCache() + nonceCache: DPoPNonceCache = defaultNonceCache ) { if (!cfg.clientId) { throw new ConfigurationError( diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index e601153e4..b2a0d0b8b 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { AccessToken } from '../../src/auth/oidc.js'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { defaultNonceCache } from '../../src/auth/dpop-nonce.js'; import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -18,8 +19,11 @@ describe('DPoP nonce challenge — integration with mock server', function (this keyPair = await generateSigningKeyPair(); }); - // Each AccessToken/provider owns its own per-client nonce cache, so tests are - // naturally isolated — no shared-cache teardown needed. + // AccessToken/providers default to the shared defaultNonceCache; clear it + // between tests so a cached nonce doesn't leak across cases. + afterEach(() => { + defaultNonceCache.clearAll(); + }); it('transparently retries with server-issued nonce and returns 200', async () => { const accessToken = new AccessToken( diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts index 81ef6b8fe..03399316d 100644 --- a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -3,6 +3,7 @@ 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 { defaultNonceCache } from '../../src/auth/dpop-nonce.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'; @@ -36,7 +37,10 @@ describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock s dpopKeyPair = await generateSigningKeyPair(); }); - // The provider owns its per-client nonce cache, so each test is isolated. + // Providers default to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); it('decrypt survives the rewrap nonce challenge and returns the plaintext', async () => { const expectedVal = 'rewrap nonce roundtrip'; diff --git a/lib/tests/mocha/dpop-rpc-nonce.spec.ts b/lib/tests/mocha/dpop-rpc-nonce.spec.ts index 0ae030c34..d42ac7767 100644 --- a/lib/tests/mocha/dpop-rpc-nonce.spec.ts +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { defaultNonceCache } from '../../src/auth/dpop-nonce.js'; import { PlatformClient } from '../../src/platform.js'; import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -28,7 +29,10 @@ describe('DPoP RS nonce retry over Connect-RPC — integration with mock server' keyPair = await generateSigningKeyPair(); }); - // The provider owns its per-client nonce cache, so each test is isolated. + // Providers default to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { const authProvider = await clientSecretAuthProvider({ diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index c695dc654..ddcfc436e 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -2,7 +2,7 @@ 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 { defaultNonceCache, 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'; @@ -27,7 +27,10 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { keyPair = await generateSigningKeyPair(); }); - // Each AccessToken owns its own per-client nonce cache — tests are isolated. + // AccessToken defaults to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); function makeAccessToken(fetchStub: typeof fetch) { return new AccessToken( diff --git a/lib/tests/web/auth/dpop-rpc-nonce.test.ts b/lib/tests/web/auth/dpop-rpc-nonce.test.ts index 378812b4f..69f6b8883 100644 --- a/lib/tests/web/auth/dpop-rpc-nonce.test.ts +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -1,5 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { clientSecretAuthProvider } from '../../../src/auth/providers.js'; +import { defaultNonceCache } from '../../../src/auth/dpop-nonce.js'; import { PlatformClient } from '../../../src/platform.js'; import { generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; @@ -21,7 +22,10 @@ describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { keyPair = await generateSigningKeyPair(); }); - // The provider owns its per-client nonce cache, so each test is isolated. + // Providers default to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.clearAll(); + }); it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { const authProvider = await clientSecretAuthProvider({ From 9cd2d48f61b362758ed9fac9db46e9e299802eee Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 9 Jul 2026 11:01:36 -0400 Subject: [PATCH 46/68] =?UTF-8?q?refactor(dpop):=20review=20cleanups=20?= =?UTF-8?q?=E2=80=94=20consolidate=20nonce=20helpers,=20drop=20deprecated?= =?UTF-8?q?=20alias=20(DSPX-3397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dpop-nonce.ts: extract shared adoptIfFresh() + toOrigin(); route captureNonce and the two try/catch origin sites (interceptors, access-fetch) through toOrigin - remove the unused @deprecated globalNonceCache alias (not exported, no consumers) - dpop.ts: narrow DPoPJwtHeaderParameters.typ to the literal 'dpop+jwt' - oidc.ts: fix the truncated/contradictory signingKey doc comment - interceptors.ts: add X-VirtruPubKey->X-OpenTDF-PubKey rename TODO (mirrors oidc.ts) - tests: dpop-nonce.spec assert real outgoing headers (not just a config flag); dpop-headers.spec drop console noise + assert proof typ='dpop+jwt' --- lib/src/access/access-fetch.ts | 9 ++-- lib/src/auth/dpop-nonce.ts | 59 +++++++++++++++--------- lib/src/auth/dpop.ts | 2 +- lib/src/auth/interceptors.ts | 10 ++-- lib/src/auth/oidc.ts | 7 ++- lib/tests/mocha/dpop-nonce.spec.ts | 23 +++++++-- web-app/tests/tests/dpop-headers.spec.ts | 15 +++--- 7 files changed, 76 insertions(+), 49 deletions(-) diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index 6c6b8c01e..bccb404ec 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -3,6 +3,7 @@ import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; import { adoptChallengeNonce, defaultNonceCache, + toOrigin, warmNonceFromResponse, } from '../auth/dpop-nonce.js'; import { @@ -52,12 +53,8 @@ async function fetchWithCredsAndNonceRetry( // 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. - } + // Non-absolute URLs have no origin; nonce caching is origin-keyed, so those pass through. + const origin = toOrigin(httpReq.url); let response = await send(); diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 268e5e95c..865dcb9d1 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -49,6 +49,34 @@ export class DPoPNonceCache { */ type NonceHeaders = Headers | undefined; +/** The origin of an absolute URL, or `undefined` when it is relative/unparseable. */ +export function toOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +/** + * Adopt `challenge` as this origin's nonce when it is present and differs from + * the one we just sent (`sentNonce`), recording it in `cache`. Returns the fresh + * nonce, or `undefined` when the caller should NOT retry (no nonce, or it matches + * what we already used). RFC 9449 §9. + */ +function adoptIfFresh( + cache: DPoPNonceCache, + origin: string, + challenge: string | undefined, + sentNonce: string | undefined +): string | undefined { + if (challenge && challenge !== sentNonce) { + cache.set(origin, challenge); + return challenge; + } + return 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 @@ -61,12 +89,7 @@ export function adoptChallengeNonce( headers: NonceHeaders, sentNonce: string | undefined ): string | undefined { - const challenge = DPoPNonceCache.extractNonce(headers); - if (challenge && challenge !== sentNonce) { - cache.set(origin, challenge); - return challenge; - } - return undefined; + return adoptIfFresh(cache, origin, DPoPNonceCache.extractNonce(headers), sentNonce); } /** @@ -81,12 +104,12 @@ export function adoptChallengeNonceFromConnectError( 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; + return adoptIfFresh( + cache, + origin, + cache.get(origin) ?? DPoPNonceCache.extractNonce(metadata), + sentNonce + ); } /** @@ -98,10 +121,7 @@ export function warmNonceFromResponse( origin: string, headers: NonceHeaders ): void { - const nonce = DPoPNonceCache.extractNonce(headers); - if (nonce) { - cache.set(origin, nonce); - } + adoptIfFresh(cache, origin, DPoPNonceCache.extractNonce(headers), undefined); } /** @@ -115,13 +135,6 @@ export function warmNonceFromResponse( */ export const defaultNonceCache = new DPoPNonceCache(); -/** - * @deprecated Prefer {@link defaultNonceCache} (or an injected per-client - * {@link DPoPNonceCache}). 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. * diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index beee4536f..cea222156 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -24,7 +24,7 @@ function buf(input: string): Uint8Array { type DPoPJwtHeaderParameters = { alg: JWSAlgorithm; - typ: string; + typ: 'dpop+jwt'; jwk: JsonWebKey; }; diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 8791b3aac..ac502ec7b 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -9,6 +9,7 @@ import { adoptChallengeNonceFromConnectError, DPoPNonceCache, defaultNonceCache, + toOrigin, warmNonceFromResponse, } from './dpop-nonce.js'; @@ -113,6 +114,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI req.header.set('Authorization', `DPoP ${token}`); req.header.set('DPoP', dpopProof); + // TODO: rename to X-OpenTDF-PubKey (coordinate with platform Keycloak mapper; see oidc.ts doPost) req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem)); // Call next and handle DPoP-Nonce retry @@ -219,12 +221,8 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // 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. - } + // Non-absolute URLs have no origin; nonce caching is origin-keyed, so those pass through. + const origin = toOrigin(req.url); await sign(); // Snapshot the nonce we just signed with (withCreds reads it from the cache) diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 9e8c60aa0..9f77dbebf 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -25,7 +25,12 @@ export type CommonCredentials = { /** Whether or not DPoP is enabled. */ dpopEnabled?: boolean; - /** the client's public key, base64 encoded. Will be bound to the OIDC token. Deprecated. If not set in the constructor, */ + /** + * The client's DPoP/signing key pair, bound to the issued OIDC token (as + * `cnf.jkt`) when DPoP is enabled. May be supplied here or bound later via + * `updateClientPublicKey`, which forces a token refresh so the new key takes + * effect. + */ signingKey?: KeyPair; }; diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index b2a0d0b8b..833380c3e 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -109,13 +109,30 @@ describe('DPoP nonce challenge — integration with mock server', function (this 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', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, exchange: 'client', // No dpopEnabled / signingKey — non-DPoP flow. }); await provider.updateClientPublicKey(keyPair); - // The exposed AccessToken config must remain non-DPoP after the bind. + + // Capture the actual outgoing token POST rather than trusting a config flag: + // a stubbed request lets us assert the real header shape without a server. + let sentHeaders: Record | undefined; + provider.oidcAuth.request = async (_input, init) => { + sentHeaders = init?.headers as Record; + return new Response(JSON.stringify({ access_token: 'non-dpop-token' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('non-dpop-token'); + // The real regression guard: no DPoP proof (nor pubkey header) on the token POST. + expect(sentHeaders).to.not.have.property('DPoP'); + expect(sentHeaders).to.not.have.property('X-VirtruPubKey'); + // And the exposed AccessToken config must remain non-DPoP after the bind. expect(provider.oidcAuth.config.dpopEnabled).to.not.equal(true); }); }); diff --git a/web-app/tests/tests/dpop-headers.spec.ts b/web-app/tests/tests/dpop-headers.spec.ts index 9af7bf5a4..f41c7e84e 100644 --- a/web-app/tests/tests/dpop-headers.spec.ts +++ b/web-app/tests/tests/dpop-headers.spec.ts @@ -28,8 +28,6 @@ test('DPoP headers on token and KAS rewrap requests', async ({ page }) => { } }); - page.on('console', (m) => console.log(m.text())); - await authorize(page); await loadFile(page, 'README.md'); const downloadPromise = page.waitForEvent('download'); @@ -46,13 +44,6 @@ test('DPoP headers on token and KAS rewrap requests', async ({ page }) => { 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); @@ -62,4 +53,10 @@ test('DPoP headers on token and KAS rewrap requests', async ({ page }) => { expect(r.dpop, `${r.url} should carry a DPoP header`).toBeTruthy(); } } + + // Decode one proof header to confirm it is a well-formed DPoP proof (RFC 9449 §4.2). + const proof = captured.find((r) => r.url.includes('/kas') && r.dpop)?.dpop; + expect(proof, 'a KAS request should carry a DPoP proof').toBeTruthy(); + const header = JSON.parse(Buffer.from(proof!.split('.')[0], 'base64url').toString('utf8')); + expect(header.typ).toBe('dpop+jwt'); }); From 2c912843c8880d39c11e80858d907e4b95d101bd Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 9 Jul 2026 11:10:00 -0400 Subject: [PATCH 47/68] fix(access,dpop): surface masked RPC errors + add nonce-retry diagnostics (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit access.ts: replace tryPromisesUntilFirstSuccess with tryRpcThenLegacy, reused by fetchWrappedKey, fetchKeyAccessServers, and fetchKasPubKey. The two sibling fetches now short-circuit on a definitive RPC auth error and surface the RPC error (not the legacy 404) on double failure — the fix previously applied only to fetchWrappedKey, so a DPoP auth failure on a Connect-only platform no longer masquerades as a 404 for an unimplemented legacy endpoint. dpop-nonce.ts: add warnNonceRetryGiveUp() and emit one concise diagnostic at every nonce-retry give-up site (both interceptors, legacy fetch, oidc doPost/info), gated on a genuinely present DPoP-Nonce so ordinary 401s stay quiet. captureNonce now warns when a nonce arrives on a non-absolute URL (which silently disables the Connect-RPC retry) instead of dropping it. tests: add an interceptor 'gives up and rethrows the original error' test; tighten the access-fetch no-retry tests to assert the surfaced ServiceError identity. --- lib/src/access.ts | 82 +++++++++++------------ lib/src/access/access-fetch.ts | 7 ++ lib/src/auth/dpop-nonce.ts | 35 ++++++++-- lib/src/auth/interceptors.ts | 15 +++++ lib/src/auth/oidc.ts | 7 ++ lib/tests/web/access/access-fetch.test.ts | 13 +++- lib/tests/web/interceptors.test.ts | 44 ++++++++++++ 7 files changed, 154 insertions(+), 49 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 839c0818a..c97ae6d1e 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -66,37 +66,22 @@ export async function fetchWrappedKey( return await 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. - console.info('v2 rewrap request error', rpcError); - try { - return (await fetchWrappedKeysLegacy( + // Try the modern Connect-RPC rewrap first, falling back to the legacy REST + // rewrap only for non-auth failures (older, non-Connect platforms). A + // definitive KAS auth/validation answer (401/403/400 — incl. a post-nonce- + // challenge 401, RFC 9449 §9) surfaces as-is via tryRpcThenLegacy rather than + // being masked by the legacy 404 on Connect-only platforms. + // We intentionally omit the rewrap additional context from legacy requests: + // platforms new enough to know about obligations handle RPC successfully. + return await tryRpcThenLegacy( + rpcCall, + async () => + (await fetchWrappedKeysLegacy( url, { signedRequestToken }, authProvider - )) 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; - } - } + )) as unknown as RewrapResponse + ); } /** @@ -230,7 +215,7 @@ export async function fetchKeyAccessServers( return await rpcCall(); } - return await tryPromisesUntilFirstSuccess(rpcCall, () => + return await tryRpcThenLegacy(rpcCall, () => fetchKeyAccessServersLegacy(platformUrl, authProvider) ); } @@ -264,7 +249,7 @@ export async function fetchKasPubKey( console.log(e); } - return await tryPromisesUntilFirstSuccess( + return await tryRpcThenLegacy( () => fetchKasPubKeyRpc(kasEndpoint, algorithm), () => fetchKasPubKeyLegacy(kasEndpoint, algorithm) ); @@ -305,23 +290,34 @@ export class OriginAllowList { } /** - * Tries two promise-returning functions in order and returns the first successful result. - * If both fail, throws the error from the second. - * @param first First function returning a promise to try. - * @param second Second function returning a promise to try if the first fails. + * Try the modern Connect-RPC call first, falling back to the legacy REST call + * only for non-auth failures. A definitive auth/validation answer from the RPC + * layer short-circuits: the legacy endpoint 404s on Connect-only platforms and + * would otherwise mask the real error. On a double failure, surface the (more + * meaningful) RPC error while still logging the legacy one so the fallback path + * stays debuggable. + * @param rpcCall The modern Connect-RPC call to try first. + * @param legacyCall The legacy REST call to fall back to for non-auth failures. + * @param isAuthError Predicate identifying a definitive auth/validation error + * that must surface as-is rather than trigger the legacy fallback. */ -async function tryPromisesUntilFirstSuccess( - first: () => Promise, - second: () => Promise +async function tryRpcThenLegacy( + rpcCall: () => Promise, + legacyCall: () => Promise, + isAuthError: (e: unknown) => boolean = isRewrapAuthError ): Promise { try { - return await first(); - } catch (e1) { - console.info('v2 request error', e1); + return await rpcCall(); + } catch (rpcError) { + if (isAuthError(rpcError)) { + throw rpcError; + } + console.info('v2 request error', rpcError); try { - return await second(); - } catch (err) { - throw err; + return await legacyCall(); + } catch (legacyError) { + console.info('legacy fallback also failed', legacyError); + throw rpcError; } } } diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index bccb404ec..128c004aa 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -3,8 +3,10 @@ import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; import { adoptChallengeNonce, defaultNonceCache, + DPoPNonceCache, toOrigin, warmNonceFromResponse, + warnNonceRetryGiveUp, } from '../auth/dpop-nonce.js'; import { ConfigurationError, @@ -60,8 +62,13 @@ async function fetchWithCredsAndNonceRetry( if (!response.ok && origin) { const sentNonce = nonceCache.get(origin); + const challenge = DPoPNonceCache.extractNonce(response.headers); if (adoptChallengeNonce(nonceCache, origin, response.headers, sentNonce)) { response = await send(); + } else if (challenge) { + // A DPoP-Nonce was offered but is stale/unusable, so the retry is skipped; + // note it (only when a nonce was actually present — never on a plain 401). + warnNonceRetryGiveUp('legacy fetch', origin, challenge, sentNonce); } } diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 865dcb9d1..ba85a3c4d 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -124,6 +124,27 @@ export function warmNonceFromResponse( adoptIfFresh(cache, origin, DPoPNonceCache.extractNonce(headers), undefined); } +/** + * Emit ONE concise warning when a genuine DPoP-Nonce challenge was detected but + * no fresh nonce could be adopted (the server omitted it, or repeated the one we + * already sent), so the retry is skipped and the original error propagates. Call + * only after confirming a challenge was present — otherwise an ordinary 401 would + * spam a misleading warning. RFC 9449 §9. + */ +export function warnNonceRetryGiveUp( + context: string, + origin: string, + challenge: string | undefined, + sentNonce: string | undefined +): void { + const reason = !challenge + ? 'server omitted the DPoP-Nonce' + : challenge === sentNonce + ? 'server repeated the already-used DPoP-Nonce' + : 'DPoP-Nonce could not be adopted'; + console.warn(`DPoP nonce retry skipped (${context}, ${origin}): ${reason}`); +} + /** * Shared, process-wide nonce cache — the default for every DPoP path (the * `AccessToken` cache, the auth interceptor, the Connect transport, and the @@ -150,9 +171,15 @@ export function captureNonce(cache: DPoPNonceCache, requestUrl: string, 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. + const origin = toOrigin(requestUrl); + if (origin) { + cache.set(origin, nonce); + } else { + // The cache is origin-keyed, so a relative request URL can't be stored — and + // since this is the only place a Connect-RPC nonce challenge is captured, that + // silently disables the retry. Surface it rather than dropping it quietly. + console.warn( + `DPoP-Nonce present but request URL is not absolute (${requestUrl}); cannot cache nonce, retry disabled.` + ); } } diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index ac502ec7b..24648aafd 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -11,6 +11,7 @@ import { defaultNonceCache, toOrigin, warmNonceFromResponse, + warnNonceRetryGiveUp, } from './dpop-nonce.js'; /** @@ -150,6 +151,14 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI warmNonceFromResponse(nonceCache, origin, retryResponse.header); return retryResponse; } + // A nonce challenge we can't act on (server omitted/repeated the nonce): + // surface why the retry was skipped before the original error propagates. + warnNonceRetryGiveUp( + 'rpc interceptor', + origin, + nonceCache.get(origin) ?? DPoPNonceCache.extractNonce(err.metadata), + cachedNonce + ); } // Re-throw if not a nonce challenge or retry failed @@ -257,6 +266,12 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor warmNonceFromResponse(nonceCache, origin, retryResponse.header); return retryResponse; } + warnNonceRetryGiveUp( + 'auth interceptor', + origin, + nonceCache.get(origin) ?? DPoPNonceCache.extractNonce(err.metadata), + sentNonce + ); } throw err; } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 9f77dbebf..7275c0082 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -10,6 +10,7 @@ import { defaultNonceCache, DPoPNonceCache, warmNonceFromResponse, + warnNonceRetryGiveUp, } from './dpop-nonce.js'; /** @@ -196,6 +197,7 @@ export class AccessToken { // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. if (this.config.dpopEnabled && this.signingKey && !response.ok) { + const challenge = DPoPNonceCache.extractNonce(response.headers); const challengeNonce = adoptChallengeNonce( this.nonceCache, origin, @@ -214,6 +216,8 @@ export class AccessToken { response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); + } else if (challenge) { + warnNonceRetryGiveUp('userinfo', origin, challenge, cachedNonce); } } @@ -264,6 +268,7 @@ export class AccessToken { // 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 challenge = DPoPNonceCache.extractNonce(response.headers); const challengeNonce = adoptChallengeNonce( this.nonceCache, origin, @@ -288,6 +293,8 @@ export class AccessToken { warmNonceFromResponse(this.nonceCache, origin, retryResponse.headers); return retryResponse; + } else if (challenge) { + warnNonceRetryGiveUp('token endpoint', origin, challenge, cachedNonce); } } diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index ab3bb23cd..00bd25773 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -298,12 +298,18 @@ describe('access-fetch.js', () => { it('does not retry when the 401 carries no DPoP-Nonce', async () => { fetchStub.returns(responseWithNonce('nope', false, 401)); + let caught: unknown; try { await fetchKeyAccessServers(platformUrl, dpopAuthProvider); expect.fail('Should have thrown'); } catch (e) { - expect(e).to.be.instanceOf(ServiceError); + caught = e; } + // The real 401 must surface unchanged (not masked): a ServiceError that + // names the KAS-list request and its status. + expect(caught).to.be.instanceOf(ServiceError); + expect((caught as ServiceError).message).to.include('unable to fetch kas list'); + expect((caught as ServiceError).message).to.include('status: 401'); expect(fetchStub.calledOnce).to.be.true; }); @@ -311,12 +317,15 @@ describe('access-fetch.js', () => { // Server keeps rejecting with the same nonce: retry once, then give up. fetchStub.returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + let caught: unknown; try { await fetchKeyAccessServers(platformUrl, dpopAuthProvider); expect.fail('Should have thrown'); } catch (e) { - expect(e).to.be.instanceOf(ServiceError); + caught = e; } + expect(caught).to.be.instanceOf(ServiceError); + expect((caught as ServiceError).message).to.include('status: 401'); expect(fetchStub.calledTwice).to.be.true; }); }); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 91db683d1..f8fcb8a08 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -201,6 +201,50 @@ describe('authProviderInterceptor', () => { expect(nonceCache.get(origin)).to.equal('server-nonce-xyz'); }); + it('gives up and rethrows the original error when the challenge carries no new nonce', async () => { + const origin = 'https://platform.example.com'; + const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; + const nonceCache = new DPoPNonceCache(); + + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + nonceCache, + withCreds: async (req: HttpRequest) => withHeaders(req, { Authorization: 'DPoP token' }), + }; + + // Unauthenticated, but the server supplied no DPoP-Nonce and the cache is + // empty, so there is nothing to retry with: the original error must + // propagate unchanged rather than be swallowed or retried in a loop. + const thrown = new ConnectError('unauthenticated', Code.Unauthenticated); + let attempts = 0; + const mockNext = async () => { + attempts++; + throw thrown; + }; + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + + let caught: unknown; + try { + const interceptor = authProviderInterceptor(mockAuthProvider); + const mockReq = { header: new Headers(), url } as Parameters>[0]; + await interceptor(mockNext)(mockReq); + } catch (e) { + caught = e; + } finally { + console.warn = originalWarn; + } + + expect(caught).to.equal(thrown); // same error instance, not masked + expect(attempts).to.equal(1); // no retry, no loop + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include('nonce retry skipped'); + }); + it('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {}, From 4a6edced362c328fd940af00233b7b3fe4d4c764 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 9 Jul 2026 12:05:05 -0400 Subject: [PATCH 48/68] fix(cli,access): error on unsupported DPoP alg; trim auth-path error logging (DSPX-3397) cli/dpop-helpers.ts: reject RS384/RS512 (removed from VALID_DPOP_ALGS; they signed as RS256 anyway) instead of warning + silently downgrading. Error when an explicit --dpop= conflicts with a --dpopKey file's algorithm (EC curve exact, RSA family match); a bare --dpop still infers from the key. Threads algWasExplicit from resolveDPoPFromArgs. access.ts: add errBrief() and log a one-line summary (message + Connect code) instead of the whole error object in the RPC->legacy fallback path, so response headers/metadata (incl. DPoP nonces) aren't dumped to logs. docs: update the DPoP CLI design spec + plan to match (RS384/RS512 rejected; explicit alg/key conflict is an error rather than 'key type wins'). tests: cover RS384/RS512 rejection and the explicit alg/key conflict + match cases. --- cli/src/dpop-helpers.ts | 66 +++++++++++++++---- cli/tests/dpop-helpers.spec.ts | 51 +++++++++++++- .../plans/2026-06-09-dpop-cli-flags.md | 4 +- .../specs/2026-06-09-dpop-cli-flags-design.md | 6 +- lib/src/access.ts | 20 +++++- 5 files changed, 127 insertions(+), 20 deletions(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index b155df444..7e85300dd 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -4,7 +4,7 @@ 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; +const VALID_DPOP_ALGS = ['ES256', 'ES384', 'ES512', 'RS256'] as const; export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; const EC_CURVE_MAP: Record = { @@ -35,7 +35,9 @@ export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { /** * 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). + * RS256 → RSA-2048 via the SDK's generateSigningKeyPair(). + * RS384/RS512 are not supported (the SDK signs all RSA DPoP proofs as RS256) and + * are rejected rather than silently downgraded. */ export async function generateEphemeralDPoPKeyPair(alg: string): Promise { if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { @@ -45,12 +47,6 @@ export async function generateEphemeralDPoPKeyPair(alg: string): Promise = { + ES256: 'ec:secp256r1', + ES384: 'ec:secp384r1', + ES512: 'ec:secp521r1', + RS256: 'rsa', +}; + +/** + * Throw if a loaded key's algorithm doesn't satisfy an explicitly-requested + * `--dpop` algorithm, so the CLI never silently signs with a different (possibly + * weaker) algorithm than the user asked for. + */ +function assertKeyMatchesRequestedAlg(keyPair: KeyPair, alg: string, keyPath: string): void { + if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { + throw new CLIError( + 'CRITICAL', + `Unsupported DPoP algorithm: ${alg}. Valid values: ${VALID_DPOP_ALGS.join(', ')}` + ); + } + const expected = DPOP_ALG_TO_KEY_ALG[alg as DPoPAlg]; + const actual = keyPair.publicKey.algorithm; + const matches = expected === 'rsa' ? actual.startsWith('rsa') : actual === expected; + if (!matches) { + throw new CLIError( + 'CRITICAL', + `--dpop=${alg} conflicts with the key in --dpopKey (${keyPath}): the key's algorithm is ` + + `${actual}. Remove --dpop to infer the algorithm from the key, or supply a key matching ${alg}.` + ); + } +} + /** * Main entry point: resolve a DPoP KeyPair from CLI arguments. - * Returns undefined if DPoP is not requested. + * Returns undefined if DPoP is not requested. When a key file is supplied, its + * algorithm is inferred from the key; an explicitly-requested `--dpop` algorithm + * that disagrees with the key is a hard error (see {@link assertKeyMatchesRequestedAlg}). */ export async function resolveDPoPKeyPair( alg: string | undefined, - keyPath: string | undefined + keyPath: string | undefined, + algWasExplicit = false ): Promise { if (keyPath) { - return loadDPoPKeyPairFromPem(keyPath); + const keyPair = await loadDPoPKeyPairFromPem(keyPath); + if (alg && algWasExplicit) { + assertKeyMatchesRequestedAlg(keyPair, alg, keyPath); + } + return keyPair; } if (alg) { return generateEphemeralDPoPKeyPair(alg); @@ -198,7 +237,10 @@ export async function resolveDPoPFromArgs(argv: { dpopKey?: string; }): Promise<{ dpopEnabled: boolean; dpopKeyPair: KeyPair | undefined }> { const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; + // A non-empty --dpop value is an explicit algorithm choice; a bare --dpop + // (empty string → ES256 default) is not, so it never conflicts with --dpopKey. + const algWasExplicit = !!argv.dpop; const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; - const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey, algWasExplicit); return { dpopEnabled, dpopKeyPair }; } diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts index 444a318af..b557e098e 100644 --- a/cli/tests/dpop-helpers.spec.ts +++ b/cli/tests/dpop-helpers.spec.ts @@ -59,6 +59,17 @@ describe('generateEphemeralDPoPKeyPair', function () { expect((err as Error).message).to.include('Unsupported DPoP algorithm'); } }); + + for (const alg of ['RS384', 'RS512']) { + it(`rejects ${alg} (unsupported; not silently downgraded to RS256)`, async function () { + try { + await generateEphemeralDPoPKeyPair(alg); + 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 }; @@ -196,13 +207,32 @@ describe('resolveDPoPKeyPair', function () { expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); }); - it('prefers keyPath over alg when both are provided', async function () { + it('infers the key algorithm, ignoring a non-explicit (default) alg', async function () { + // algWasExplicit defaults to false: a bare --dpop must not conflict with a key. 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'); }); + + it('errors when an explicit --dpop alg conflicts with the key file', async function () { + const path = join(tmpDir, 'p384-conflict.pem'); + await writeFile(path, await ecPrivatePem('P-384')); + try { + await resolveDPoPKeyPair('ES256', path, true); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('conflicts with the key'); + } + }); + + it('accepts an explicit --dpop alg that matches the key file', async function () { + const path = join(tmpDir, 'p384-match.pem'); + await writeFile(path, await ecPrivatePem('P-384')); + const result = await resolveDPoPKeyPair('ES384', path, true); + expect(result!.publicKey.algorithm).to.equal('ec:secp384r1'); + }); }); describe('resolveDPoPFromArgs', function () { @@ -251,4 +281,23 @@ describe('resolveDPoPFromArgs', function () { expect((err as Error).message).to.include('Unsupported DPoP algorithm'); } }); + + it('errors when an explicit --dpop conflicts with --dpopKey', async function () { + const path = join(tmpDir, 'args-conflict.pem'); + await writeFile(path, await ecPrivatePem('P-384')); + try { + await resolveDPoPFromArgs({ dpop: 'ES256', dpopKey: path }); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('conflicts with the key'); + } + }); + + it('bare --dpop with --dpopKey infers the key algorithm (no conflict)', async function () { + const path = join(tmpDir, 'args-bare.pem'); + await writeFile(path, await ecPrivatePem('P-384')); + const result = await resolveDPoPFromArgs({ dpop: '', dpopKey: path }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp384r1'); + }); }); diff --git a/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md index ec38e2b13..90f1ed8ff 100644 --- a/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md +++ b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md @@ -122,7 +122,7 @@ 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; +const VALID_DPOP_ALGS = ['ES256', 'ES384', 'ES512', 'RS256'] as const; export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; const EC_CURVE_MAP: Record = { @@ -142,7 +142,7 @@ export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { /** * 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). + * RS256 → RSA-2048 via SDK's generateSigningKeyPair(). RS384/RS512 are rejected (all RSA proofs sign as RS256). */ export async function generateEphemeralDPoPKeyPair(alg: string): Promise { if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { 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 index 95a694fb7..f9810a343 100644 --- a/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md +++ b/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md @@ -19,7 +19,7 @@ The branch already ships `lib/src/auth/dpop-nonce.ts` (nonce cache) and `lib/src | `--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. +Supported algorithm values: `ES256`, `ES384`, `ES512`, `RS256`. RS384/RS512 are **rejected** with a `CLIError` (not silently downgraded): the SDK's `determineJWSAlgorithmFromKeyInfo` signs all RSA DPoP proofs as RS256, so requesting RS384/RS512 could never be honored. Help text for both flags contains the word "dpop" so `grep -i dpop` matches. @@ -42,7 +42,7 @@ 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) +- **RSA (RS256):** `WebCryptoService.generateSigningKeyPair()` (existing, returns RSA-2048) ### PEM key from file (`--dpop-key`) @@ -96,7 +96,7 @@ console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); - 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) +- `--dpop-key` alone infers the algorithm from the key. An **explicit** `--dpop=` combined with a `--dpop-key` whose algorithm disagrees is a `CLIError` (EC curves matched exactly; RSA matched by family). A bare `--dpop` (default `ES256`) never conflicts with a key. --- diff --git a/lib/src/access.ts b/lib/src/access.ts index c97ae6d1e..59fbfc6e9 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -1,3 +1,4 @@ +import { Code, ConnectError } from '@connectrpc/connect'; import { type AuthConfig, resolveAuthConfig } from './auth/interceptors.js'; import { RewrapResponse } from './platform/kas/kas_pb.js'; import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js'; @@ -312,12 +313,27 @@ async function tryRpcThenLegacy( if (isAuthError(rpcError)) { throw rpcError; } - console.info('v2 request error', rpcError); + console.info('v2 request error:', errBrief(rpcError)); try { return await legacyCall(); } catch (legacyError) { - console.info('legacy fallback also failed', legacyError); + console.info('legacy fallback also failed:', errBrief(legacyError)); throw rpcError; } } } + +/** + * A log-safe one-line summary of an error: its message (and Connect code), never + * the whole error object — Connect errors can carry response headers/metadata + * (including DPoP nonces) that should not be dumped to logs on the auth path. + */ +function errBrief(e: unknown): string { + if (e instanceof ConnectError) { + return `${Code[e.code]}: ${e.message}`; + } + if (e instanceof Error) { + return e.message; + } + return String(e); +} From 20ad3f1df3f81afab27329adf77de18f8145661a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 9 Jul 2026 13:49:20 -0400 Subject: [PATCH 49/68] harden(crypto): validate DER structure in derToIeeeP1363 (DSPX-3397) Bounds-check every index and slice while parsing the ECDSA DER signature so malformed input always throws a controlled ConfigurationError instead of an out-of-bounds RangeError/TypeError or a silently-truncated component: - reject signatures shorter than the 8-byte minimal SEQUENCE up front - parse each INTEGER through a bounds-checked reader (rejects truncated or over-long length fields rather than slicing a short/empty component) - strip all leading zero pad bytes and reject any component larger than the curve's fixed width (previously produced a negative result.set offset) Valid signatures are byte-for-byte unchanged (ES256/384/512 sign->verify round-trips still pass). Adds direct malformed-DER unit tests. derToIeeeP1363 is on both the DPoP proof and TDF3 JWT signing/verification paths (RFC 7518 3.4). --- lib/tdf3/src/crypto/core/signing.ts | 71 +++++++----- .../mocha/unit/crypto/der-signature.spec.ts | 106 ++++++++++++++++++ 2 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 lib/tests/mocha/unit/crypto/der-signature.spec.ts diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index 9516472cd..dff504b12 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -128,6 +128,14 @@ export function derToIeeeP1363( throw new ConfigurationError(`Unsupported algorithm for DER conversion: ${algorithm}`); } + // Smallest well-formed ECDSA DER SEQUENCE is 8 bytes: + // 0x30 seqLen 0x02 rLen r(>=1) 0x02 sLen s(>=1) + // Anything shorter cannot be parsed; reject before indexing so a malformed + // input throws a clean ConfigurationError rather than coercing undefined. + if (signature.length < 8) { + throw new ConfigurationError('Invalid DER signature: too short'); + } + if (signature[0] !== 0x30) { throw new ConfigurationError('Invalid DER signature: expected SEQUENCE'); } @@ -141,40 +149,51 @@ export function derToIeeeP1363( throw new ConfigurationError('Invalid DER signature: invalid long-form length'); } offset += 1 + lenBytesCount; - if (offset > signature.length) { - throw new ConfigurationError('Invalid DER signature: length bytes exceed signature length'); - } } else { // Short-form: single length byte. offset += 1; } - // Parse r INTEGER - if (signature[offset] !== 0x02) { - throw new ConfigurationError('Invalid DER signature: expected INTEGER for r'); - } - const rLen = signature[offset + 1]; - offset += 2; - let r = signature.slice(offset, offset + rLen); - offset += rLen; - - // Parse s INTEGER - if (signature[offset] !== 0x02) { - throw new ConfigurationError('Invalid DER signature: expected INTEGER for s'); - } - const sLen = signature[offset + 1]; - offset += 2; - let s = signature.slice(offset, offset + sLen); + // Parse a DER INTEGER at `offset`, advancing past it. Every read is + // bounds-checked so a truncated or over-long length field throws a clean + // ConfigurationError instead of silently slicing a short/empty component. + const readInteger = (label: 'r' | 's'): Uint8Array => { + if (offset + 1 >= signature.length) { + throw new ConfigurationError(`Invalid DER signature: truncated before ${label} INTEGER`); + } + if (signature[offset] !== 0x02) { + throw new ConfigurationError(`Invalid DER signature: expected INTEGER for ${label}`); + } + const len = signature[offset + 1]; + const start = offset + 2; + const end = start + len; + if (len === 0 || end > signature.length) { + throw new ConfigurationError(`Invalid DER signature: ${label} INTEGER length out of range`); + } + offset = end; + return signature.slice(start, end); + }; - // Remove leading zero padding if present - if (r[0] === 0 && r.length > componentLen) { - r = r.slice(1); - } - if (s[0] === 0 && s.length > componentLen) { - s = s.slice(1); + let r = readInteger('r'); + let s = readInteger('s'); + + // Strip DER's leading zero padding (INTEGERs are zero-prefixed to stay positive). + const stripLeadingZeros = (arr: Uint8Array): Uint8Array => { + let i = 0; + while (i < arr.length - 1 && arr[i] === 0) i++; + return arr.slice(i); + }; + r = stripLeadingZeros(r); + s = stripLeadingZeros(s); + + // After stripping, each component must fit its fixed-width slot; a larger value + // means the signature does not belong to this curve (and would otherwise produce + // a negative offset in result.set below). + if (r.length > componentLen || s.length > componentLen) { + throw new ConfigurationError('Invalid DER signature: component larger than expected for curve'); } - // Pad to component length + // Pad to component length (right-aligned): result = r_padded || s_padded. const result = new Uint8Array(componentLen * 2); result.set(r, componentLen - r.length); result.set(s, componentLen * 2 - s.length); diff --git a/lib/tests/mocha/unit/crypto/der-signature.spec.ts b/lib/tests/mocha/unit/crypto/der-signature.spec.ts new file mode 100644 index 000000000..59ec99de9 --- /dev/null +++ b/lib/tests/mocha/unit/crypto/der-signature.spec.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; + +import { derToIeeeP1363 } from '../../../../tdf3/src/crypto/core/signing.js'; +import { ConfigurationError } from '../../../../src/errors.js'; + +/** + * Direct unit tests for derToIeeeP1363's DER parsing. The happy path (real + * signatures round-tripping through sign→verify) is covered in + * crypto-service.spec.ts; these focus on malformed input, which must always + * throw a controlled ConfigurationError rather than an out-of-bounds + * RangeError/TypeError or a silently-truncated component. + */ +describe('derToIeeeP1363 DER validation', () => { + it('RS256 passes through unchanged (no DER parsing)', () => { + const sig = new Uint8Array([1, 2, 3]); + expect(derToIeeeP1363(sig, 'RS256')).to.equal(sig); + }); + + describe('well-formed DER (positive controls)', () => { + it('parses a minimal r=0x01, s=0x02 into a right-aligned 64-byte ES256 output', () => { + // 0x30 seqLen 0x02 rLen r 0x02 sLen s + const der = new Uint8Array([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + const out = derToIeeeP1363(der, 'ES256'); + expect(out).to.have.length(64); + expect(out[31]).to.equal(0x01); // r right-aligned in first 32 bytes + expect(out[63]).to.equal(0x02); // s right-aligned in second 32 bytes + // everything else zero-padded + expect(out.slice(0, 31).every((b) => b === 0)).to.be.true; + expect(out.slice(32, 63).every((b) => b === 0)).to.be.true; + }); + + it('strips a DER leading-zero pad byte (high-bit component)', () => { + // r = 0x00 0x80 (zero-prefixed to stay positive) → 0x80 after stripping + const der = new Uint8Array([0x30, 0x07, 0x02, 0x02, 0x00, 0x80, 0x02, 0x01, 0x01]); + const out = derToIeeeP1363(der, 'ES256'); + expect(out).to.have.length(64); + expect(out[31]).to.equal(0x80); + expect(out[63]).to.equal(0x01); + }); + }); + + describe('malformed DER throws ConfigurationError', () => { + const cases: Array<{ name: string; bytes: number[]; match: RegExp }> = [ + { name: 'empty', bytes: [], match: /too short/ }, + { name: 'single 0x30 byte', bytes: [0x30], match: /too short/ }, + { + name: 'wrong SEQUENCE tag', + bytes: [0x31, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x01], + match: /expected SEQUENCE/, + }, + { + name: 'missing INTEGER tag for r', + bytes: [0x30, 0x06, 0x03, 0x01, 0x01, 0x02, 0x01, 0x01], + match: /expected INTEGER for r/, + }, + { + name: 'r INTEGER length overruns the buffer', + bytes: [0x30, 0x06, 0x02, 0x40, 0x01, 0x02, 0x01, 0x01], + match: /r INTEGER length out of range/, + }, + { + name: 's INTEGER length overruns the buffer', + bytes: [0x30, 0x08, 0x02, 0x01, 0x01, 0x02, 0x40, 0x01], + match: /s INTEGER length out of range/, + }, + { + name: 'truncated before s INTEGER', + bytes: [0x30, 0x82, 0x00, 0x08, 0x02, 0x01, 0x01, 0x02], + match: /truncated before s INTEGER/, + }, + { + name: 'invalid long-form length (too many length bytes)', + bytes: [0x30, 0x85, 0, 0, 0, 0, 0, 0], + match: /invalid long-form length/, + }, + ]; + + for (const { name, bytes, match } of cases) { + it(name, () => { + expect(() => derToIeeeP1363(new Uint8Array(bytes), 'ES256')).to.throw( + ConfigurationError, + match + ); + }); + } + + it('rejects an r component larger than the curve size (ES256)', () => { + // r = 33 bytes, no leading zero → cannot fit a 32-byte P-256 slot. + const rBytes = new Array(33).fill(0x7f); + const der = new Uint8Array([ + 0x30, + 2 + 33 + 3, // seqLen (short form): r INTEGER (2+33) + s INTEGER (2+1) + 0x02, + 33, + ...rBytes, + 0x02, + 0x01, + 0x01, + ]); + expect(() => derToIeeeP1363(der, 'ES256')).to.throw( + ConfigurationError, + /component larger than expected/ + ); + }); + }); +}); From 5ce2fcc4c5d3bc5b119a1f8d38d765504847a370 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 9 Jul 2026 13:54:04 -0400 Subject: [PATCH 50/68] refactor(dpop): narrow JWS alg types, replace unchecked cast (DSPX-3397) determineJWSAlgorithmFromKeyInfo now returns AsymmetricSigningAlgorithm (the subset CryptoService can actually sign with); it never produces the forward-looking PS256/EdDSA members of JWSAlgorithm. In jwt(), replace the unchecked 'header.alg as AsymmetricSigningAlgorithm' with an isAsymmetricSigningAlgorithm type guard that throws UnsupportedOperationError for an unsupported alg, so a bad value fails early and clearly instead of deep inside getSigningAlgorithmParams. The JWSAlgorithm union and its PS256/EdDSA roadmap docs are kept unchanged. --- lib/src/auth/dpop.ts | 29 +++++++++++++++++++++++++++-- lib/tests/mocha/dpop-proof.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index cea222156..376adbc53 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -38,7 +38,14 @@ async function jwt( cryptoService: CryptoService ) { const input = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(claimsSet)))}`; - const alg = header.alg as AsymmetricSigningAlgorithm; + const { alg } = header; + // The header alg is a JWSAlgorithm, which is wider than what CryptoService can + // actually sign with (it documents forward-looking values like PS256/EdDSA). + // Validate rather than blind-cast so an unsupported alg fails here with a clear + // error instead of surfacing deep inside getSigningAlgorithmParams. + if (!isAsymmetricSigningAlgorithm(alg)) { + throw new UnsupportedOperationError(`unsupported DPoP alg: ${alg}`); + } 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 @@ -124,10 +131,28 @@ class UnsupportedOperationError extends Error { } } +const ASYMMETRIC_SIGNING_ALGORITHMS: readonly AsymmetricSigningAlgorithm[] = [ + 'RS256', + 'ES256', + 'ES384', + 'ES512', +]; + +/** + * Type guard narrowing a JWS `alg` to one CryptoService can actually sign with. + * `JWSAlgorithm` also lists forward-looking identifiers (PS256/EdDSA) that have + * no runtime signing support yet; those must be rejected, not signed. + */ +function isAsymmetricSigningAlgorithm(alg: string): alg is AsymmetricSigningAlgorithm { + return (ASYMMETRIC_SIGNING_ALGORITHMS as readonly string[]).includes(alg); +} + /** * Determines a supported JWS `alg` identifier from PublicKeyInfo algorithm string. + * Returns an AsymmetricSigningAlgorithm (the subset CryptoService can sign with); + * it never produces the forward-looking PS256/EdDSA members of JWSAlgorithm. */ -function determineJWSAlgorithmFromKeyInfo(algorithm: KeyAlgorithm): JWSAlgorithm { +function determineJWSAlgorithmFromKeyInfo(algorithm: KeyAlgorithm): AsymmetricSigningAlgorithm { if (isRsaKeyAlgorithm(algorithm)) { return 'RS256'; } diff --git a/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts index 53dc7d3b5..043cfc1ae 100644 --- a/lib/tests/mocha/dpop-proof.spec.ts +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -140,6 +140,28 @@ describe('DPoP proof — JWS conformance vs jose.jwtVerify (RFC 9449 + RFC 7518 } }); +describe('DPoP proof — unsupported key algorithm', function () { + it('throws before signing when the key algorithm is not a supported JWS alg', async () => { + // determineJWSAlgorithmFromKeyInfo (now typed to return only the four + // AsymmetricSigningAlgorithm values) must still reject an unknown key + // algorithm string up front, rather than the type change silently widening + // what flows into the signer. + const bogusKeyPair = { + publicKey: { algorithm: 'ec:brainpoolP256r1' }, + privateKey: {}, + } as unknown as KeyPair; + + let err: Error | undefined; + try { + await dpopFn(bogusKeyPair, DefaultCryptoService, HTU, HTM); + } catch (e) { + err = e as Error; + } + expect(err, 'expected an unsupported-algorithm error').to.be.instanceOf(Error); + expect(err?.message).to.match(/unsupported key algorithm/); + }); +}); + /** * 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 From b370338db59097f4144043ca33672beb21d71176 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 9 Jul 2026 14:02:37 -0400 Subject: [PATCH 51/68] fix(oidc): consistently reject DPoP-enabled-without-signingKey (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dpopEnabled && !signingKey state was handled inconsistently at request time: withCreds and doPost threw (with two different messages) while info() silently downgraded to a Bearer token — a silent failure on a misconfigured DPoP client. Add a private requireSigningKey() helper and route all three request paths through it, so they fail with one clear ConfigurationError. info() no longer falls back to Bearer when DPoP is enabled but no key is bound. Validation stays at request time (not construction), so the legitimate deferred-binding flow — enable DPoP now, bind the key later via refreshTokenClaimsWithClientPubkeyIfNeeded (opentdf.ts ready) — keeps working; a new test covers it. Also removes a stray debug console.log on the Connect-RPC rewrap error path (log-hygiene, matching the earlier errBrief work). --- lib/src/access/access-rpc.ts | 1 - lib/src/auth/oidc.ts | 68 +++++++++++++++++++------------ lib/tests/web/auth/auth.test.ts | 71 +++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 27 deletions(-) diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index b4b350dff..31d5c8564 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -67,7 +67,6 @@ export async function fetchWrappedKey( export function handleRpcRewrapError(e: unknown, platformUrl: string): never { if (e instanceof ConnectError) { - console.log('Error is a ConnectError with code:', e.code); switch (e.code) { case Code.InvalidArgument: // 400 Bad Request throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e.message}]`); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 7275c0082..be2957c22 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -166,6 +166,27 @@ export class AccessToken { this.nonceCache = nonceCache; } + /** + * Returns the configured DPoP signing key, throwing if DPoP is enabled but no + * key has been bound yet. Call only from DPoP-enabled paths. + * + * Validation is intentionally at request time rather than construction so the + * deferred-binding flow keeps working: a client may construct with DPoP + * enabled and bind the key later via + * {@link refreshTokenClaimsWithClientPubkeyIfNeeded} (e.g. `opentdf.ts` + * `ready`), which happens before the first request. All request paths + * (`info`, `doPost`, `withCreds`) fail here consistently rather than one + * silently downgrading to a Bearer token. + */ + private requireSigningKey(): KeyPair { + if (!this.signingKey) { + throw new ConfigurationError( + 'Client public key was not set via `updateClientPublicKey` or passed in via constructor; required when DPoP is enabled' + ); + } + return this.signingKey; + } + /** * https://connect2id.com/products/server/docs/api/userinfo * @param accessToken the current access_token or code @@ -176,11 +197,15 @@ export class AccessToken { const headers = { ...this.extraHeaders, } as Record; + // Resolve the DPoP signing key up front (throws if DPoP is enabled but no + // key has been bound); undefined when DPoP is disabled. No silent Bearer + // downgrade — a misconfigured DPoP client fails consistently with doPost/withCreds. + const signingKey = this.config.dpopEnabled ? this.requireSigningKey() : undefined; let cachedNonce: string | undefined; - if (this.config.dpopEnabled && this.signingKey) { + if (signingKey) { cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn( - this.signingKey, + signingKey, this.cryptoService, this.userInfoEndpoint, 'GET', @@ -196,7 +221,7 @@ export class AccessToken { }); // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. - if (this.config.dpopEnabled && this.signingKey && !response.ok) { + if (signingKey && !response.ok) { const challenge = DPoPNonceCache.extractNonce(response.headers); const challengeNonce = adoptChallengeNonce( this.nonceCache, @@ -206,7 +231,7 @@ export class AccessToken { ); if (challengeNonce) { headers.DPoP = await dpopFn( - this.signingKey, + signingKey, this.cryptoService, this.userInfoEndpoint, 'GET', @@ -242,20 +267,19 @@ export class AccessToken { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }; - // add DPoP headers if configured + // add DPoP headers if configured. Resolve the signing key up front (throws + // if DPoP is enabled but no key has been bound); undefined when disabled. + const signingKey = this.config.dpopEnabled ? this.requireSigningKey() : undefined; let cachedNonce: string | undefined; - if (this.config.dpopEnabled) { - if (!this.signingKey) { - throw new ConfigurationError('No signature configured'); - } + if (signingKey) { // Export opaque public key to PEM format for header - const publicKeyPem = await this.cryptoService.exportPublicKeyPem(this.signingKey.publicKey); + const publicKeyPem = await this.cryptoService.exportPublicKeyPem(signingKey.publicKey); // 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); cachedNonce = this.nonceCache.get(origin); - headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); + headers.DPoP = await dpopFn(signingKey, this.cryptoService, url, 'POST', cachedNonce); } const response = await (this.request || fetch)(url, { @@ -267,7 +291,7 @@ export class AccessToken { // 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) { + if (signingKey && !response.ok) { const challenge = DPoPNonceCache.extractNonce(response.headers); const challengeNonce = adoptChallengeNonce( this.nonceCache, @@ -277,13 +301,7 @@ export class AccessToken { ); if (challengeNonce) { // Regenerate DPoP proof with the server-provided nonce and retry. - headers.DPoP = await dpopFn( - this.signingKey!, - this.cryptoService, - url, - 'POST', - challengeNonce - ); + headers.DPoP = await dpopFn(signingKey, this.cryptoService, url, 'POST', challengeNonce); const retryResponse = await (this.request || fetch)(url, { method: 'POST', @@ -444,13 +462,11 @@ export class AccessToken { } async withCreds(httpReq: HttpRequest): Promise { - if (this.config.dpopEnabled && !this.signingKey) { - throw new ConfigurationError( - 'Client public key was not set via `updateClientPublicKey` or passed in via constructor; required when DPoP is enabled' - ); - } + // Resolve the DPoP signing key up front (throws if DPoP is enabled but no + // key has been bound); undefined when DPoP is disabled. + const signingKey = this.config.dpopEnabled ? this.requireSigningKey() : undefined; const accessToken = await this.get(); - if (this.config.dpopEnabled && this.signingKey) { + if (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 @@ -459,7 +475,7 @@ export class AccessToken { const htu = `${origin}${url.pathname}`; const cachedNonce = this.nonceCache.get(origin); const dpopToken = await dpopFn( - this.signingKey, + signingKey, this.cryptoService, htu, httpReq.method, diff --git a/lib/tests/web/auth/auth.test.ts b/lib/tests/web/auth/auth.test.ts index b3447a64c..4ccc0c659 100644 --- a/lib/tests/web/auth/auth.test.ts +++ b/lib/tests/web/auth/auth.test.ts @@ -87,6 +87,28 @@ describe('AccessToken', () => { expect(e.message).to.match(/Unauthorized/); } }); + it('throws when DPoP is enabled but signingKey is missing (no silent Bearer downgrade)', async () => { + const mf = mockFetch({ access_token: 'fdfsdffsdf' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/yeet', + clientId: 'yoo', + refreshToken: 'ignored', + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + try { + await accessToken.info('fakeToken'); + assert.fail('Expected ConfigurationError'); + } catch (e) { + expect(e.message).to.match(/required when DPoP is enabled/); + } + // Must fail before contacting userinfo, not silently fall back to Bearer. + expect(mf.called, 'must not send a userinfo request when misconfigured').to.be.false; + }); }); describe('exchanging refresh token for token with TDF claims', () => { @@ -428,6 +450,55 @@ describe('AccessToken', () => { } }); + it('token exchange (doPost via get) throws when DPoP is enabled but signingKey is missing', async () => { + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + try { + await accessToken.get(); + assert.fail('Expected ConfigurationError'); + } catch (e) { + expect(e.message).to.match(/required when DPoP is enabled/); + } + // Same consistent failure as info()/withCreds — never POST to the token endpoint. + expect(mf.called, 'must not POST to the token endpoint when misconfigured').to.be.false; + }); + + it('deferred key binding: withCreds succeeds after refreshTokenClaimsWithClientPubkeyIfNeeded', async () => { + // The legitimate deferred-binding flow (mirrors opentdf.ts `ready`): + // construct DPoP-enabled with NO key, bind the key later, then request. + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + const signingKey = await generateTestSigningKey(); + await accessToken.refreshTokenClaimsWithClientPubkeyIfNeeded(signingKey); + const result = await accessToken.withCreds({ + url: 'https://kas.invalid/v2/rewrap', + method: 'POST', + headers: {}, + }); + expect(result.headers).to.have.property('Authorization', 'DPoP test_token'); + expect(result.headers).to.have.property('DPoP'); + }); + 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' }); From 09a1c67138e05f1584c3fb01ae65244c3dea8719 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 16:50:02 -0400 Subject: [PATCH 52/68] fix(sdk): export DPoP nonce cache (DSPX-3397) Signed-off-by: Dave Mihalcik --- lib/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/index.ts b/lib/src/index.ts index d0cf60edf..d8ae9d333 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,6 +10,7 @@ export { type Interceptor, type TokenProvider, } from './auth/interceptors.js'; +export { DPoPNonceCache } from './auth/dpop-nonce.js'; export { clientCredentialsTokenProvider, refreshTokenProvider, From e90c01ed2e43c6551063bde3ff85f993da19a82a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 16:59:21 -0400 Subject: [PATCH 53/68] fix(web-app): retry DPoP nonce challenges (DSPX-3397) Signed-off-by: Dave Mihalcik --- web-app/src/session.ts | 38 ++++++++++++++++++------ web-app/tests/tests/dpop-headers.spec.ts | 9 ++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/web-app/src/session.ts b/web-app/src/session.ts index c63d4e669..1d4147792 100644 --- a/web-app/src/session.ts +++ b/web-app/src/session.ts @@ -1,7 +1,7 @@ import { decodeJwt } from 'jose'; import { default as dpopFn } from 'dpop'; import { base64 } from '@opentdf/sdk/encodings'; -import { AuthProvider, HttpRequest, withHeaders } from '@opentdf/sdk'; +import { AuthProvider, DPoPNonceCache, HttpRequest, withHeaders } from '@opentdf/sdk'; import { type KeyPair, WebCryptoService } from '@opentdf/sdk/singlecontainer'; export type OpenidConfiguration = { @@ -174,6 +174,7 @@ export class OidcClient implements AuthProvider { scope: string; sessionIdentifier: string; _sessions?: Sessions; + readonly nonceCache = new DPoPNonceCache(); // Store as opaque KeyPair private signingKey?: KeyPair; @@ -463,13 +464,31 @@ export class OidcClient implements AuthProvider { publicKey: publicKeyPem, privateKey: privateKeyPem, }); - headers.DPoP = await dpopFn(cryptoPair, config.token_endpoint, 'POST'); - const response = await fetch(config.token_endpoint, { - method: 'POST', - headers, - body: params, - credentials: 'include', - }); + const tokenOrigin = new URL(config.token_endpoint).origin; + const sentNonce = this.nonceCache.get(tokenOrigin); + headers.DPoP = await dpopFn(cryptoPair, config.token_endpoint, 'POST', sentNonce); + const sendTokenRequest = () => + fetch(config.token_endpoint, { + method: 'POST', + headers, + body: params, + credentials: 'include', + }); + + let response = await sendTokenRequest(); + if (!response.ok) { + const challengeNonce = response.headers.get('DPoP-Nonce') || undefined; + if (challengeNonce && challengeNonce !== sentNonce) { + this.nonceCache.set(tokenOrigin, challengeNonce); + headers.DPoP = await dpopFn(cryptoPair, config.token_endpoint, 'POST', challengeNonce); + response = await sendTokenRequest(); + } + } + + const nextNonce = response.headers.get('DPoP-Nonce'); + if (nextNonce) { + this.nonceCache.set(tokenOrigin, nextNonce); + } if (!response.ok) { throw new Error(response.statusText); } @@ -516,11 +535,12 @@ export class OidcClient implements AuthProvider { publicKey: publicKeyPem, privateKey: privateKeyPem, }); + const requestOrigin = new URL(httpReq.url).origin; const dpopToken = await dpopFn( cryptoPair, httpReq.url, httpReq.method, - /* nonce */ undefined, + this.nonceCache.get(requestOrigin), accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? diff --git a/web-app/tests/tests/dpop-headers.spec.ts b/web-app/tests/tests/dpop-headers.spec.ts index f41c7e84e..ec7f3d88a 100644 --- a/web-app/tests/tests/dpop-headers.spec.ts +++ b/web-app/tests/tests/dpop-headers.spec.ts @@ -59,4 +59,13 @@ test('DPoP headers on token and KAS rewrap requests', async ({ page }) => { expect(proof, 'a KAS request should carry a DPoP proof').toBeTruthy(); const header = JSON.parse(Buffer.from(proof!.split('.')[0], 'base64url').toString('utf8')); expect(header.typ).toBe('dpop+jwt'); + + // The test environment requires a server-issued nonce. At least one retried + // token or KAS request must therefore carry that nonce in its proof. + const nonceProof = captured.find((r) => { + if (!r.dpop) return false; + const payload = JSON.parse(Buffer.from(r.dpop.split('.')[1], 'base64url').toString('utf8')); + return typeof payload.nonce === 'string' && payload.nonce.length > 0; + }); + expect(nonceProof, 'a retried DPoP proof should carry the server nonce').toBeTruthy(); }); From 51c7f906c459ca88216a9e0d6ffc7c49c6a8ee50 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 17:02:42 -0400 Subject: [PATCH 54/68] fix(dpop): prefer rotated Connect nonce metadata (DSPX-3397) Signed-off-by: Dave Mihalcik --- lib/src/auth/dpop-nonce.ts | 13 +++++++------ lib/tests/web/auth/dpop-nonce.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index ba85a3c4d..a18c5808c 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -104,12 +104,13 @@ export function adoptChallengeNonceFromConnectError( metadata: NonceHeaders, sentNonce: string | undefined ): string | undefined { - return adoptIfFresh( - cache, - origin, - cache.get(origin) ?? DPoPNonceCache.extractNonce(metadata), - sentNonce - ); + const metadataNonce = DPoPNonceCache.extractNonce(metadata); + const cachedNonce = cache.get(origin); + // Prefer metadata when it carries a nonce different from the one sent. The + // cache can still contain that stale sent nonce when a custom Connect + // transport exposes response metadata but does not capture raw headers. + const challenge = metadataNonce && metadataNonce !== sentNonce ? metadataNonce : cachedNonce; + return adoptIfFresh(cache, origin, challenge, sentNonce); } /** diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index ddcfc436e..8598f999a 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -177,4 +177,31 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { expect(mockNext.callCount).to.equal(1); }); + + it('uses a rotated metadata nonce when the cache still contains the sent nonce', async () => { + const sentNonce = 'stale-nonce'; + const rotatedNonce = 'rotated-nonce'; + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, sentNonce); + + const mockNext = stub(); + mockNext + .onFirstCall() + .rejects( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': rotatedNonce }) + ) + ); + mockNext.onSecondCall().resolves({ header: new Headers() }); + + const interceptor = makeInterceptor(nonceCache); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(nonceCache.get(ORIGIN)).to.equal(rotatedNonce); + const retryReq = mockNext.secondCall.firstArg as { header: Headers }; + expect(decodeJwtPayload(retryReq.header.get('DPoP')!).nonce).to.equal(rotatedNonce); + }); }); From 9ff1ecf9012aa32d5928e3d5117c2c7330af1e99 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 17:11:41 -0400 Subject: [PATCH 55/68] fix(crypto): enforce ECDSA JWS signature lengths (DSPX-3397) Signed-off-by: Dave Mihalcik --- lib/tdf3/src/crypto/core/signing.ts | 43 +++++++++++-------- lib/tests/mocha/reqsignature-jws.spec.ts | 37 ++++++++++++++++ .../mocha/unit/crypto/der-signature.spec.ts | 21 ++++++++- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index dff504b12..ea57f7bcb 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -39,6 +39,20 @@ function getSigningAlgorithmParams(algorithm: AsymmetricSigningAlgorithm): { } } +/** Fixed-width byte length of each ECDSA signature component (R or S). */ +function getEcdsaComponentLength(algorithm: AsymmetricSigningAlgorithm): number { + switch (algorithm) { + case 'ES256': + return 32; + case 'ES384': + return 48; + case 'ES512': + return 66; + default: + throw new ConfigurationError(`Unsupported algorithm for ECDSA conversion: ${algorithm}`); + } +} + /** * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format. * RS256 signatures don't need conversion. @@ -51,10 +65,17 @@ export function ieeeP1363ToDer( return signature; } + const componentLen = getEcdsaComponentLength(algorithm); + const expectedLength = componentLen * 2; + if (signature.length !== expectedLength) { + throw new ConfigurationError( + `Invalid IEEE P1363 signature: expected ${expectedLength} bytes for ${algorithm}, got ${signature.length}` + ); + } + // IEEE P1363: r || s where each is padded to key size - const halfLen = signature.length / 2; - const r = signature.slice(0, halfLen); - const s = signature.slice(halfLen); + const r = signature.slice(0, componentLen); + const s = signature.slice(componentLen); // Remove leading zeros but keep one if the high bit is set const trimLeadingZeros = (arr: Uint8Array): Uint8Array => { @@ -112,21 +133,7 @@ export function derToIeeeP1363( return signature; } - // Determine the expected component length based on algorithm - let componentLen: number; - switch (algorithm) { - case 'ES256': - componentLen = 32; - break; - case 'ES384': - componentLen = 48; - break; - case 'ES512': - componentLen = 66; - break; - default: - throw new ConfigurationError(`Unsupported algorithm for DER conversion: ${algorithm}`); - } + const componentLen = getEcdsaComponentLength(algorithm); // Smallest well-formed ECDSA DER SEQUENCE is 8 bytes: // 0x30 seqLen 0x02 rLen r(>=1) 0x02 sLen s(>=1) diff --git a/lib/tests/mocha/reqsignature-jws.spec.ts b/lib/tests/mocha/reqsignature-jws.spec.ts index 099726b6a..0af29f359 100644 --- a/lib/tests/mocha/reqsignature-jws.spec.ts +++ b/lib/tests/mocha/reqsignature-jws.spec.ts @@ -36,6 +36,20 @@ function derToPem(der: Uint8Array, label: string): string { return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----`; } +function decodeBase64url(value: string): Uint8Array { + const base64 = value + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(value.length / 4) * 4, '='); + return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); +} + +function encodeBase64url(value: Uint8Array): string { + let binary = ''; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + async function ecdsaKeyPair( namedCurve: 'P-256' | 'P-384' | 'P-521' ): Promise<{ sdk: KeyPair; pubPem: string }> { @@ -89,4 +103,27 @@ describe('reqSignature / signJwt — JWS conformance vs jose.jwtVerify (RFC 7518 expect(payload.sub).to.equal('test'); }); } + + it('verifyJwt rejects a truncated ES256 signature', async () => { + const { sdk } = await ecdsaKeyPair('P-256'); + const token = await signJwt(DefaultCryptoService, { sub: 'test' }, sdk.privateKey, { + alg: 'ES256', + }); + const [header, payload, signature] = token.split('.'); + const truncated = encodeBase64url(decodeBase64url(signature).subarray(1)); + + let caught: unknown; + try { + await verifyJwt(DefaultCryptoService, `${header}.${payload}.${truncated}`, sdk.publicKey, { + algorithms: ['ES256'], + }); + } catch (error) { + caught = error; + } + + expect(caught).to.be.instanceOf(Error); + expect((caught as Error).message).to.include( + 'Invalid IEEE P1363 signature: expected 64 bytes for ES256, got 63' + ); + }); }); diff --git a/lib/tests/mocha/unit/crypto/der-signature.spec.ts b/lib/tests/mocha/unit/crypto/der-signature.spec.ts index 59ec99de9..d655a1f63 100644 --- a/lib/tests/mocha/unit/crypto/der-signature.spec.ts +++ b/lib/tests/mocha/unit/crypto/der-signature.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { derToIeeeP1363 } from '../../../../tdf3/src/crypto/core/signing.js'; +import { derToIeeeP1363, ieeeP1363ToDer } from '../../../../tdf3/src/crypto/core/signing.js'; import { ConfigurationError } from '../../../../src/errors.js'; /** @@ -104,3 +104,22 @@ describe('derToIeeeP1363 DER validation', () => { }); }); }); + +describe('ieeeP1363ToDer fixed-width validation', () => { + for (const [algorithm, expectedLength] of [ + ['ES256', 64], + ['ES384', 96], + ['ES512', 132], + ] as const) { + it(`${algorithm} accepts exactly ${expectedLength} bytes`, () => { + expect(ieeeP1363ToDer(new Uint8Array(expectedLength), algorithm)[0]).to.equal(0x30); + }); + + it(`${algorithm} rejects a shortened signature`, () => { + expect(() => ieeeP1363ToDer(new Uint8Array(expectedLength - 1), algorithm)).to.throw( + ConfigurationError, + `expected ${expectedLength} bytes` + ); + }); + } +}); From 17f0cd9324d5b1a36e0ad189f72c6cc3f1ad4bfe Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 21:50:39 -0400 Subject: [PATCH 56/68] chore: rm stray spec prompts --- .../plans/2026-06-09-dpop-cli-flags.md | 594 ------------------ .../specs/2026-06-09-dpop-cli-flags-design.md | 114 ---- 2 files changed, 708 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-09-dpop-cli-flags.md delete mode 100644 docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md diff --git a/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md deleted file mode 100644 index 90f1ed8ff..000000000 --- a/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md +++ /dev/null @@ -1,594 +0,0 @@ -# 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'] 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 → RSA-2048 via SDK's generateSigningKeyPair(). RS384/RS512 are rejected (all RSA proofs sign as RS256). - */ -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 deleted file mode 100644 index f9810a343..000000000 --- a/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md +++ /dev/null @@ -1,114 +0,0 @@ -# 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 are **rejected** with a `CLIError` (not silently downgraded): the SDK's `determineJWSAlgorithmFromKeyInfo` signs all RSA DPoP proofs as RS256, so requesting RS384/RS512 could never be honored. - -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):** `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` alone infers the algorithm from the key. An **explicit** `--dpop=` combined with a `--dpop-key` whose algorithm disagrees is a `CLIError` (EC curves matched exactly; RSA matched by family). A bare `--dpop` (default `ES256`) never conflicts with a key. - ---- - -## 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 From 68f36218c30efacbe6523fdfe28d78c61184af8f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:11:53 -0400 Subject: [PATCH 57/68] fix(cli): treat --no-dpop as disabling DPoP (DSPX-3397) yargs coerces the negated `--no-dpop` flag into boolean false on the now string-typed --dpop option; `false || 'ES256'` re-enabled DPoP with an ephemeral ES256 key. Only a string value now requests DPoP. Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 10 +++++++--- cli/tests/dpop-helpers.spec.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index 7e85300dd..60109272a 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -233,13 +233,17 @@ export async function resolveDPoPKeyPair( * `--dpopKey` enables DPoP even without `--dpop`. */ export async function resolveDPoPFromArgs(argv: { - dpop?: string; + dpop?: string | boolean; dpopKey?: string; }): Promise<{ dpopEnabled: boolean; dpopKeyPair: KeyPair | undefined }> { - const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; + // yargs coerces `--no-dpop` into the boolean `false` (its automatic negation), + // and a bare `--dpop` into the empty string. Only a string requests DPoP; a + // boolean `false` (or absent) means the user explicitly disabled it, so it must + // NOT fall through to the ES256 default. + const dpopAlg = typeof argv.dpop === 'string' ? argv.dpop || 'ES256' : undefined; // A non-empty --dpop value is an explicit algorithm choice; a bare --dpop // (empty string → ES256 default) is not, so it never conflicts with --dpopKey. - const algWasExplicit = !!argv.dpop; + const algWasExplicit = typeof argv.dpop === 'string' && argv.dpop !== ''; const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey, algWasExplicit); return { dpopEnabled, dpopKeyPair }; diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts index b557e098e..eddd4f1c1 100644 --- a/cli/tests/dpop-helpers.spec.ts +++ b/cli/tests/dpop-helpers.spec.ts @@ -259,6 +259,14 @@ describe('resolveDPoPFromArgs', function () { expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp256r1'); }); + it('stays disabled for --no-dpop (yargs boolean false), not re-enabled', async function () { + // yargs turns the negated `--no-dpop` flag into boolean false on the + // string-typed --dpop option; it must NOT fall through to the ES256 default. + const result = await resolveDPoPFromArgs({ dpop: false }); + expect(result.dpopEnabled).to.be.false; + expect(result.dpopKeyPair).to.be.undefined; + }); + it('honours an explicit --dpop=ES384', async function () { const result = await resolveDPoPFromArgs({ dpop: 'ES384' }); expect(result.dpopEnabled).to.be.true; From 13d01c33d9dff112d01bd8ec0db88fda0e907677 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:12:43 -0400 Subject: [PATCH 58/68] fix(oidc): keep non-DPoP tokens cached across key rotation (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cached token was deleted unconditionally, then again inside an `if (dpopEnabled)` block — dead code, and the comment claimed non-DPoP tokens stay cached when they never did. Only invalidate the cached token (and its expiry/in-flight promise) when DPoP is enabled, since only a DPoP-bound token (cnf.jkt) depends on the signing key. Also rewrite the rotted method docstring to match actual behavior. Signed-off-by: Dave Mihalcik --- lib/src/auth/oidc.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index be2957c22..8ea216b6e 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -409,11 +409,13 @@ export class AccessToken { } /** - * A TDF client MUST call this method whenever the client wants to use a new - * ephemeral key set. This updates the keys used to: - * or wishes to set the keypair after creating the object. + * A TDF client MUST call this method whenever it wants to bind a new ephemeral + * signing key (e.g. when setting the keypair after constructing the object). * - * Calling this function will trigger a forcible token refresh using the cached refresh token, and contact the auth server. + * It records the new signing key and, when DPoP is enabled, invalidates the + * cached token so the next `get()` obtains a token bound to the new key. It is + * a no-op when the key is unchanged and a token is already cached; it does not + * itself contact the auth server. */ async refreshTokenClaimsWithClientPubkeyIfNeeded(signingKey: KeyPair): Promise { // If we already have a token, and the pubkey is unchanged, @@ -422,15 +424,14 @@ export class AccessToken { if (this.data?.access_token && signingKey === this.signingKey) { return; } - delete this.data; - delete this.cachedExpiry; - delete this.inFlight; this.signingKey = signingKey; - // A DPoP-bound token (cnf.jkt) is tied to a specific key; rotating the + // A DPoP-bound token (cnf.jkt) is tied to a specific key, so rotating the // signing key invalidates any cached token. Non-DPoP tokens are key- - // independent and can stay cached. + // independent and can stay cached across a key change. if (this.config.dpopEnabled) { delete this.data; + delete this.cachedExpiry; + delete this.inFlight; } } From 22e057e6485e85913f0760a9e2963688fc7e253c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:13:33 -0400 Subject: [PATCH 59/68] test(dpop): add RS256 JWS conformance coverage (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proof and rewrap-token conformance loops only exercised ES256/384/512, leaving the RSA pass-through branch (RS256, the default for RSA keys, signed without the DER↔P1363 transform) unverified against a conformant verifier. Add RS256 cases to dpop-proof and reqsignature-jws specs. Signed-off-by: Dave Mihalcik --- lib/tests/mocha/dpop-proof.spec.ts | 64 ++++++++++++++++++++++++ lib/tests/mocha/reqsignature-jws.spec.ts | 56 +++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts index 043cfc1ae..7ace6272d 100644 --- a/lib/tests/mocha/dpop-proof.spec.ts +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -47,6 +47,33 @@ async function ecdsaKeyPair(namedCurve: 'P-256' | 'P-384' | 'P-521'): Promise { + // RS256 is the default DPoP alg for any RSA key and, unlike ES*, its signature + // is passed through unconverted (no DER↔P1363 transform). Exercise that branch + // against the same conformant verifier the ES cases use. + 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'] + ); + 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]); @@ -140,6 +167,43 @@ describe('DPoP proof — JWS conformance vs jose.jwtVerify (RFC 9449 + RFC 7518 } }); +describe('DPoP proof — RS256 JWS conformance vs jose.jwtVerify (RFC 9449)', function (this: Mocha.Suite) { + this.timeout(10_000); + + it('RS256 proof verifies against jose.jwtVerify', async () => { + const kp = await rsaKeyPair(); + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + + const header = jose.decodeProtectedHeader(proof); + expect(header.typ).to.equal('dpop+jwt'); + expect(header.alg).to.equal('RS256'); + expect(header.jwk).to.exist; + + const key = await jose.importJWK(header.jwk as jose.JWK, 'RS256'); + 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('RS256 proof verification rejects a flipped signature byte', async () => { + const kp = await rsaKeyPair(); + 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, 'RS256'); + let threw = false; + try { + await jose.jwtVerify(tampered, key); + } catch { + threw = true; + } + expect(threw, 'jose.jwtVerify must reject a tampered RS256 signature').to.equal(true); + }); +}); + describe('DPoP proof — unsupported key algorithm', function () { it('throws before signing when the key algorithm is not a supported JWS alg', async () => { // determineJWSAlgorithmFromKeyInfo (now typed to return only the four diff --git a/lib/tests/mocha/reqsignature-jws.spec.ts b/lib/tests/mocha/reqsignature-jws.spec.ts index 0af29f359..5d9c10b56 100644 --- a/lib/tests/mocha/reqsignature-jws.spec.ts +++ b/lib/tests/mocha/reqsignature-jws.spec.ts @@ -70,6 +70,32 @@ async function ecdsaKeyPair( return { sdk: { publicKey, privateKey }, pubPem }; } +async function rsaKeyPair(): Promise<{ sdk: KeyPair; pubPem: string }> { + // RS256 is the default for RSA keys and is signed without the DER↔P1363 + // transform applied to ES*; verify that pass-through branch stays conformant. + 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'] + ); + 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); @@ -104,6 +130,36 @@ describe('reqSignature / signJwt — JWS conformance vs jose.jwtVerify (RFC 7518 }); } + it('reqSignature RS256 token verifies against jose.jwtVerify', async () => { + const { sdk, pubPem } = await rsaKeyPair(); + + const token = await reqSignature( + { requestBody: 'hello' }, + sdk.privateKey, + DefaultCryptoService, + { + alg: 'RS256', + } + ); + + const key = await jose.importSPKI(pubPem, 'RS256'); + 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 RS256 round-trips through verifyJwt', async () => { + const { sdk } = await rsaKeyPair(); + const token = await signJwt(DefaultCryptoService, { sub: 'test' }, sdk.privateKey, { + alg: 'RS256', + }); + const { payload } = await verifyJwt(DefaultCryptoService, token, sdk.publicKey, { + algorithms: ['RS256'], + }); + expect(payload.sub).to.equal('test'); + }); + it('verifyJwt rejects a truncated ES256 signature', async () => { const { sdk } = await ecdsaKeyPair('P-256'); const token = await signJwt(DefaultCryptoService, { sub: 'test' }, sdk.privateKey, { From ae97eda9a6e3caedb2eb959e88fe7073a3de52cf Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:14:58 -0400 Subject: [PATCH 60/68] fix(cli): chain key-import error cause in loadDPoPKeyPairFromPem (DSPX-3397) The EC/RSA import probes discarded every underlying importKey error, so a genuinely corrupt key reported only the generic "unsupported key type" message. Retain the last import failure and chain it as the CLIError cause. Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index 60109272a..877ae5d3f 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -98,13 +98,17 @@ export async function loadDPoPKeyPairFromPem(pemPath: string): Promise // 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. + // Retain the most recent import failure so a genuinely corrupt key surfaces its + // real decode error as the cause, instead of only the generic "unsupported" message. + let lastImportError: unknown; 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 { + } catch (err) { + lastImportError = err; // wrong curve or not an EC key — try next } if (privCK) { @@ -122,7 +126,8 @@ export async function loadDPoPKeyPairFromPem(pemPath: string): Promise true, ['sign'] ); - } catch { + } catch (err) { + lastImportError = err; // not RSA either } if (rsaCK) { @@ -134,7 +139,8 @@ export async function loadDPoPKeyPairFromPem(pemPath: string): Promise 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` + `Cannot parse DPoP key from ${pemPath}: expected PKCS8 PEM with ECDSA (P-256/P-384/P-521) or RSA private key`, + lastImportError instanceof Error ? lastImportError : undefined ); } From 67e3eafaae4ea8c0e5e7e13c93de7ec1fba27a25 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:15:29 -0400 Subject: [PATCH 61/68] fix(cli): fail inspect when requested DPoP binding is absent (DSPX-3397) console.assert only prints to stderr and never sets a non-zero exit code, so `inspect` could report success even when a requested DPoP proof or cnf.jkt binding never took effect. Throw CLIError for those security-outcome checks. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 575ac63f6..8c1a6089a 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -635,14 +635,23 @@ 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 (dpopEnabled) { - console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); + if (dpopEnabled && !accessToken.cnf?.jkt) { + // A missing cnf.jkt means token binding silently didn't take + // effect; fail loudly rather than exit 0 with only a warning. + throw new CLIError( + 'CRITICAL', + 'DPoP requested but the access token is not bound (missing cnf.jkt)' + ); } break; } } - console.assert(accessToken, 'No access_token found'); - console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); + if (!accessToken) { + throw new CLIError('CRITICAL', 'No access_token found'); + } + if (dpopEnabled && !dpopToken) { + throw new CLIError('CRITICAL', 'DPoP requested but no DPoP proof was sent'); + } } finally { client.close(); } From eb49e387b897e5ba2555312116d0d17b8e034e03 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:16:02 -0400 Subject: [PATCH 62/68] fix(access): surface missing BaseKey as ConfigurationError (DSPX-3397) fetchKasBasePubKey threw NetworkError for a malformed platform config and the surrounding catch re-wrapped it under a [PublicKey] network banner, pointing operators at KAS connectivity for a config problem. Throw ConfigurationError and re-throw it unchanged; only genuine RPC failures become NetworkError. Signed-off-by: Dave Mihalcik --- lib/src/access/access-rpc.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index 31d5c8564..7c0cfb9ce 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -230,7 +230,7 @@ export async function fetchKasPubKey( /** * Fetch the base public key from WellKnownConfiguration of the platform. * @param kasEndpoint The KAS endpoint URL. - * @throws {ConfigurationError} If the KAS endpoint is not defined. + * @throws {ConfigurationError} If the KAS endpoint is not defined, or the platform config is missing its BaseKey. * @throws {NetworkError} If there is an error fetching the public key from the KAS endpoint. * @returns The base public key information for the KAS endpoint. */ @@ -248,7 +248,7 @@ export async function fetchKasBasePubKey(kasEndpoint: string): Promise Date: Mon, 27 Jul 2026 12:16:41 -0400 Subject: [PATCH 63/68] fix(access): log base-key fallback with errBrief, not raw error (DSPX-3397) fetchKasPubKey dumped the raw base-key error via console.log(e) with no context. Use the existing errBrief helper and a descriptive prefix so the log is a one-liner and never dumps a Connect error object (which may carry DPoP nonce metadata), consistent with the rest of the auth path. Signed-off-by: Dave Mihalcik --- lib/src/access.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 59fbfc6e9..41657063a 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -247,7 +247,10 @@ export async function fetchKasPubKey( try { return await fetchKasBasePubKey(kasEndpoint); } catch (e) { - console.log(e); + // Base key is optional; fall back to the RPC/legacy public-key path. Log a + // one-line summary via errBrief (never the raw error object, which for Connect + // errors can carry response metadata including DPoP nonces). + console.log(`base key fetch failed, falling back to RPC/legacy public key: ${errBrief(e)}`); } return await tryRpcThenLegacy( From 3b34f9a8cc19b68e6018c3e9a8bdf6e620df5540 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:17:29 -0400 Subject: [PATCH 64/68] refactor(crypto): share asymmetric-alg guard across JWT and DPoP signers (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signJwt/verifyJwt cast header.alg to AsymmetricSigningAlgorithm unchecked, while dpop.ts validated with a local type guard — sibling signers enforcing the same invariant with different rigor. Promote the guard and its backing list to declarations.ts (next to the type), reuse it in dpop.ts, and replace both jwt.ts casts so an unsupported alg fails fast with a clear error. Signed-off-by: Dave Mihalcik --- lib/src/auth/dpop.ts | 21 ++++----------------- lib/tdf3/src/crypto/declarations.ts | 21 +++++++++++++++++++++ lib/tdf3/src/crypto/jwt.ts | 12 +++++++++--- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index 376adbc53..98711a38b 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -8,7 +8,10 @@ import type { AsymmetricSigningAlgorithm, KeyAlgorithm, } from '../../tdf3/src/crypto/declarations.js'; -import { isRsaKeyAlgorithm } from '../../tdf3/src/crypto/declarations.js'; +import { + isAsymmetricSigningAlgorithm, + isRsaKeyAlgorithm, +} from '../../tdf3/src/crypto/declarations.js'; import { derToIeeeP1363 } from '../../tdf3/src/crypto/core/signing.js'; export type JsonObject = { [Key in string]?: JsonValue }; @@ -131,22 +134,6 @@ class UnsupportedOperationError extends Error { } } -const ASYMMETRIC_SIGNING_ALGORITHMS: readonly AsymmetricSigningAlgorithm[] = [ - 'RS256', - 'ES256', - 'ES384', - 'ES512', -]; - -/** - * Type guard narrowing a JWS `alg` to one CryptoService can actually sign with. - * `JWSAlgorithm` also lists forward-looking identifiers (PS256/EdDSA) that have - * no runtime signing support yet; those must be rejected, not signed. - */ -function isAsymmetricSigningAlgorithm(alg: string): alg is AsymmetricSigningAlgorithm { - return (ASYMMETRIC_SIGNING_ALGORITHMS as readonly string[]).includes(alg); -} - /** * Determines a supported JWS `alg` identifier from PublicKeyInfo algorithm string. * Returns an AsymmetricSigningAlgorithm (the subset CryptoService can sign with); diff --git a/lib/tdf3/src/crypto/declarations.ts b/lib/tdf3/src/crypto/declarations.ts index 91ec76b5b..8503e23ca 100644 --- a/lib/tdf3/src/crypto/declarations.ts +++ b/lib/tdf3/src/crypto/declarations.ts @@ -190,6 +190,27 @@ export type ECCurve = 'P-256' | 'P-384' | 'P-521'; */ export type AsymmetricSigningAlgorithm = 'RS256' | 'ES256' | 'ES384' | 'ES512'; +/** + * Runtime list of {@link AsymmetricSigningAlgorithm} values, kept in sync with + * the type above. Used to validate untyped/JWS-header algorithm strings. + */ +export const ASYMMETRIC_SIGNING_ALGORITHMS: readonly AsymmetricSigningAlgorithm[] = [ + 'RS256', + 'ES256', + 'ES384', + 'ES512', +]; + +/** + * Type guard narrowing an arbitrary string to an algorithm CryptoService can + * actually sign/verify with. The JWS `alg` space is wider (e.g. forward-looking + * PS256/EdDSA identifiers) than this runtime-supported subset; those must be + * rejected, not cast. + */ +export function isAsymmetricSigningAlgorithm(alg: string): alg is AsymmetricSigningAlgorithm { + return (ASYMMETRIC_SIGNING_ALGORITHMS as readonly string[]).includes(alg); +} + /** * Symmetric signing algorithm (requires raw key bytes). */ diff --git a/lib/tdf3/src/crypto/jwt.ts b/lib/tdf3/src/crypto/jwt.ts index 9ea29e37c..7e9b41a0e 100644 --- a/lib/tdf3/src/crypto/jwt.ts +++ b/lib/tdf3/src/crypto/jwt.ts @@ -1,5 +1,5 @@ import { - type AsymmetricSigningAlgorithm, + isAsymmetricSigningAlgorithm, type CryptoService, type PrivateKey, type PublicKey, @@ -135,7 +135,10 @@ export async function signJwt( if (key._brand !== 'PrivateKey') { throw new Error(`${header.alg} requires a PrivateKey`); } - const alg = header.alg as AsymmetricSigningAlgorithm; + if (!isAsymmetricSigningAlgorithm(header.alg)) { + throw new Error(`Unsupported JWS signing algorithm: ${header.alg}`); + } + const alg = header.alg; 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 @@ -238,7 +241,10 @@ export async function verifyJwt( typeof key === 'string' ? await cryptoService.importPublicKey(key, { usage: 'sign' }) : (key as PublicKey); - const alg = header.alg as AsymmetricSigningAlgorithm; + if (!isAsymmetricSigningAlgorithm(header.alg)) { + throw new joseErrors.JWTInvalid(`Invalid JWT: unsupported algorithm "${header.alg}"`); + } + const alg = header.alg; // 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. From 2c91d78f7c1711816f4546a38352e9152a572408 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 27 Jul 2026 12:18:41 -0400 Subject: [PATCH 65/68] docs(dpop,access): correct comment and docstring rot (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DER↔P1363 comments in dpop.ts/jwt.ts said RSA/EdDSA are passed through raw, but EdDSA is rejected upstream and never reaches those branches. - fetchWrappedKey JSDoc documented `requestBody`/`clientVersion` params that don't exist; align with the actual (url, signedRequestToken, auth, ...) signature. Signed-off-by: Dave Mihalcik --- lib/src/access/access-rpc.ts | 5 ++--- lib/src/auth/dpop.ts | 7 ++++--- lib/tdf3/src/crypto/jwt.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index 7c0cfb9ce..0910833a5 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -30,10 +30,9 @@ import { ConnectError, Code } from '@connectrpc/connect'; /** * Get a rewrapped access key to the document, if possible * @param url Key access server rewrap endpoint - * @param requestBody a signed request with an encrypted document key - * @param authProvider Authorization middleware + * @param signedRequestToken a signed request with an encrypted document key + * @param auth Authorization middleware * @param rewrapAdditionalContextHeader optional value for 'X-Rewrap-Additional-Context' - * @param clientVersion */ export async function fetchWrappedKey( url: string, diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index 98711a38b..b72ea68f9 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -52,9 +52,10 @@ async function jwt( 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. + // accepted by RFC-conformant verifiers (Keycloak, panva-jose). RSA signatures + // are already raw bytes — no conversion (EdDSA is rejected by the guard above + // and never reaches here). See DSPX-3634 for the broader cleanup that would + // make this transform unnecessary. if (alg.startsWith('ES')) { signature = derToIeeeP1363(signature, alg); } diff --git a/lib/tdf3/src/crypto/jwt.ts b/lib/tdf3/src/crypto/jwt.ts index 7e9b41a0e..1c6c04e9f 100644 --- a/lib/tdf3/src/crypto/jwt.ts +++ b/lib/tdf3/src/crypto/jwt.ts @@ -142,9 +142,9 @@ export async function signJwt( 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. + // rewrap request token) is accepted by RFC-conformant verifiers. RSA + // signatures are already raw bytes — no conversion (only RS256 and ES* + // reach here). Mirrors the DPoP proof signer in src/auth/dpop.ts. if (alg.startsWith('ES')) { signature = derToIeeeP1363(signature, alg); } From 20aeab26880dc2ae5a1ae8dab269b21245f1a0aa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 4 Aug 2026 12:00:46 -0500 Subject: [PATCH 66/68] fix(ci): probe keycloak health on the management port (DSPX-3397) Keycloak 25+ serves health on a separate management interface (port 9000) which inherits KC_HTTP_RELATIVE_PATH, so the old 8888/auth/health/live probe 404s against the 26.2 image and the container never reports healthy. Signed-off-by: Dave Mihalcik --- .github/workflows/roundtrip/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/roundtrip/docker-compose.yaml b/.github/workflows/roundtrip/docker-compose.yaml index 6b39b65ce..d573ed050 100644 --- a/.github/workflows/roundtrip/docker-compose.yaml +++ b/.github/workflows/roundtrip/docker-compose.yaml @@ -23,7 +23,7 @@ services: ports: - '8888:8888' healthcheck: - test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:8888/auth/health/live'] + test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:9000/auth/health/live'] interval: 5s timeout: 10s retries: 3 From 9f45ab01f22f04a7bcbd2fe2ab36bedacda5d896 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 4 Aug 2026 12:00:46 -0500 Subject: [PATCH 67/68] fix(ci): run kcadm inside the keycloak container (DSPX-3397) config-demo-idp.sh downloaded the Keycloak release zip and ran kcadm.sh on the runner JRE. Keycloak 26.2 kcadm is compiled for Java 17 (class file 61.0) but ubuntu-22.04 defaults to Java 11 (max 55.0), so every call died with UnsupportedClassVersionError and no clients or users were created: Error: LinkageError occurred while loading main class org.keycloak.client.admin.cli.KcAdmMain Run kcadm inside the keycloak container instead. It ships a JRE matching its own Keycloak version, which removes both the JRE-skew failure and a ~155MB download. Resolve the compose file from the script directory so the caller cwd does not matter, and target the container-internal port since the 65432 vite proxy is only reachable from the host. Also give the config-demo-idp.sh failure branch in wait-and-test.sh its own error string; it previously printed the same text as the provision step, making the failing stage ambiguous in CI logs. Signed-off-by: Dave Mihalcik --- .../workflows/roundtrip/config-demo-idp.sh | 37 +++++++------------ .github/workflows/roundtrip/wait-and-test.sh | 2 +- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/.github/workflows/roundtrip/config-demo-idp.sh b/.github/workflows/roundtrip/config-demo-idp.sh index dcda88bf1..9c278b624 100755 --- a/.github/workflows/roundtrip/config-demo-idp.sh +++ b/.github/workflows/roundtrip/config-demo-idp.sh @@ -2,31 +2,22 @@ set -x -: "${KC_VERSION:=26.2.0}" +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -if ! which kcadm.sh; then - KCADM_URL=https://github.com/keycloak/keycloak/releases/download/${KC_VERSION}/keycloak-${KC_VERSION}.zip - echo "DOWNLOADING ${KCADM_URL}" - if ! curl --output kc.zip --fail --location "${KCADM_URL}"; then - echo "[ERROR] Failed to download ${KCADM_URL}" - exit 3 - fi - ls -l - if ! unzip ./kc.zip; then - echo "[ERROR] Failed to unzip file from ${KCADM_URL}" - exit 3 - fi - ls -l - ls -l "$(pwd)/keycloak-${KC_VERSION}/bin" - PATH=$PATH:"$(pwd)/keycloak-${KC_VERSION}/bin" - export PATH - if ! which kcadm.sh; then - echo "[ERROR] Failed to find kcadm.sh" - exit 3 - fi -fi +# Run kcadm inside the keycloak container instead of downloading the release +# zip. The container ships a JRE matching its own Keycloak version; the host +# does not necessarily -- Keycloak 26's kcadm needs Java 17, while the +# ubuntu-22.04 runner defaults to Java 11 (UnsupportedClassVersionError). +# Using -f makes the compose project resolve from this script's directory, so +# the caller's working directory doesn't matter. +kcadm.sh() { + docker compose -f "${APP_DIR}/docker-compose.yaml" \ + exec -T keycloak /opt/keycloak/bin/kcadm.sh "$@" +} -kcadm.sh config credentials --server http://localhost:65432/auth \ +# Inside the container Keycloak is reached on its own KC_HTTP_PORT, not through +# the vite dev-server proxy on 65432 that host-side callers use. +kcadm.sh config credentials --server http://localhost:8888/auth \ --realm master --user admin --password changeme kcadm.sh create clients -r opentdf \ diff --git a/.github/workflows/roundtrip/wait-and-test.sh b/.github/workflows/roundtrip/wait-and-test.sh index f289b86d6..7e06c42eb 100755 --- a/.github/workflows/roundtrip/wait-and-test.sh +++ b/.github/workflows/roundtrip/wait-and-test.sh @@ -115,7 +115,7 @@ _init_platform() { return 1 fi if ! ./config-demo-idp.sh; then - echo "[ERROR] unable to provision keycloak" + echo "[ERROR] unable to configure demo idp clients" return 1 fi if ! ./init-temp-keys.sh; then From 0fc4c3fb50b5218f82782b161a763373db8aae92 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 5 Aug 2026 10:24:22 -0500 Subject: [PATCH 68/68] test(ci): exercise the DPoP nonce challenge in the roundtrip (DSPX-3397) The roundtrip already runs every CLI and browser flow with DPoP, but only proves plain proof-of-possession: KAS never challenges, so the server-issued nonce retry this PR adds is never walked. xtest can't fill the gap. Its nonce cases are gated on opentdf/tests' shared `dpop-challenge` input, which also turns on require_nonce for every SDK in the matrix -- including ones with no nonce support -- so it stays off. Without this, the feature ships with no CI coverage. Set server.auth.dpop.require_nonce so KAS answers the first proofed request with 401 + DPoP-Nonce. `enforce` stays off, so bearer tokens are still accepted and the non-DPoP paths in this job are unchanged. Signed-off-by: Dave Mihalcik --- .github/workflows/roundtrip/opentdf.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/roundtrip/opentdf.yaml b/.github/workflows/roundtrip/opentdf.yaml index 0b402d2f6..abd6deb2e 100644 --- a/.github/workflows/roundtrip/opentdf.yaml +++ b/.github/workflows/roundtrip/opentdf.yaml @@ -54,6 +54,16 @@ server: public_client_id: 'opentdf-public' audience: 'http://localhost:65432' issuer: http://localhost:65432/auth/realms/opentdf + dpop: + # Make KAS answer the first DPoP-proofed request with 401 + DPoP-Nonce so + # the roundtrip actually walks the server-issued nonce retry, not just + # plain proof-of-possession. xtest can't cover this: its nonce cases only + # run when the shared `dpop-challenge` input is on, which also swaps in a + # platform config other SDKs aren't ready for. Left off, this PR's headline + # feature would ship with no CI coverage at all. + # Only `enforce` would reject bearer tokens outright; that stays off, so + # the non-DPoP paths in this job are unaffected. + require_nonce: true policy: ## Dot notation is used to access nested claims (i.e. realm_access.roles) # Claim that represents the user (i.e. email)