diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index 4801fee83..a16190504 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -8,7 +8,10 @@ import type { AsymmetricSigningAlgorithm, KeyAlgorithm, } from '../../tdf3/src/crypto/declarations.js'; -import { isRsaKeyAlgorithm } from '../../tdf3/src/crypto/declarations.js'; +import { + isAsymmetricSigningAlgorithm, + isRsaKeyAlgorithm, +} from '../../tdf3/src/crypto/declarations.js'; export type JsonObject = { [Key in string]?: JsonValue }; export type JsonArray = JsonValue[]; @@ -21,11 +24,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 +40,11 @@ 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; + if (!isAsymmetricSigningAlgorithm(alg)) { + throw new UnsupportedOperationError(`unsupported DPoP alg: ${alg}`); + } + const signature = await cryptoService.sign(buf(input), privateKey, alg); return `${input}.${b64u(signature)}`; } @@ -120,8 +123,10 @@ class UnsupportedOperationError extends Error { /** * Determines a supported JWS `alg` identifier from PublicKeyInfo algorithm string. + * Returns an AsymmetricSigningAlgorithm (the subset CryptoService can sign with); + * notably, it does not support PS256/EdDSA members of JWSAlgorithm. */ -function determineJWSAlgorithmFromKeyInfo(algorithm: KeyAlgorithm): JWSAlgorithm { +function determineJWSAlgorithmFromKeyInfo(algorithm: KeyAlgorithm): AsymmetricSigningAlgorithm { if (isRsaKeyAlgorithm(algorithm)) { return 'RS256'; } diff --git a/lib/tdf3/src/assertions.ts b/lib/tdf3/src/assertions.ts index 0100bd041..61d0845c5 100644 --- a/lib/tdf3/src/assertions.ts +++ b/lib/tdf3/src/assertions.ts @@ -111,7 +111,8 @@ async function sign( try { token = await signJwt(cryptoService, payload, signingMaterial, header); } catch (error) { - throw new ConfigurationError(`Signing assertion failed: ${error.message}`, error); + const msg = error instanceof Error ? error.message : String(error); + throw new ConfigurationError(`Signing assertion failed: ${msg}`, error); } thiz.binding.method = 'jws'; thiz.binding.signature = token; @@ -185,7 +186,8 @@ export async function verify( }); payload = result.payload as AssertionPayload; } catch (error) { - throw new InvalidFileError(`Verifying assertion failed: ${error.message}`, error); + const msg = error instanceof Error ? error.message : String(error); + throw new InvalidFileError(`Verifying assertion failed: ${msg}`, error); } const { assertionHash, assertionSig } = payload; diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c3b824f9d..e0caace36 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -39,141 +39,13 @@ function getSigningAlgorithmParams(algorithm: AsymmetricSigningAlgorithm): { } } -/** - * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format (used by JWT). - * RS256 signatures don't need conversion. - */ -function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { - if (algorithm === 'RS256') { - return signature; - } - - // 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); - - // Remove leading zeros but keep one if the high bit is set - const trimLeadingZeros = (arr: Uint8Array): Uint8Array => { - let i = 0; - while (i < arr.length - 1 && arr[i] === 0) i++; - return arr.slice(i); - }; - - let rTrimmed = trimLeadingZeros(r); - let sTrimmed = trimLeadingZeros(s); - - // Add leading zero if high bit is set (to keep positive in DER) - if (rTrimmed[0] & 0x80) { - const padded = new Uint8Array(rTrimmed.length + 1); - padded.set(rTrimmed, 1); - rTrimmed = padded; - } - if (sTrimmed[0] & 0x80) { - const padded = new Uint8Array(sTrimmed.length + 1); - padded.set(sTrimmed, 1); - sTrimmed = padded; - } - - // DER SEQUENCE: 0x30 [length] [r INTEGER] [s INTEGER] - // INTEGER: 0x02 [length] [value] - const rDer = new Uint8Array([0x02, rTrimmed.length, ...rTrimmed]); - const sDer = new Uint8Array([0x02, sTrimmed.length, ...sTrimmed]); - - const seqLen = rDer.length + sDer.length; - // DER length: short-form for < 128, long-form (0x81 nn) for 128-255. - // ECDSA sequences never exceed 255 bytes for any supported curve. - const lenBytes = seqLen < 128 ? new Uint8Array([seqLen]) : new Uint8Array([0x81, seqLen]); - const result = new Uint8Array(1 + lenBytes.length + seqLen); - result[0] = 0x30; - result.set(lenBytes, 1); - result.set(rDer, 1 + lenBytes.length); - result.set(sDer, 1 + lenBytes.length + rDer.length); - - return result; -} - -/** - * Convert DER signature format (used by JWT) to IEEE P1363 format (used by WebCrypto ECDSA). - * RS256 signatures don't need conversion. - */ -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}`); - } - - if (signature[0] !== 0x30) { - throw new ConfigurationError('Invalid DER signature: expected SEQUENCE'); - } - - // Skip SEQUENCE tag, then parse DER length (short- or long-form). - let offset = 1; - if (signature[offset] & 0x80) { - // Long-form: low 7 bits = number of subsequent length bytes. - const lenBytesCount = signature[offset] & 0x7f; - if (lenBytesCount === 0 || lenBytesCount > 4) { - 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); - - // 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); - } - - // Pad to component length - const result = new Uint8Array(componentLen * 2); - result.set(r, componentLen - r.length); - result.set(s, componentLen * 2 - s.length); - - return result; -} - /** * Sign data with an asymmetric private key. + * + * ECDSA signatures come back as raw IEEE P1363 (`R || S`) — the fixed-width + * encoding WebCrypto emits and the one RFC 7518 section 3.4 requires on the JWS + * wire — so nothing transcodes between here and the token. RSA signatures have + * a single encoding. */ export async function sign( data: Uint8Array, @@ -185,15 +57,14 @@ export async function sign( // Unwrap the internal CryptoKey const key = unwrapKey(privateKey); - // Sign the data - const signature = await crypto.subtle.sign(signParams, key, data); - - // Convert from IEEE P1363 to DER for EC algorithms - return ieeeP1363ToDer(new Uint8Array(signature), algorithm); + return new Uint8Array(await crypto.subtle.sign(signParams, key, data)); } /** * Verify signature with an asymmetric public key. + * + * Expects the encoding {@link sign} produces: raw IEEE P1363 for ECDSA. A + * wrong-length signature fails verification rather than throwing. */ export async function verify( data: Uint8Array, @@ -206,9 +77,5 @@ export async function verify( // Unwrap the internal CryptoKey const key = unwrapKey(publicKey); - // Convert from DER to IEEE P1363 for EC algorithms - const ieeeSignature = derToIeeeP1363(signature, algorithm); - - // Verify the signature - return crypto.subtle.verify(signParams, key, ieeeSignature, data); + return crypto.subtle.verify(signParams, key, signature, data); } diff --git a/lib/tdf3/src/crypto/declarations.ts b/lib/tdf3/src/crypto/declarations.ts index 91ec76b5b..ba951c085 100644 --- a/lib/tdf3/src/crypto/declarations.ts +++ b/lib/tdf3/src/crypto/declarations.ts @@ -185,10 +185,32 @@ export type SymmetricKey = { */ export type ECCurve = 'P-256' | 'P-384' | 'P-521'; +/** + * ECDSA signing algorithms. Signatures for these are raw IEEE P1363 (`R || S`) + * everywhere in this SDK, per RFC 7518 §3.4; see `crypto/core/signing.ts`. + */ +export const EC_SIGNING_ALGORITHMS = ['ES256', 'ES384', 'ES512'] as const; + +export type EcSigningAlgorithm = (typeof EC_SIGNING_ALGORITHMS)[number]; + +/** + * Runtime list of asymmetric signing algorithms. Used to validate + * untyped/JWS-header algorithm strings. + */ +export const ASYMMETRIC_SIGNING_ALGORITHMS = ['RS256', ...EC_SIGNING_ALGORITHMS] as const; + /** * Asymmetric signing algorithms (require PEM keys). */ -export type AsymmetricSigningAlgorithm = 'RS256' | 'ES256' | 'ES384' | 'ES512'; +export type AsymmetricSigningAlgorithm = (typeof ASYMMETRIC_SIGNING_ALGORITHMS)[number]; + +/** + * Type guard narrowing an arbitrary string to an algorithm CryptoService can + * sign/verify with. + */ +export function isAsymmetricSigningAlgorithm(alg: string): alg is AsymmetricSigningAlgorithm { + return (ASYMMETRIC_SIGNING_ALGORITHMS as readonly string[]).includes(alg); +} /** * Symmetric signing algorithm (requires raw key bytes). @@ -287,6 +309,12 @@ export type CryptoService = { /** * Sign data with an asymmetric private key. + * + * ECDSA signature encoding: returns raw IEEE P1363 (`R || S`), fixed-width + * per curve — 64 bytes for ES256, 96 for ES384, 132 for ES512. + * + * RSA uses RSASSA-PKCS1-v1_5 with SHA-256. + * * @param data - Data to sign * @param privateKey - Opaque private key * @param algorithm - Signing algorithm (RS256, ES256, ES384, ES512) @@ -299,6 +327,7 @@ export type CryptoService = { /** * Verify signature with an asymmetric public key. + * * @param data - Original data that was signed * @param signature - Signature to verify * @param publicKey - Opaque public key diff --git a/lib/tdf3/src/crypto/jwt.ts b/lib/tdf3/src/crypto/jwt.ts index 5ae08fd20..a5bab0b0c 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, @@ -134,11 +134,10 @@ 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}`); + } + signature = await cryptoService.sign(signingInputBytes, key, header.alg); } // Return compact JWT @@ -232,12 +231,11 @@ 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}"`); + } + // Sigs are IEEE P1363 for ECDSA (RFC 7518 §3.4), PKCS#1 for RSA. + valid = await cryptoService.verify(signingInputBytes, signature, publicKey, header.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-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts new file mode 100644 index 000000000..63736d78d --- /dev/null +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -0,0 +1,184 @@ +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 type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import { CURVES, ecdsaKeyPair, rsaKeyPair } from './helpers/jws-keys.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'; + +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 { sdk: 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 { sdk: 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 { sdk: kp1 } = await ecdsaKeyPair(namedCurve); + const { sdk: 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 { sdk: 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 { sdk: 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/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 9677746cc..eac97d5b6 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -420,6 +420,53 @@ describe('encrypt decrypt test', async function () { assert.equal(new TextDecoder().decode(decryptedText), expectedVal); }); + it('decrypt signs the rewrap request token with EC dpop keys (ES256)', async function () { + // Regression for DSPX-3397: the rewrap request token was always signed with + // RS256, which made WebCrypto reject EC dpop keys ("Unable to use this key to + // sign"). The token alg must follow the dpop key algorithm. + const cipher = new AesGcmCipher(WebCryptoService); + const encryptionInformation = new SplitKey(cipher); + const key1 = await encryptionInformation.generateKey(); + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + const client = new Client.Client({ + kasEndpoint: kasUrl, + platformUrl: kasUrl, + dpopKeys: Mocks.entityECKeyPair(), + clientId: 'id', + authProvider, + }); + + const scope: Scope = { + dissem: ['user@domain.com'], + attributes: [], + }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: 'rsa:2048', + offline: true, + scope, + keyMiddleware, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); + it('encrypt-decrypt with system metadata assertion', async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); diff --git a/lib/tests/mocha/helpers/jws-keys.ts b/lib/tests/mocha/helpers/jws-keys.ts new file mode 100644 index 000000000..8ae6faebb --- /dev/null +++ b/lib/tests/mocha/helpers/jws-keys.ts @@ -0,0 +1,61 @@ +import { exportPublicKeyPem } from '../../../tdf3/src/crypto/core/key-format.js'; +import { wrapPrivateKey, wrapPublicKey } from '../../../tdf3/src/crypto/core/keys.js'; +import { generateSigningKeyPair } from '../../../tdf3/src/crypto/core/rsa.js'; +import type { + ECCurve, + EcKeyAlgorithm, + EcSigningAlgorithm, + KeyPair, +} from '../../../tdf3/src/crypto/declarations.js'; + +/** + * Shared signing-key fixtures for the JWS suites (dpop-proof, reqsignature-jws, + * assertions). + * + * Each fixture returns an SDK-opaque `KeyPair` alongside its SPKI PEM, so tests + * can hand the PEM to `jose.importSPKI` and verify the SDK's output with an + * independent, RFC-conformant implementation. + */ + +export const CURVES: Array<{ namedCurve: ECCurve; alg: EcSigningAlgorithm }> = [ + { namedCurve: 'P-256', alg: 'ES256' }, + { namedCurve: 'P-384', alg: 'ES384' }, + { namedCurve: 'P-521', alg: 'ES512' }, +]; + +export type TestKeyPair = { sdk: KeyPair; pubPem: string }; + +const CURVE_ALGORITHMS: Record = { + 'P-256': 'ec:secp256r1', + 'P-384': 'ec:secp384r1', + 'P-521': 'ec:secp521r1', +}; + +async function withPem(sdk: KeyPair): Promise { + return { sdk, pubPem: await exportPublicKeyPem(sdk.publicKey) }; +} + +/** + * Generated with raw WebCrypto rather than `generateECKeyPair`, which produces + * ECDH `deriveBits` keys that cannot sign. + */ +export async function ecdsaKeyPair(namedCurve: ECCurve): Promise { + const algorithm = CURVE_ALGORITHMS[namedCurve]; + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + return withPem({ + publicKey: wrapPublicKey(raw.publicKey, algorithm), + privateKey: wrapPrivateKey(raw.privateKey, algorithm), + }); +} + +/** + * RS256 is the default DPoP alg for any RSA key and, unlike ES*, its signature + * is passed through unconverted (no DER<->P1363 transform). Suites use this to + * exercise that pass-through branch against the same conformant verifier. + */ +export async function rsaKeyPair(): Promise { + return withPem(await generateSigningKeyPair()); +} diff --git a/lib/tests/mocha/reqsignature-jws.spec.ts b/lib/tests/mocha/reqsignature-jws.spec.ts new file mode 100644 index 000000000..f82268d15 --- /dev/null +++ b/lib/tests/mocha/reqsignature-jws.spec.ts @@ -0,0 +1,84 @@ +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 { CURVES, ecdsaKeyPair, rsaKeyPair } from './helpers/jws-keys.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. + */ + +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'); + }); +}); diff --git a/lib/tests/mocha/unit/assertions.spec.ts b/lib/tests/mocha/unit/assertions.spec.ts index 31e308045..e4c1e4ea9 100644 --- a/lib/tests/mocha/unit/assertions.spec.ts +++ b/lib/tests/mocha/unit/assertions.spec.ts @@ -1,12 +1,15 @@ // tests for assertions.ts import { expect } from 'chai'; +import { base64url } from 'jose'; import * as assertions from '../../../tdf3/src/assertions.js'; import * as DefaultCryptoService from '../../../tdf3/src/crypto/index.js'; import { hex, base64 } from '../../../src/encodings/index.js'; +import { exportPublicKeyJwk } from '../../../tdf3/src/crypto/core/key-format.js'; import { signJwt } from '../../../tdf3/src/crypto/jwt.js'; import type { CryptoService } from '../../../tdf3/src/crypto/declarations.js'; +import { ecdsaKeyPair } from '../helpers/jws-keys.js'; describe('assertions', () => { const cryptoService: CryptoService = DefaultCryptoService; @@ -63,28 +66,10 @@ describe('assertions', () => { const isLegacyTDF = false; it('should verify assertion using jwk from header', async () => { - // Generate ECDSA key pair for ES256 signing (not ECDH) - const webCryptoKeyPair = await crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'P-256' }, - true, - ['sign', 'verify'] - ); - const keyPair = { - publicKey: { - _brand: 'PublicKey', - algorithm: 'ec:secp256r1', - curve: 'P-256', - _internal: webCryptoKeyPair.publicKey, - } as any, - privateKey: { - _brand: 'PrivateKey', - algorithm: 'ec:secp256r1', - curve: 'P-256', - _internal: webCryptoKeyPair.privateKey, - } as any, - }; + // ES256 signing key pair (ECDSA, not ECDH) + const { sdk: keyPair } = await ecdsaKeyPair('P-256'); // Get JWK from the public key - const jwk = await crypto.subtle.exportKey('jwk', webCryptoKeyPair.publicKey); + const jwk = await exportPublicKeyJwk(keyPair.publicKey); const assertion: assertions.Assertion = { id: 'test-assertion', @@ -130,6 +115,50 @@ describe('assertions', () => { await assertions.verify(assertion, aggregateHash, dummyKey, isLegacyTDF, cryptoService); }); + it('persists ES256 bindings as raw IEEE P1363, the only encoding on the wire', async () => { + // binding.signature is the one signature this SDK writes to durable + // storage — it lands in the manifest and is read back by other SDKs. RFC + // 7518 §3.4 requires raw R || S (64 bytes for P-256); ASN.1 DER is 69-72 + // bytes and is rejected on length by conformant verifiers. Pin the width + // so no encoding change can slip into the file format unnoticed. + const { sdk: keyPair } = await ecdsaKeyPair('P-256'); + const signingKey: assertions.AssertionKey = { + alg: 'ES256', + key: keyPair.privateKey, + }; + + const assertion = await assertions.CreateAssertion( + aggregateHash, + { + id: 'raw-p1363-binding', + type: 'handling', + scope: 'tdo', + appliesToState: 'unencrypted', + statement: { + format: 'json', + schema: 'test-schema', + value: '{"foo":"bar"}', + }, + signingKey, + }, + cryptoService + ); + + const [, , signatureB64url] = assertion.binding.signature.split('.'); + expect(base64url.decode(signatureB64url).length).to.equal(64); + + await assertions.verify( + assertion, + aggregateHash, + { + alg: 'ES256', + key: keyPair.publicKey, + }, + isLegacyTDF, + cryptoService + ); + }); + it('should fallback to provided key if no key in header', async () => { const symmetricKey = await cryptoService.importSymmetricKey( await cryptoService.randomBytes(32) diff --git a/lib/tests/mocha/unit/crypto/crypto-service.spec.ts b/lib/tests/mocha/unit/crypto/crypto-service.spec.ts index 7acd43bfd..6d266d55d 100644 --- a/lib/tests/mocha/unit/crypto/crypto-service.spec.ts +++ b/lib/tests/mocha/unit/crypto/crypto-service.spec.ts @@ -557,42 +557,41 @@ describe('Crypto Service', () => { expect(valid).to.be.true; }); - it('ES512 DER output is well-formed and round-trips correctly', async () => { - // Verifies that ieeeP1363ToDer and derToIeeeP1363 both use correct DER length - // encoding, including long-form (0x81 ) when the SEQUENCE body is ≥ 128 bytes. - // Correct encoding is required for interoperability with external parsers - // (Go crypto, OpenSSL, etc.) that strictly validate the DER structure. - const webCryptoKeyPair = await crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'P-521' }, - true, - ['sign', 'verify'] - ); - const ecKeyPair = { - publicKey: { - _brand: 'PublicKey', - algorithm: 'ec:secp521r1', - curve: 'P-521', - _internal: webCryptoKeyPair.publicKey, - } as any, - privateKey: { + // RFC 7518 §3.4 fixes the ECDSA signature encoding for JWS as raw IEEE + // P1363 (R || S), one fixed-width field element each. ASN.1 DER — which is + // what several non-WebCrypto stacks emit — is variable length and always + // longer, so an exact byte count is what distinguishes the two. Signatures + // go from here straight onto the wire, so a regression here silently + // produces tokens that conformant verifiers (jose, jwx, nimbus) reject. + const RAW_SIGNATURE_BYTES = [ + { alg: 'ES256', namedCurve: 'P-256', keyAlgorithm: 'ec:secp256r1', length: 64 }, + { alg: 'ES384', namedCurve: 'P-384', keyAlgorithm: 'ec:secp384r1', length: 96 }, + { alg: 'ES512', namedCurve: 'P-521', keyAlgorithm: 'ec:secp521r1', length: 132 }, + ] as const; + + for (const { alg, namedCurve, keyAlgorithm, length } of RAW_SIGNATURE_BYTES) { + it(`${alg} signatures are exactly ${length} raw bytes, never DER`, async () => { + const webCryptoKeyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve }, + true, + ['sign', 'verify'] + ); + const privateKey = { _brand: 'PrivateKey', - algorithm: 'ec:secp521r1', - curve: 'P-521', + algorithm: keyAlgorithm, + curve: namedCurve, _internal: webCryptoKeyPair.privateKey, - } as any, - }; - const data = new TextEncoder().encode('test data for ES512 DER long-form'); - - const der = await sign(data, ecKeyPair.privateKey, 'ES512'); - - // Output must be a DER SEQUENCE regardless of component size. - expect(der[0]).to.equal(0x30); - - // Round-trip validates that the length field was encoded and decoded consistently, - // including long-form (needed when the SEQUENCE body is ≥ 128 bytes). - const valid = await verify(data, der, ecKeyPair.publicKey, 'ES512'); - expect(valid).to.be.true; - }); + } as any; + const data = new TextEncoder().encode(`raw signature width check for ${alg}`); + + // A handful of draws: r and s are uniform, so ~1 signature in 256 has a + // leading zero byte that DER would drop and P1363 must keep. + for (let i = 0; i < 8; i++) { + const signature = await sign(data, privateKey, alg); + expect(signature.length).to.equal(length); + } + }); + } }); describe('sign and verify with RS256', () => {