diff --git a/.github/workflows/roundtrip/config-demo-idp.sh b/.github/workflows/roundtrip/config-demo-idp.sh index 6978f161e..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:=24.0.3}" +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 \ @@ -48,7 +39,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..d573ed050 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' @@ -29,24 +23,11 @@ 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 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/.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) 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 diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0f0a56998..8c1a6089a 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -22,6 +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'; +import { resolveDPoPFromArgs } from './dpop-helpers.js'; type AuthToProcess = { auth?: string; @@ -53,14 +54,10 @@ const parseJwtComplete = (jwt: string) => { return { header: parseJwt(jwt, 0), payload: parseJwt(jwt) }; }; -async function processAuth({ - auth, - clientId, - clientSecret, - concurrencyLimit, - oidcEndpoint, - userId, -}: AuthToProcess): Promise { +async function processAuth( + { auth, clientId, clientSecret, concurrencyLimit, oidcEndpoint, userId }: AuthToProcess, + dpopKeyPair?: KeyPair +): Promise { log('DEBUG', 'Processing auth params'); if (!oidcEndpoint) { throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); @@ -79,11 +76,19 @@ async function processAuth({ 'Auth expects clientId and clientSecret, or combined auth param' ); } + // Pass DPoP key into the provider config so the AccessToken is born with + // DPoP enabled (config.dpopEnabled + signingKey). Without this, the very + // first POST /token would go out without a DPoP proof — Keycloak clients + // with dpop_bound_access_tokens=true reject that with 400 invalid_request. + // Without a key, DPoP stays off so non-DPoP clients still get plain Bearer + // tokens that the platform will accept. const actual = await AuthProviders.clientSecretAuthProvider({ clientId, oidcOrigin: oidcEndpoint, exchange: 'client', clientSecret, + dpopEnabled: !!dpopKeyPair, + signingKey: dpopKeyPair, }); if (concurrencyLimit !== 1) { await actual.oidcAuth.get(); @@ -91,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}]`); @@ -394,8 +405,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') @@ -513,6 +530,21 @@ export const handleArgs = (args: string[]) => { description: 'output file', }) + .command( + 'supports ', + 'Check if a feature is supported', + (yargs) => { + yargs.strict().positional('feature', { + describe: 'feature name to check', + type: 'string', + choices: ['dpop'], + }); + }, + async () => { + // yargs choices validation ensures feature is supported; return naturally exits 0 + } + ) + .command( 'inspect [file]', 'Inspect TDF and extract header information, without decrypting', @@ -561,9 +593,11 @@ export const handleArgs = (args: string[]) => { if (!argv.oidcEndpoint) { throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); } - const authProvider = await processAuth(argv); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); + const authProvider = await processAuth(argv, dpopKeyPair); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); + const client = new OpenTDF({ authProvider, defaultCreateOptions: { @@ -574,7 +608,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, }); @@ -600,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 (argv.dpop) { - 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(!argv.dpop || 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(); } @@ -624,7 +668,8 @@ export const handleArgs = (args: string[]) => { }, async (argv) => { log('DEBUG', 'Running encrypt command'); - const authProvider = await processAuth(argv); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); + const authProvider = await processAuth(argv, dpopKeyPair); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); @@ -633,7 +678,8 @@ export const handleArgs = (args: string[]) => { defaultCreateOptions: { defaultKASEndpoint: argv.kasEndpoint, }, - disableDPoP: !argv.dpop, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, policyEndpoint: guessedPolicyEndpoint, platformUrl: argv.platformUrl || guessedPolicyEndpoint, }); diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts new file mode 100644 index 000000000..877ae5d3f --- /dev/null +++ b/cli/src/dpop-helpers.ts @@ -0,0 +1,256 @@ +// 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'] as const; +export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; + +const EC_CURVE_MAP: Record = { + ES256: 'P-256', + ES384: 'P-384', + ES512: 'P-521', +}; + +/** Resolve the optional WebCryptoService.importPrivateKey method, failing with a clear CLIError if absent. */ +function requireImportPrivateKey() { + if (!WebCryptoService.importPrivateKey) { + throw new CLIError( + 'CRITICAL', + 'WebCryptoService.importPrivateKey is unavailable in this SDK build; cannot load DPoP private keys' + ); + } + return WebCryptoService.importPrivateKey; +} + +/** Convert a DER buffer to a PEM string with the given type label. */ +export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { + const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; + const b64 = Buffer.from(bytes).toString('base64'); + const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64; + return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; +} + +/** + * Generate an ephemeral DPoP key pair for the given JWS algorithm. + * ES256/ES384/ES512 → ECDSA key via WebCrypto + SDK import. + * RS256 → 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)) { + 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 importPriv = requireImportPrivateKey(); + const [privateKey, publicKey] = await Promise.all([ + importPriv(privPem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; + } + + // RSA fallback — generateSigningKeyPair() produces RSA-2048 (DPoP maps this to RS256) + return WebCryptoService.generateSigningKeyPair(); +} + +/** + * Load a DPoP key pair from a PKCS8 PEM-encoded private key file. + * Derives the public key from the private key via JWK round-trip. + * Supports ECDSA (P-256, P-384, P-521) and RSA (PKCS1-v1_5 SHA-256). + */ +export async function loadDPoPKeyPairFromPem(pemPath: string): Promise { + let privatePem: string; + try { + privatePem = await readFile(pemPath, 'utf8'); + } catch (err) { + throw new CLIError('CRITICAL', `Cannot read DPoP key file: ${pemPath}`, err as Error); + } + + let der: Uint8Array; + try { + const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n\s]/g, ''); + der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + } catch (err) { + throw new CLIError( + 'CRITICAL', + `Cannot decode DPoP key file as PEM/base64: ${pemPath}. Ensure the file is a PKCS8 PEM-encoded private key.`, + err as Error + ); + } + + // Try EC curves (P-256, P-384, P-521). Catch only the importKey call so that + // any SDK-layer errors from buildKeyPairFromCryptoKey propagate with full context. + // 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 (err) { + lastImportError = err; + // wrong curve or not an EC key — try next + } + if (privCK) { + return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + } + } + + // Try RSA (PKCS1-v1_5 SHA-256). Same narrowing rationale as above. + let rsaCK: webcrypto.CryptoKey | undefined; + try { + rsaCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['sign'] + ); + } catch (err) { + lastImportError = err; + // not RSA either + } + if (rsaCK) { + return await buildKeyPairFromCryptoKey(privatePem, rsaCK, { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + } + + throw new CLIError( + 'CRITICAL', + `Cannot parse DPoP key from ${pemPath}: expected PKCS8 PEM with ECDSA (P-256/P-384/P-521) or RSA private key`, + lastImportError instanceof Error ? lastImportError : undefined + ); +} + +/** + * Derive the public key from an already-imported private CryptoKey via JWK round-trip, + * then import both through the SDK to get the opaque KeyPair type. + */ +async function buildKeyPairFromCryptoKey( + privatePem: string, + privCK: webcrypto.CryptoKey, + algorithm: + | webcrypto.AlgorithmIdentifier + | webcrypto.RsaHashedImportParams + | webcrypto.EcKeyImportParams +): Promise { + // Export private key as JWK; strip private components to build the public JWK + const privJwk = await crypto.subtle.exportKey('jwk', privCK); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, ...pubJwkProps } = privJwk; + const pubJwk: webcrypto.JsonWebKey = { ...pubJwkProps, key_ops: ['verify'] }; + + const pubCK = await crypto.subtle.importKey('jwk', pubJwk, algorithm, true, ['verify']); + const pubDer = await crypto.subtle.exportKey('spki', pubCK); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + + const importPriv = requireImportPrivateKey(); + const [privateKey, publicKey] = await Promise.all([ + importPriv(privatePem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +/** + * The opaque key `algorithm` string the SDK reports for a key of each DPoP JWS + * algorithm. EC curves are matched exactly; all RSA key sizes sign as RS256, so + * RS256 matches the `rsa` family (e.g. `rsa:2048`, `rsa:4096`). + */ +const DPOP_ALG_TO_KEY_ALG: Record = { + 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. 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, + algWasExplicit = false +): Promise { + if (keyPath) { + const keyPair = await loadDPoPKeyPairFromPem(keyPath); + if (alg && algWasExplicit) { + assertKeyMatchesRequestedAlg(keyPair, alg, keyPath); + } + return keyPair; + } + if (alg) { + return generateEphemeralDPoPKeyPair(alg); + } + return undefined; +} + +/** + * Resolve DPoP configuration from CLI argv. Bare `--dpop` defaults to ES256; + * `--dpopKey` enables DPoP even without `--dpop`. + */ +export async function resolveDPoPFromArgs(argv: { + dpop?: string | boolean; + dpopKey?: string; +}): Promise<{ dpopEnabled: boolean; dpopKeyPair: KeyPair | undefined }> { + // 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 = 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 new file mode 100644 index 000000000..eddd4f1c1 --- /dev/null +++ b/cli/tests/dpop-helpers.spec.ts @@ -0,0 +1,311 @@ +// cli/tests/dpop-helpers.spec.ts +import { expect } from '@esm-bundle/chai'; +import { type webcrypto } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + derToPem, + generateEphemeralDPoPKeyPair, + loadDPoPKeyPairFromPem, + resolveDPoPFromArgs, + resolveDPoPKeyPair, +} from '../src/dpop-helpers.js'; + +describe('derToPem', function () { + it('wraps DER bytes in PEM armor with the given type', function () { + const der = new Uint8Array([0x01, 0x02, 0x03]); + const pem = derToPem(der, 'PUBLIC KEY'); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + expect(pem).to.include('AQID'); // base64 of [1,2,3] + }); + + it('wraps an ArrayBuffer in PEM armor', function () { + const der = new Uint8Array([0x01, 0x02]).buffer; + const pem = derToPem(der, 'PRIVATE KEY'); + expect(pem).to.include('-----BEGIN PRIVATE KEY-----'); + expect(pem).to.include('-----END PRIVATE KEY-----'); + }); +}); + +describe('generateEphemeralDPoPKeyPair', function () { + it('generates ES256 (ec:secp256r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES256'); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('generates ES384 (ec:secp384r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES384'); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('generates ES512 (ec:secp521r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES512'); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('generates RS256 (rsa:2048) key pair', async function () { + this.timeout(15_000); + const kp = await generateEphemeralDPoPKeyPair('RS256'); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws on unknown algorithm', async function () { + try { + await generateEphemeralDPoPKeyPair('HS256'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); + + 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 }; + +async function ecPrivatePem(curve: 'P-256' | 'P-384' | 'P-521'): Promise { + const raw = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: curve }, true, [ + 'sign', + 'verify', + ])) as GeneratedPair; + const der = await crypto.subtle.exportKey('pkcs8', raw.privateKey); + return derToPem(der, 'PRIVATE KEY'); +} + +async function rsaPrivatePem(): Promise { + const raw = (await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + )) as GeneratedPair; + const der = await crypto.subtle.exportKey('pkcs8', raw.privateKey); + return derToPem(der, 'PRIVATE KEY'); +} + +describe('loadDPoPKeyPairFromPem', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-helpers-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeTmp(name: string, contents: string): Promise { + const path = join(tmpDir, name); + await writeFile(path, contents); + return path; + } + + it('loads a P-256 PEM into an ec:secp256r1 key pair', async function () { + const path = await writeTmp('p256.pem', await ecPrivatePem('P-256')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('loads a P-384 PEM into an ec:secp384r1 key pair', async function () { + const path = await writeTmp('p384.pem', await ecPrivatePem('P-384')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('loads a P-521 PEM into an ec:secp521r1 key pair', async function () { + const path = await writeTmp('p521.pem', await ecPrivatePem('P-521')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('loads an RSA-2048 PEM into an rsa:2048 key pair', async function () { + this.timeout(15_000); + const path = await writeTmp('rsa.pem', await rsaPrivatePem()); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws CLIError when the file cannot be read', async function () { + try { + await loadDPoPKeyPairFromPem(join(tmpDir, 'does-not-exist.pem')); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot read DPoP key file'); + } + }); + + it('throws CLIError when the PEM body is not valid base64', async function () { + const path = await writeTmp( + 'corrupt.pem', + '-----BEGIN PRIVATE KEY-----\n!!!not-base64!!!\n-----END PRIVATE KEY-----' + ); + try { + await loadDPoPKeyPairFromPem(path); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot decode DPoP key file as PEM/base64'); + } + }); + + it('throws CLIError when the bytes are not a recognized key type', async function () { + // Valid base64 but the decoded bytes are not a PKCS8 EC or RSA key. + const path = await writeTmp( + 'garbage.pem', + '-----BEGIN PRIVATE KEY-----\nQUJDREVGR0g=\n-----END PRIVATE KEY-----' + ); + try { + await loadDPoPKeyPairFromPem(path); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot parse DPoP key from'); + } + }); +}); + +describe('resolveDPoPKeyPair', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-resolve-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns undefined when both alg and keyPath are undefined', async function () { + const result = await resolveDPoPKeyPair(undefined, undefined); + expect(result).to.be.undefined; + }); + + it('returns an ES256 key pair when alg is ES256', async function () { + const result = await resolveDPoPKeyPair('ES256', undefined); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('loads from the keyPath PEM when only keyPath is provided', async function () { + const path = join(tmpDir, 'p256-from-path.pem'); + await writeFile(path, await ecPrivatePem('P-256')); + const result = await resolveDPoPKeyPair(undefined, path); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('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 () { + 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('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; + 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'); + } + }); + + 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/lib/src/access.ts b/lib/src/access.ts index 29cb2fb66..41657063a 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'; @@ -7,6 +8,7 @@ import { type KeyAlgorithm, isKeyAlgorithm, } from '../tdf3/src/crypto/declarations.js'; +import { InvalidFileError, PermissionDeniedError, UnauthenticatedError } from './errors.js'; import { fetchKasBasePubKey, @@ -46,32 +48,54 @@ export async function fetchWrappedKey( fulfillableObligationFQNs: string[] ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); + // Pass the original AuthConfig (not just its interceptors) so the RPC layer can + // recover the provider's per-client DPoP nonce cache and keep the transport's + // nonce capture and the interceptor's retry on the same instance (RFC 9449 §9). const rpcCall = () => fetchWrappedKeysRpc( platformUrl, signedRequestToken, - { interceptors }, + auth, rewrapAdditionalContextHeader(fulfillableObligationFQNs) ); // When no AuthProvider is available, skip the legacy fallback so the real - // RPC error propagates instead of being masked by tryPromisesUntilFirstSuccess. + // RPC error propagates instead of being masked. if (!authProvider) { return await rpcCall(); } - return await tryPromisesUntilFirstSuccess( + // 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, - // 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( + async () => + (await fetchWrappedKeysLegacy( url, { signedRequestToken }, authProvider - ) as unknown as Promise + )) as unknown as RewrapResponse + ); +} + +/** + * 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 ); } @@ -182,15 +206,17 @@ 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(); } - return await tryPromisesUntilFirstSuccess(rpcCall, () => + return await tryRpcThenLegacy(rpcCall, () => fetchKeyAccessServersLegacy(platformUrl, authProvider) ); } @@ -221,10 +247,13 @@ 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 tryPromisesUntilFirstSuccess( + return await tryRpcThenLegacy( () => fetchKasPubKeyRpc(kasEndpoint, algorithm), () => fetchKasPubKeyLegacy(kasEndpoint, algorithm) ); @@ -265,23 +294,49 @@ 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:', errBrief(rpcError)); try { - return await second(); - } catch (err) { - throw err; + return await legacyCall(); + } catch (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); +} diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index f25627e29..128c004aa 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,5 +1,13 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; -import { type AuthProvider } from '../auth/auth.js'; +import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; +import { + adoptChallengeNonce, + defaultNonceCache, + DPoPNonceCache, + toOrigin, + warmNonceFromResponse, + warnNonceRetryGiveUp, +} from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -10,6 +18,68 @@ import { } from '../errors.js'; import { validateSecureUrl } from '../utils.js'; +/** fetch() options shared by the authenticated legacy requests. */ +type FetchInit = Omit; + +/** + * Signs `httpReq` via the AuthProvider, sends it, and handles a single + * DPoP-Nonce challenge (RFC 9449 §9): if a resource server rejects the request + * with a fresh `DPoP-Nonce` header, cache the nonce and retry once so + * `withCreds` can mint a proof carrying it. Non-DPoP providers and servers + * never emit a `DPoP-Nonce`, so they take the single-request path unchanged. + * + * The caller keeps ownership of status-code handling; this only owns transport + * and the nonce retry. + */ +async function fetchWithCredsAndNonceRetry( + authProvider: AuthProvider, + httpReq: HttpRequest, + init: FetchInit, + networkErrorMessage: string +): Promise { + const send = async (): Promise => { + const req = await authProvider.withCreds(httpReq); + try { + return await fetch(req.url, { + ...init, + method: req.method, + headers: req.headers, + body: req.body as BodyInit, + }); + } catch (e) { + throw new NetworkError(`${networkErrorMessage} [${req.url}]`, e); + } + }; + + // Use the provider's per-client cache so the retry proof carries the nonce + // withCreds reads back (falls back to the shared default for custom providers). + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + + // Non-absolute URLs have no origin; nonce caching is origin-keyed, so those pass through. + const origin = toOrigin(httpReq.url); + + let response = await send(); + + 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); + } + } + + // Keep the cache warm from whichever response we end on. + if (origin) { + warmNonceFromResponse(nonceCache, origin, response.headers); + } + + return response; +} + export type RewrapRequest = { signedRequestToken: string; }; @@ -33,53 +103,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 +153,29 @@ export async function fetchKeyAccessServers( let nextOffset = 0; const allServers = []; do { - const req = await authProvider.withCreds({ - url: `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`, - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - let response: Response; - try { - response = await fetch(req.url, { - method: req.method, - headers: req.headers, - body: req.body as BodyInit, + const requestUrl = `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`; + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url: requestUrl, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } as HttpRequest, + { mode: 'cors', cache: 'no-cache', credentials: 'same-origin', redirect: 'follow', referrerPolicy: 'no-referrer', - }); - } catch (e) { - throw new NetworkError(`unable to fetch kas list from [${req.url}]`, e); - } + }, + 'unable to fetch kas list from' + ); // if we get an error from the kas registry, throw an error if (!response.ok) { throw new ServiceError( - `unable to fetch kas list from [${req.url}], status: ${response.status}` + `unable to fetch kas list from [${requestUrl}], status: ${response.status}` ); } const { keyAccessServers = [], pagination = {} } = await response.json(); diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index 56c106e9d..0910833a5 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, @@ -29,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, @@ -41,7 +41,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 = { @@ -59,7 +66,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}]`); @@ -129,7 +135,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; @@ -217,7 +229,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. */ @@ -235,7 +247,7 @@ export async function fetchKasBasePubKey(kasEndpoint: string): Promise; + + /** + * 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; }; export function isAuthProvider(a?: unknown): a is AuthProvider { diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts new file mode 100644 index 000000000..a18c5808c --- /dev/null +++ b/lib/src/auth/dpop-nonce.ts @@ -0,0 +1,186 @@ +/** + * DPoP-Nonce cache manager per RFC 9449 §8. + * Caches server-issued nonces by origin for use in subsequent DPoP proofs. + */ + +export class DPoPNonceCache { + private cache = new Map(); + + /** + * Get cached nonce for an origin. + */ + get(origin: string): string | undefined { + return this.cache.get(origin); + } + + /** + * Store a nonce for an origin. + * Overwrites any existing nonce for that origin. + */ + set(origin: string, nonce: string): void { + this.cache.set(origin, nonce); + } + + /** + * Clear nonce for an origin (e.g., when it's rejected by the server). + */ + clear(origin: string): void { + this.cache.delete(origin); + } + + /** + * Clear all cached nonces. Useful for test teardown. + */ + clearAll(): void { + this.cache.clear(); + } + + /** + * Extract DPoP-Nonce from response headers (case-insensitive). + */ + static extractNonce(headers?: Headers): string | undefined { + return typeof headers?.get === 'function' ? headers.get('dpop-nonce') || undefined : undefined; + } +} + +/** + * A `DPoP-Nonce` header source: a raw `Response`'s headers or a Connect error's + * metadata (both are `Headers`, whose `get` is case-insensitive). + */ +type NonceHeaders = Headers | undefined; + +/** 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 + * `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 { + return adoptIfFresh(cache, origin, DPoPNonceCache.extractNonce(headers), sentNonce); +} + +/** + * 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 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); +} + +/** + * 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 { + 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 + * 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(); + +/** + * Record a `DPoP-Nonce` response header into `cache`, keyed by the request's origin. + * + * This works directly off the raw `Response`, so it captures the nonce even when + * a transport (e.g. Connect-RPC) does not surface response headers on its error + * type. Some resource servers (KAS) reject a proof minted without a nonce with a + * raw HTTP 401 carrying `DPoP-Nonce` + `WWW-Authenticate: DPoP error="use_dpop_nonce"` + * (RFC 9449 §9); capturing here lets the auth layer mint a nonce-bearing proof on + * retry. + */ +export function captureNonce(cache: DPoPNonceCache, requestUrl: string, headers?: Headers): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (!nonce) { + return; + } + 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/dpop.ts b/lib/src/auth/dpop.ts index 4801fee83..b72ea68f9 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -8,7 +8,11 @@ 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 }; export type JsonArray = JsonValue[]; @@ -21,11 +25,11 @@ function buf(input: string): Uint8Array { return encoder.encode(input); } -interface DPoPJwtHeaderParameters { +type DPoPJwtHeaderParameters = { alg: JWSAlgorithm; - typ: string; + typ: 'dpop+jwt'; jwk: JsonWebKey; -} +}; /** * Minimal JWT sign() implementation using CryptoService. @@ -37,11 +41,24 @@ 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; + // 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 + // 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); + } return `${input}.${b64u(signature)}`; } @@ -120,8 +137,10 @@ class UnsupportedOperationError extends Error { /** * 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/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c0a0f7971..24648aafd 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -1,10 +1,18 @@ -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; export type { Interceptor } from '@connectrpc/connect'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js'; import DPoP from './dpop.js'; import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; +import { + adoptChallengeNonceFromConnectError, + DPoPNonceCache, + defaultNonceCache, + toOrigin, + warmNonceFromResponse, + warnNonceRetryGiveUp, +} from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -22,6 +30,12 @@ export type DPoPInterceptorOptions = { dpopKeys?: KeyPair | Promise; /** CryptoService for signing. Defaults to DefaultCryptoService. */ cryptoService?: CryptoService; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8). Defaults to the shared + * {@link defaultNonceCache}; pass the same instance to `PlatformClient` for + * strict per-client isolation on the interceptor-only path. + */ + nonceCache?: DPoPNonceCache; }; /** @@ -78,6 +92,7 @@ export function authTokenInterceptor(tokenProvider: TokenProvider): Interceptor */ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPInterceptor { const cryptoService = options.cryptoService ?? DefaultCryptoService; + const nonceCache = options.nonceCache ?? defaultNonceCache; const dpopKeysPromise: Promise = options.dpopKeys ? Promise.resolve(options.dpopKeys) : cryptoService.generateSigningKeyPair(); @@ -86,19 +101,69 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const [token, keys] = await Promise.all([options.tokenProvider(), dpopKeysPromise]); const url = new URL(req.url); - const httpUri = `${url.origin}${url.pathname}`; + const origin = url.origin; + const httpUri = `${origin}${url.pathname}`; + + // Check for cached nonce + const cachedNonce = nonceCache.get(origin); // Generate DPoP proof JWT for this request - const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST'); + const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST', cachedNonce, token); // Export public key PEM for X-VirtruPubKey header const publicKeyPem = await cryptoService.exportPublicKeyPem(keys.publicKey); - req.header.set('Authorization', `Bearer ${token}`); + req.header.set('Authorization', `DPoP ${token}`); req.header.set('DPoP', dpopProof); + // TODO: rename to X-OpenTDF-PubKey (coordinate with platform Keycloak mapper; see oidc.ts doPost) req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem)); - return next(req); + // Call next and handle DPoP-Nonce retry + try { + const response = await next(req); + warmNonceFromResponse(nonceCache, origin, response.header); + return response; + } catch (err) { + // A Connect Unauthenticated error may carry a DPoP-Nonce challenge. The + // transport's fetch wrapper records the nonce from the raw 401 response + // into the cache (Connect errors don't reliably surface response headers); + // error metadata is a fallback for transports that do expose it. + if (err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = adoptChallengeNonceFromConnectError( + nonceCache, + origin, + err.metadata, + cachedNonce + ); + if (serverNonce) { + // Regenerate proof with the server nonce and retry once. + const retryDpopProof = await DPoP( + keys, + cryptoService, + httpUri, + 'POST', + serverNonce, + token + ); + req.header.set('DPoP', retryDpopProof); + + const retryResponse = await next(req); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); + return retryResponse; + } + // 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 + throw err; + } }; // Attach dpopKeys to the interceptor function @@ -121,38 +186,95 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI */ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor { return (next) => async (req) => { - const url = new URL(req.url); - const pathOnly = url.pathname; - // Signs only the path of the url in the request - let token; - try { - token = await authProvider.withCreds({ - url: pathOnly, - method: 'POST', - // Start with any headers Connect already has - headers: { - ...Object.fromEntries(req.header.entries()), - 'Content-Type': 'application/json', - }, + // Pass the full request URL to withCreds. DPoP-enabled providers need the + // absolute URL to compute the proof's `htu` claim and the origin for the + // nonce cache; `new URL()` on a bare path throws "Invalid URL". Non-DPoP + // providers ignore the URL (they only add a Bearer header), so this stays + // backwards-compatible with legacy AuthProviders. + + // Re-sign the request via withCreds and apply the resulting headers. Called + // once normally, and again on a DPoP-Nonce challenge so the provider mints a + // fresh proof carrying the server-issued nonce (read from nonceCache). + const sign = async (): Promise => { + let token; + try { + token = await authProvider.withCreds({ + url: req.url, + method: 'POST', + // Start with any headers Connect already has + headers: { + ...Object.fromEntries(req.header.entries()), + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { + throw new Error( + 'PlatformClient: DPoP key binding is not complete. ' + + 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + + '`await client.ready` before constructing PlatformClient. ' + + `Original error: ${msg}` + ); + } + throw err; + } + + Object.entries(token.headers).forEach(([key, value]) => { + req.header.set(key, value); }); + }; + + // Share the provider's per-client cache so the nonce withCreds embeds and the + // one we read back on a 401 are the same instance (falls back to the shared + // default for custom providers that don't expose one). + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + + // 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) + // so a 401 can tell us whether the server handed back a *new* one to retry. + const sentNonce = origin ? nonceCache.get(origin) : undefined; + + try { + const response = await next(req); + // Keep the nonce cache warm from successful responses (RFC 9449 §8). + if (origin) { + warmNonceFromResponse(nonceCache, origin, response.header); + } + return response; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { - throw new Error( - 'PlatformClient: DPoP key binding is not complete. ' + - 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + - '`await client.ready` before constructing PlatformClient. ' + - `Original error: ${msg}` + // A DPoP resource server rejects a proof minted without (or with a stale) + // nonce by returning Unauthenticated with a fresh `DPoP-Nonce`. The + // transport's fetch wrapper captures that header from the raw response into + // the cache (Connect errors don't reliably surface response headers); we + // also fall back to error metadata. Re-sign so withCreds embeds the nonce + // and retry once (RFC 9449 §9). Non-DPoP providers/servers never emit a + // DPoP-Nonce, so this is a no-op for them. + if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = adoptChallengeNonceFromConnectError( + nonceCache, + origin, + err.metadata, + sentNonce + ); + if (serverNonce) { + await sign(); + const retryResponse = await next(req); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); + return retryResponse; + } + warnNonceRetryGiveUp( + 'auth interceptor', + origin, + nonceCache.get(origin) ?? DPoPNonceCache.extractNonce(err.metadata), + sentNonce ); } throw err; } - - Object.entries(token.headers).forEach(([key, value]) => { - req.header.set(key, value); - }); - - return await next(req); }; } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 8d3629bae..d14a57b2b 100644 --- a/lib/src/auth/oidc-clientcredentials-provider.ts +++ b/lib/src/auth/oidc-clientcredentials-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ClientSecretCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -14,6 +15,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -29,6 +32,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -41,4 +46,9 @@ export class OIDCClientCredentialsProvider implements AuthProvider { async withCreds(httpReq: HttpRequest): Promise { return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index 2a7266882..0a706e888 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ExternalJwtCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -15,6 +16,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -30,6 +33,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -50,4 +55,9 @@ export class OIDCExternalJwtProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index 9f7bac2d9..42391ebb9 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type RefreshTokenCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -29,6 +30,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -44,6 +47,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); @@ -64,4 +69,9 @@ export class OIDCRefreshTokenProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index b842890e6..8ea216b6e 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,6 +5,13 @@ 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, + defaultNonceCache, + DPoPNonceCache, + warmNonceFromResponse, + warnNonceRetryGiveUp, +} from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -19,7 +26,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; }; @@ -109,7 +121,21 @@ export class AccessToken { cryptoService: CryptoService; - constructor(cfg: OIDCCredentials, cryptoService: CryptoService, request?: typeof fetch) { + /** + * 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; + + constructor( + cfg: OIDCCredentials, + cryptoService: CryptoService, + request?: typeof fetch, + nonceCache: DPoPNonceCache = defaultNonceCache + ) { if (!cfg.clientId) { throw new ConfigurationError( 'A Keycloak client identifier is currently required for all auth mechanisms' @@ -137,6 +163,28 @@ export class AccessToken { this.userInfoEndpoint = cfg.oidcUserInfoEndpoint || `${this.baseUrl}/protocol/openid-connect/userinfo`; this.signingKey = cfg.signingKey; + 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; } /** @@ -145,21 +193,64 @@ 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) { + // 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 (signingKey) { + cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn( - this.signingKey, + signingKey, this.cryptoService, this.userInfoEndpoint, - 'POST' + 'GET', + cachedNonce, + accessToken ); + headers.Authorization = `DPoP ${accessToken}`; + } else { + headers.Authorization = `Bearer ${accessToken}`; } - const response = await (this.request || fetch)(this.userInfoEndpoint, { + let response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); + + // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. + if (signingKey && !response.ok) { + const challenge = DPoPNonceCache.extractNonce(response.headers); + const challengeNonce = adoptChallengeNonce( + this.nonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { + headers.DPoP = await dpopFn( + signingKey, + this.cryptoService, + this.userInfoEndpoint, + 'GET', + challengeNonce, + accessToken + ); + response = await (this.request || fetch)(this.userInfoEndpoint, { + headers, + }); + } else if (challenge) { + warnNonceRetryGiveUp('userinfo', origin, challenge, cachedNonce); + } + } + + // Update nonce cache from final response + if (this.config.dpopEnabled) { + warmNonceFromResponse(this.nonceCache, origin, response.headers); + } + if (!response.ok) { console.error(await response.text()); throw new TdfError( @@ -171,27 +262,66 @@ export class AccessToken { } async doPost(url: string, o: Record) { + const origin = new URL(url).origin; const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }; - // add DPoP headers if configured - if (this.config.dpopEnabled) { - if (!this.signingKey) { - throw new ConfigurationError('No signature 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 (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); - headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST'); + + cachedNonce = this.nonceCache.get(origin); + headers.DPoP = await dpopFn(signingKey, this.cryptoService, url, 'POST', cachedNonce); } - return (this.request || fetch)(url, { + + const response = await (this.request || fetch)(url, { method: 'POST', headers, body: qstringify(o), }); + + // Handle DPoP-Nonce challenge. RFC 9449 §8: authorization servers return + // HTTP 400 with error=use_dpop_nonce; §9: resource servers return 401. + // Trigger on any non-OK response that carries a fresh DPoP-Nonce header. + if (signingKey && !response.ok) { + const challenge = DPoPNonceCache.extractNonce(response.headers); + const challengeNonce = adoptChallengeNonce( + this.nonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { + // Regenerate DPoP proof with the server-provided nonce and retry. + headers.DPoP = await dpopFn(signingKey, this.cryptoService, url, 'POST', challengeNonce); + + const retryResponse = await (this.request || fetch)(url, { + method: 'POST', + headers, + body: qstringify(o), + }); + + warmNonceFromResponse(this.nonceCache, origin, retryResponse.headers); + return retryResponse; + } else if (challenge) { + warnNonceRetryGiveUp('token endpoint', origin, challenge, cachedNonce); + } + } + + // Update nonce cache from successful responses + if (this.config.dpopEnabled && response.ok) { + warmNonceFromResponse(this.nonceCache, origin, response.headers); + } + + return response; } async accessTokenLookup(cfg: OIDCCredentials) { @@ -279,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, @@ -292,10 +424,15 @@ 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, so rotating the + // signing key invalidates any cached token. Non-DPoP tokens are key- + // independent and can stay cached across a key change. + if (this.config.dpopEnabled) { + delete this.data; + delete this.cachedExpiry; + delete this.inFlight; + } } /** @@ -326,23 +463,28 @@ 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 + // fragment. Resource servers (and the mock) recompute and compare it, so + // a proof carrying the query string is rejected. + const htu = `${origin}${url.pathname}`; + const cachedNonce = this.nonceCache.get(origin); const dpopToken = await dpopFn( - this.signingKey, + signingKey, this.cryptoService, - httpReq.url, + htu, httpReq.method, - /* nonce */ undefined, + cachedNonce, accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}` }); } diff --git a/lib/src/auth/providers.ts b/lib/src/auth/providers.ts index d5893f609..b36d3134a 100644 --- a/lib/src/auth/providers.ts +++ b/lib/src/auth/providers.ts @@ -42,6 +42,8 @@ export const clientSecretAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -74,6 +76,8 @@ export const externalAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -104,6 +108,8 @@ export const refreshAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); diff --git a/lib/src/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, diff --git a/lib/src/platform.ts b/lib/src/platform.ts index fdff544eb..afab6f8f5 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,6 +5,29 @@ export * as platformConnect from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; import type { AuthProvider } from '../tdf3/index.js'; import { authProviderInterceptor } from './auth/interceptors.js'; +import { captureNonce, DPoPNonceCache, defaultNonceCache } from './auth/dpop-nonce.js'; + +/** + * Build a `fetch` wrapper that records any `DPoP-Nonce` response header into + * `nonceCache` before handing the response back to the Connect transport. The + * Connect error type does not reliably surface response headers, so capturing at + * the transport layer is what lets the DPoP auth interceptors mint a + * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. `nonceCache` + * must be the same instance the auth interceptor reads. + */ +function makeNonceCapturingFetch(nonceCache: DPoPNonceCache): typeof globalThis.fetch { + return async (input, init) => { + const response = await fetch(input, init); + const requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + captureNonce(nonceCache, requestUrl, response.headers); + return response; + }; +} import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; @@ -54,6 +77,13 @@ export interface PlatformClientOptions { interceptors?: Interceptor[]; /** Base URL of the platform API. */ platformUrl: string; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8) for the transport's nonce capture. + * When an `authProvider` is supplied its own `nonceCache` is used; otherwise + * pass the same instance given to `authTokenDPoPInterceptor` for the + * interceptor-only path. Defaults to the shared {@link defaultNonceCache}. + */ + nonceCache?: DPoPNonceCache; } /** @@ -96,9 +126,14 @@ export class PlatformClient { interceptors.push(...options.interceptors); } + // Capture nonces into the same cache the auth interceptor reads: the auth + // provider's own cache when present, else the caller-supplied/default one. + const nonceCache = options.authProvider?.nonceCache ?? options.nonceCache ?? defaultNonceCache; + const transport = createConnectTransport({ baseUrl: options.platformUrl, interceptors, + fetch: makeNonceCapturingFetch(nonceCache), }); this.v1 = { diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c3b824f9d..ea57f7bcb 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -39,19 +39,43 @@ 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 (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; } + 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 => { @@ -94,28 +118,29 @@ 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; } - // 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) + // 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) { @@ -131,40 +156,51 @@ function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgor 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/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 5ae08fd20..1c6c04e9f 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, @@ -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,19 @@ 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 - ); + 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 + // 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); + } } // Return compact JWT @@ -232,12 +241,15 @@ 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 - ); + 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. + const verifySignature = alg.startsWith('ES') ? ieeeP1363ToDer(signature, alg) : signature; + valid = await cryptoService.verify(signingInputBytes, verifySignature, publicKey, alg); } if (!valid) { diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 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/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts new file mode 100644 index 000000000..833380c3e --- /dev/null +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -0,0 +1,138 @@ +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'; + +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(); + }); + + // 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( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + // No fetch override: uses global fetch (Node 18+) against the real server + ); + + // doPost sends the initial request (no nonce), gets 401 + DPoP-Nonce, + // then automatically retries with the nonce and receives 200. + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + const body = (await response.json()) as { access_token: string }; + expect(body.access_token).to.equal('test-dpop-token'); + + // Cache must be populated with the server's nonce after the round-trip + expect(accessToken.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('uses cached nonce on the first request after a prior successful challenge', async () => { + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + ); + + // Pre-seed this client's cache as if a prior request already populated it + accessToken.nonceCache.set(SERVER_ORIGIN, SERVER_NONCE); + + // With the correct nonce already cached, the first request should succeed directly (no retry). + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + }); + + it('initial token fetch via clientSecretAuthProvider sends DPoP proof when configured with a signing key', async () => { + // Mirrors the CLI path: when --dpop is set, the provider is constructed + // with dpopEnabled + signingKey so the very first POST /token carries a + // DPoP header (RFC 9449 §5) and survives the nonce challenge. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('test-dpop-token'); + expect(provider.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('omits DPoP header when no signing key is configured, even after updateClientPublicKey binds one for body signing', async () => { + // Mirrors the legacy/non-DPoP CLI path: no --dpop flag, but TDF3Client. + // createSessionKeys still calls updateClientPublicKey to bind a key used + // for TDF body signing. The token POST must NOT include a DPoP header, + // otherwise Keycloak issues a DPoP-bound token that the platform's + // Connect-RPC interceptors then present as plain Bearer → 401. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + // No dpopEnabled / signingKey — non-DPoP flow. + }); + await provider.updateClientPublicKey(keyPair); + + // 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/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts new file mode 100644 index 000000000..7ace6272d --- /dev/null +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -0,0 +1,250 @@ +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 }; +} + +async function rsaKeyPair(): 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]); + 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); + }); + } +}); + +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 + // 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 + * SDK emits in real proofs. + */ +async function proofHeaderJwkFor(kp: KeyPair, alg: 'ES256' | 'ES384' | 'ES512'): Promise { + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + const header = jose.decodeProtectedHeader(proof); + void alg; // alg unused; kept in signature for caller clarity + return header.jwk; +} + +/** + * Flip exactly one bit of the base64url-decoded signature segment. + * Re-encodes back into the JWT compact form. + */ +function flipOneBitInSignatureSegment(jwt: string): string { + const [h, p, s] = jwt.split('.'); + const sig = jose.base64url.decode(s); + sig[0] ^= 0x01; + return `${h}.${p}.${jose.base64url.encode(sig)}`; +} diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts new file mode 100644 index 000000000..03399316d --- /dev/null +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -0,0 +1,95 @@ +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'; + +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(); + }); + + // 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'; + + // A DPoP-enabled provider makes every authenticated request (token + rewrap) + // present `Authorization: DPoP` and a proof, which is what activates the RS + // gate on the mock KAS rewrap endpoint. + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: dpopKeyPair, + }); + + const client = new Client.Client({ + kasEndpoint: SERVER_ORIGIN, + platformUrl: SERVER_ORIGIN, + allowedKases: [SERVER_ORIGIN], + dpopKeys: Mocks.entityKeyPair(), + clientId: 'test-client', + authProvider, + }); + + const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + offline: true, + scope, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { type: 'stream', location: encryptedStream.stream }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + + // A successful decrypt proves the rewrap survived the challenge; the cached + // RS nonce proves a challenge actually happened and the retry adopted it. + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/mocha/dpop-rpc-nonce.spec.ts b/lib/tests/mocha/dpop-rpc-nonce.spec.ts new file mode 100644 index 000000000..d42ac7767 --- /dev/null +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -0,0 +1,62 @@ +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'; + +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(); + }); + + // 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({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + // No RS nonce is cached for this origin yet: the first proof carries the + // wrong (or no) nonce, the server challenges with the RS nonce, and the + // interceptor must retry once for this call to resolve. + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + + // The consumed challenge leaves the RS nonce cached for the origin, proving + // a challenge happened and the retry adopted it. + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 9677746cc..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'); } }); }); @@ -420,6 +429,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); diff --git a/lib/tests/mocha/reqsignature-jws.spec.ts b/lib/tests/mocha/reqsignature-jws.spec.ts new file mode 100644 index 000000000..5d9c10b56 --- /dev/null +++ b/lib/tests/mocha/reqsignature-jws.spec.ts @@ -0,0 +1,185 @@ +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}-----`; +} + +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 }> { + 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 }; +} + +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); + + 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'); + }); + } + + 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, { + 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 new file mode 100644 index 000000000..d655a1f63 --- /dev/null +++ b/lib/tests/mocha/unit/crypto/der-signature.spec.ts @@ -0,0 +1,125 @@ +import { expect } from 'chai'; + +import { derToIeeeP1363, ieeeP1363ToDer } 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/ + ); + }); + }); +}); + +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` + ); + }); + } +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index b18308db2..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'; @@ -64,6 +64,284 @@ 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}`; +} + +/** + * 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++) { @@ -113,9 +391,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 { @@ -242,6 +522,13 @@ const kas: RequestListener = async (req, res) => { res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; } + + // Strict RFC 9449 DPoP resource-server check. Only triggers when the + // request actually carries `Authorization: DPoP `; non-DPoP + // (Bearer or unauthenticated) callers pass through unchanged so the + // many non-DPoP rewrap tests keep working. + if (!(await enforceRsDpop(req, res))) return; + const body = await getBody(req); const bodyText = new TextDecoder().decode(body); const { signedRequestToken } = JSON.parse(bodyText); @@ -523,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; @@ -573,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( @@ -611,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' })); @@ -622,6 +916,37 @@ const kas: RequestListener = async (req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'ok' })); return; + } else if (url.pathname === '/protocol/openid-connect/token') { + // Mock Keycloak token endpoint with strict RFC 9449 DPoP verification. + // First request gets a nonce challenge (400 + use_dpop_nonce + DPoP-Nonce header + // per RFC 9449 §8 — note: AS uses 400, RS uses 401). The retry must include + // a proof whose `nonce` claim matches. + const dpopHeader = req.headers['dpop'] as string | undefined; + const htu = requestHtu(req); + const check = await verifyDpopProof(dpopHeader, { + htm: 'POST', + htu, + requireNonce: DPOP_TOKEN_NONCE, + }); + if (!check.ok) { + const status = + check.error === 'use_dpop_nonce' ? 400 : check.status > 0 ? check.status : 400; + const headers: Record = { 'Content-Type': 'application/json' }; + if (check.challengeNonce) headers['DPoP-Nonce'] = check.challengeNonce; + res.writeHead(status, headers); + res.end(JSON.stringify({ error: check.error, error_description: check.error_description })); + return; + } + + // Mint an opaque access token; bind it to the DPoP proof's JWK thumbprint + // so the rewrap handler (RS-side, below) can enforce cnf.jkt binding. + const accessToken = 'test-dpop-token'; + dpopBoundJkts.set(accessToken, check.jkt); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 })); + return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); res.statusCode = 404; diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index abdba1c86..00bd25773 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -16,6 +16,7 @@ import { UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -230,6 +231,105 @@ describe('access-fetch.js', () => { }); }); + describe('DPoP-Nonce challenge retry (RFC 9449 §9)', () => { + const platformUrl = 'https://platform.example.com'; + const origin = 'https://platform.example.com'; + const challengeNonce = 'server-issued-nonce-123'; + + // A response carrying real Headers so DPoPNonceCache.extractNonce works. + // @ts-expect-error test helper, loose body typing + const responseWithNonce = (body, ok, status, nonce?: string) => + Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + headers: new Headers(nonce ? { 'DPoP-Nonce': nonce } : {}), + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + } as Response); + + // withCreds that signs each request with whatever nonce is currently cached + // for the origin, recording it so the test can confirm the retry saw the + // server challenge. + const noncesSeen: (string | undefined)[] = []; + // The provider owns its per-client cache; the retry path reads it back. + const nonceCache = new DPoPNonceCache(); + const dpopAuthProvider: AuthProvider = { + nonceCache, + withCreds: sinon.stub().callsFake(async (req) => { + noncesSeen.push(nonceCache.get(origin)); + return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; + }), + } as unknown as AuthProvider; + + beforeEach(() => { + noncesSeen.length = 0; + nonceCache.clear(origin); + // @ts-expect-error stub + dpopAuthProvider.withCreds.resetHistory(); + }); + + afterEach(() => { + nonceCache.clear(origin); + }); + + it('retries once with the server nonce and succeeds', async () => { + fetchStub + .onCall(0) + .returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + fetchStub + .onCall(1) + .returns( + responseWithNonce( + { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, + true, + 200 + ) + ); + + const result = await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + + expect(fetchStub.calledTwice).to.be.true; + // First proof had no nonce; the retry proof was minted after caching it. + expect(noncesSeen).to.deep.equal([undefined, challengeNonce]); + expect(result.origins).to.include('https://kas1.example.com'); + }); + + it('does not retry when the 401 carries no DPoP-Nonce', async () => { + fetchStub.returns(responseWithNonce('nope', false, 401)); + + let caught: unknown; + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + 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; + }); + + 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)); + + let caught: unknown; + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + caught = e; + } + expect(caught).to.be.instanceOf(ServiceError); + expect((caught as ServiceError).message).to.include('status: 401'); + 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..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', () => { @@ -427,5 +449,87 @@ describe('AccessToken', () => { expect(e.message).to.match(/required when DPoP is enabled/); } }); + + 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' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + signingKey, + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + const result = await accessToken.withCreds({ + url: 'https://platform.invalid/key-access-servers?pagination.offset=0', + method: 'GET', + headers: {}, + }); + + const decodeJwtPayload = (jwt: string): Record => { + let b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4 !== 0) { + b64 += '='; + } + return JSON.parse(atob(b64)); + }; + const payload = decodeJwtPayload(result.headers.DPoP); + expect(payload.htu).to.equal('https://platform.invalid/key-access-servers'); + expect(payload.htm).to.equal('GET'); + }); }); }); diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts new file mode 100644 index 000000000..8598f999a --- /dev/null +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -0,0 +1,207 @@ +import { expect } from '@esm-bundle/chai'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { stub } from 'sinon'; +import { AccessToken } from '../../../src/auth/oidc.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'; + +/** 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(); + }); + + // AccessToken defaults to the shared defaultNonceCache; clear between tests. + afterEach(() => { + defaultNonceCache.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(accessToken.nonceCache.get(ORIGIN)).to.equal(NONCE); + + // Second request's DPoP proof must include the nonce + const secondInit = fetchStub.secondCall.args[1] as RequestInit; + const secondHeaders = secondInit.headers as Record; + const retryPayload = decodeJwtPayload(secondHeaders['DPoP']); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + const fetchStub = stub().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + // Pre-seed this client's cache with the same nonce the server will return + accessToken.nonceCache.set(ORIGIN, NONCE); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + // No retry — same nonce means we'd loop; return the 401 to the caller + expect(fetchStub.callCount).to.equal(1); + expect(result.status).to.equal(401); + }); +}); + +// ── authTokenDPoPInterceptor nonce retry ──────────────────────────────────── + +describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const REQUEST_URL = `${ORIGIN}/kas.AccessService/Rewrap`; + const NONCE = 'interceptor-nonce-abc'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + function makeInterceptor(nonceCache: DPoPNonceCache) { + return authTokenDPoPInterceptor({ + tokenProvider: async () => 'dummy-access-token', + dpopKeys: Promise.resolve(keyPair), + nonceCache, + }); + } + + function makeMockReq() { + return { header: new Headers(), url: REQUEST_URL } as Parameters< + ReturnType> + >[0]; + } + + it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { + const mockNext = stub(); + // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) + ); + // Second call: success + mockNext.onSecondCall().resolves({ header: { get: () => null } }); + + const nonceCache = new DPoPNonceCache(); + const interceptor = makeInterceptor(nonceCache); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(nonceCache.get(ORIGIN)).to.equal(NONCE); + + // Retry request must have nonce in its DPoP proof + const retryReq = mockNext.secondCall.firstArg as { header: Headers }; + const retryDpopJwt = retryReq.header.get('DPoP')!; + const retryPayload = decodeJwtPayload(retryDpopJwt); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, NONCE); + + const mockNext = stub().callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) + ); + + const interceptor = makeInterceptor(nonceCache); + try { + await interceptor(mockNext as Parameters[0])(makeMockReq()); + expect.fail('should have thrown'); + } catch { + // Expected: interceptor re-throws when nonce unchanged + } + + expect(mockNext.callCount).to.equal(1); + }); + + 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); + }); +}); 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..69f6b8883 --- /dev/null +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -0,0 +1,49 @@ +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'; + +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(); + }); + + // 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({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 25ba358cb..f8fcb8a08 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -1,5 +1,5 @@ import { expect } from '@esm-bundle/chai'; -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; import type { AuthProvider } from '../../src/auth/auth.js'; import { HttpRequest, withHeaders } from '../../src/auth/auth.js'; import { @@ -10,6 +10,7 @@ import { resolveAuthConfig, isInterceptorConfig, } from '../../src/auth/interceptors.js'; +import { DPoPNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -61,7 +62,7 @@ describe('authTokenDPoPInterceptor', () => { const headers = await captureHeaders(interceptor); - expect(headers.get('Authorization')).to.equal('Bearer dpop-token'); + expect(headers.get('Authorization')).to.equal('DPoP dpop-token'); expect(headers.get('DPoP')).to.be.a('string'); expect(headers.get('DPoP')!.split('.')).to.have.length(3); // JWT format expect(headers.get('X-VirtruPubKey')).to.be.a('string'); @@ -142,6 +143,108 @@ describe('authProviderInterceptor', () => { expect(headers.get('X-Custom')).to.equal('custom-value'); }); + it('passes the full request URL to withCreds (not just the path)', async () => { + // Regression: a DPoP-enabled provider computes the proof `htu` and nonce + // origin via `new URL(req.url)`, which throws on a bare path. The + // interceptor must hand withCreds the absolute URL. + let seenUrl: string | undefined; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (req: HttpRequest) => { + seenUrl = req.url; + // Mimic a DPoP provider that parses the URL; a bare path throws here. + new URL(req.url); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + await captureHeaders(interceptor, 'https://platform.example.com/policy.attributes/Get'); + + expect(seenUrl).to.equal('https://platform.example.com/policy.attributes/Get'); + }); + + it('retries once with the server-issued DPoP-Nonce on an Unauthenticated challenge', async () => { + const origin = 'https://platform.example.com'; + const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; + const nonceCache = new DPoPNonceCache(); + + // Provider records the nonce it sees so we can assert the retry carried it. + const seenNonces: (string | undefined)[] = []; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + nonceCache, + withCreds: async (req: HttpRequest) => { + seenNonces.push(nonceCache.get(new URL(req.url).origin)); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + let attempts = 0; + const mockNext = async () => { + attempts++; + if (attempts === 1) { + // First attempt: server issues a nonce challenge. + throw new ConnectError('unauthenticated', Code.Unauthenticated, { + 'dpop-nonce': 'server-nonce-xyz', + }); + } + return { header: new Headers(), message: {} } as Awaited>>; + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + const mockReq = { header: new Headers(), url } as Parameters>[0]; + await interceptor(mockNext)(mockReq); + + expect(attempts).to.equal(2); + expect(seenNonces).to.deep.equal([undefined, 'server-nonce-xyz']); + expect(nonceCache.get(origin)).to.equal('server-nonce-xyz'); + }); + + it('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 () => {}, diff --git a/scripts/config-demo.sh b/scripts/config-demo.sh new file mode 100755 index 000000000..1d5d235aa --- /dev/null +++ b/scripts/config-demo.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Configure Keycloak for the DPoP browser demo and start the dev server. +# +# Mirrors .github/workflows/roundtrip/config-demo-idp.sh but uses the admin +# REST API directly (no kcadm download needed) and targets an otdf-local +# instance rather than the CI docker-compose stack. +# +# Prerequisites: +# - Keycloak is running (docker, via otdf-local): +# uv run otdf-local --instance DSPX-3397 up --services docker +# - Local lib is built and installed in web-app: +# ./scripts/rebuild-local-lib.sh +# +# Usage: +# ./scripts/config-demo.sh # uses defaults +# PLATFORM_URL=http://localhost:9080 ./scripts/config-demo.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +KC_URL="${KC_URL:-http://localhost:8888}" +KC_REALM="${KC_REALM:-opentdf}" +KC_ADMIN_USER="${KC_ADMIN_USER:-admin}" +KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-changeme}" +PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" +APP_URL="${APP_URL:-http://localhost:65432}" + +echo "Configuring Keycloak at ${KC_URL}/auth/realms/${KC_REALM}" + +KC_ADMIN_TOKEN=$(curl -sf "${KC_URL}/auth/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli&username=${KC_ADMIN_USER}&password=${KC_ADMIN_PASSWORD}&grant_type=password" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +_kc() { curl -sf -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" "$@"; } + +# Create browsertest public client with DPoP binding enforced. +# Audience maps to PLATFORM_URL so the platform's auth.audience check passes. +if _kc "${KC_URL}/auth/admin/realms/${KC_REALM}/clients?clientId=browsertest" \ + | python3 -c "import sys,json; exit(0 if json.load(sys.stdin) else 1)" 2>/dev/null; then + echo "browsertest client already exists, skipping creation" +else + _kc -X POST "${KC_URL}/auth/admin/realms/${KC_REALM}/clients" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"browsertest\", + \"enabled\": true, + \"redirectUris\": [\"${APP_URL}/\"], + \"consentRequired\": false, + \"standardFlowEnabled\": true, + \"directAccessGrantsEnabled\": true, + \"serviceAccountsEnabled\": false, + \"publicClient\": true, + \"protocol\": \"openid-connect\", + \"attributes\": {\"dpop.bound.access.tokens\": \"true\"}, + \"protocolMappers\": [{ + \"name\": \"aud\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-audience-mapper\", + \"consentRequired\": false, + \"config\": { + \"access.token.claim\": \"true\", + \"included.custom.audience\": \"${PLATFORM_URL}\" + } + }] + }" + echo "Created browsertest client (DPoP-bound, audience=${PLATFORM_URL})" +fi + +# Create demo user (user1 / testuser123) if not already present. +if _kc "${KC_URL}/auth/admin/realms/${KC_REALM}/users?username=user1" \ + | python3 -c "import sys,json; exit(0 if json.load(sys.stdin) else 1)" 2>/dev/null; then + echo "user1 already exists, skipping creation" +else + USER_ID=$(_kc -X POST "${KC_URL}/auth/admin/realms/${KC_REALM}/users" \ + -H "Content-Type: application/json" \ + -D - \ + -d '{"username":"user1","enabled":true,"firstName":"Alice","lastName":"User"}' \ + | grep -i "^location:" | grep -o '[^/]*$' | tr -d '\r') + _kc -X PUT "${KC_URL}/auth/admin/realms/${KC_REALM}/users/${USER_ID}/reset-password" \ + -H "Content-Type: application/json" \ + -d '{"type":"password","value":"testuser123","temporary":false}' + echo "Created user1 (password: testuser123)" +fi + +echo "" +echo "Keycloak configured. Starting dev server..." +echo "" +exec "${REPO_ROOT}/scripts/dev-local.sh" diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh new file mode 100755 index 000000000..da722774b --- /dev/null +++ b/scripts/dev-local.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Start the web-app dev server pointed at a local otdf-local instance. +# +# Prerequisites: +# - otdf-local backend is running (tests/ dir): +# uv run otdf-local --instance DSPX-3397 up --services platform,kas +# - Local lib is built and installed: +# ./scripts/rebuild-local-lib.sh +# +# DPoP is active by default (the lib sends DPoP tokens on every request). +# To see enforcement (platform rejects non-DPoP tokens), set enforceDPoP: true +# in tests/instances/DSPX-3397/opentdf.yaml, then restart platform: +# uv run otdf-local --instance DSPX-3397 up --services platform --no-provision +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +KC_REALM="${KC_REALM:-opentdf}" +KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" +APP_URL="${APP_URL:-http://localhost:65432}" + +# Use the app origin for the OIDC host so browser requests go through +# Vite's /auth proxy instead of hitting Keycloak on port 8888 directly (CORS). +export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${APP_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${APP_URL}/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" + +echo "Starting web-app dev server with:" +echo " OIDC: ${APP_URL}/auth/realms/${KC_REALM} (proxied) client=${KC_CLIENT_ID}" +echo " KAS: ${APP_URL}/kas (proxied)" +echo "" + +cd "$REPO_ROOT/web-app" +npm run dev diff --git a/scripts/rebuild-local-lib.sh b/scripts/rebuild-local-lib.sh new file mode 100755 index 000000000..e850d25c3 --- /dev/null +++ b/scripts/rebuild-local-lib.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the local lib and install it into the web-app. +# Run this after making changes to lib/ before starting the dev server. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$REPO_ROOT/lib" +npm ci +npm pack + +cd "$REPO_ROOT/web-app" +npm remove @opentdf/sdk +npm ci +npm install ../lib/opentdf-sdk-*.tgz + +echo "Done. Run scripts/dev-local.sh to start the dev server." diff --git a/web-app/src/session.ts b/web-app/src/session.ts index 2bcbe1256..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,14 +535,15 @@ 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? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } } diff --git a/web-app/tests/README.md b/web-app/tests/README.md index 46a5e9e9a..dec7da387 100644 --- a/web-app/tests/README.md +++ b/web-app/tests/README.md @@ -3,6 +3,10 @@ This folder contains playwright, e2e tests for web-app, running against a local or remote backend in proxy mode. +DPoP nonce challenges are enabled for all test clients (`browsertest` and `testclient`), +so the e2e tests and CLI roundtrip tests exercise the full DPoP challenge/retry path. +Keycloak 26.2 is required (configured in `.github/workflows/roundtrip/docker-compose.yaml`). + ## Bring up the platform behind local (vite dev server) proxy Bring up test backend services (identity provider, database, etc.): diff --git a/web-app/tests/tests/dpop-headers.spec.ts b/web-app/tests/tests/dpop-headers.spec.ts new file mode 100644 index 000000000..ec7f3d88a --- /dev/null +++ b/web-app/tests/tests/dpop-headers.spec.ts @@ -0,0 +1,71 @@ +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'], + }); + } + }); + + 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; + + // 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(); + } + } + + // 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'); + + // 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(); +});