diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0f0a56998..0749f4c8b 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(); @@ -394,8 +399,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 +524,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 +587,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 +602,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 +629,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 +662,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 +672,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'); + }); +});