Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions lib/src/auth/dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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.
Expand All @@ -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)}`;
}

Expand Down Expand Up @@ -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';
}
Expand Down
140 changes: 86 additions & 54 deletions lib/tdf3/src/crypto/core/signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,52 @@ 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}`);
}
}

/** Drop leading zero bytes, keeping at least one so the value stays non-empty. */
function trimLeadingZeros(arr: Uint8Array): Uint8Array {
let i = 0;
while (i < arr.length - 1 && arr[i] === 0) i++;
return arr.slice(i);
}

/**
* 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 => {
let i = 0;
while (i < arr.length - 1 && arr[i] === 0) i++;
return arr.slice(i);
};

let rTrimmed = trimLeadingZeros(r);
let sTrimmed = trimLeadingZeros(s);

Expand Down Expand Up @@ -94,28 +119,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) {
Expand All @@ -131,40 +157,46 @@ 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).
r = trimLeadingZeros(r);
s = trimLeadingZeros(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);
Expand Down
21 changes: 21 additions & 0 deletions lib/tdf3/src/crypto/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
36 changes: 24 additions & 12 deletions lib/tdf3/src/crypto/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
type AsymmetricSigningAlgorithm,
isAsymmetricSigningAlgorithm,
type CryptoService,
type PrivateKey,
type PublicKey,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 29 additions & 1 deletion lib/tdf3/src/tdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading