Skip to content

Commit

Permalink
fix: jwk thumprint using crypto.subtle
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra committed Jul 31, 2024
1 parent 86d0423 commit 8d869ee
Show file tree
Hide file tree
Showing 14 changed files with 104 additions and 25 deletions.
6 changes: 6 additions & 0 deletions packages/common/lib/__tests__/dpop.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createHash } from 'node:crypto';

import { createDPoP, getCreateDPoPOptions, verifyDPoP } from '../dpop';

const hasher = async (data: string) => createHash('sha256').update(data).digest();

describe('dpop', () => {
const alg = 'HS256';
const jwk = { kty: 'Ed25519', crv: 'Ed25519', x: '123', y: '123' };
Expand Down Expand Up @@ -84,6 +88,7 @@ describe('dpop', () => {
expectedNonce: 'nonce',
expectAccessToken: false,
now: 1722327194,
hasher,
},
),
).rejects.toThrow();
Expand Down Expand Up @@ -113,6 +118,7 @@ describe('dpop', () => {

return true;
},
hasher,
expectAccessToken: false,
expectedNonce: 'nonce',
now: 1722327194,
Expand Down
4 changes: 3 additions & 1 deletion packages/common/lib/dpop/DPoP.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncHasher } from '@sphereon/ssi-types';
import { jwtDecode } from 'jwt-decode';
import SHA from 'sha.js';
import * as u8a from 'uint8arrays';
Expand Down Expand Up @@ -92,6 +93,7 @@ export interface DPoPVerifyOptions {
expectAccessToken?: boolean;
jwtVerifyCallback: DPoPVerifyJwtCallback;
now?: number;
hasher: AsyncHasher;
}

export async function verifyDPoP(
Expand Down Expand Up @@ -206,7 +208,7 @@ export async function verifyDPoP(
throw new Error('invalid_dpop_proof. Access token is missing the jkt claim');
}

const thumprint = await calculateJwkThumbprint(dPoPHeader.jwk, 'sha256');
const thumprint = await calculateJwkThumbprint(options.hasher, dPoPHeader.jwk, 'sha256');
if (accessTokenPayload.cnf?.jkt !== thumprint) {
throw new Error('invalid_dpop_proof. JwkThumbprint mismatch');
}
Expand Down
20 changes: 10 additions & 10 deletions packages/common/lib/jwt/JwkThumbprint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncHasher, Hasher } from '@sphereon/ssi-types';
import * as u8a from 'uint8arrays';

import { DigestAlgorithm } from '../types';
Expand All @@ -10,12 +11,7 @@ const check = (value: unknown, description: string) => {
}
};

const digest = async (algorithm: DigestAlgorithm, data: Uint8Array) => {
const subtleDigest = `SHA-${algorithm.slice(-3)}`;
return new Uint8Array(await crypto.subtle.digest(subtleDigest, data));
};

export async function calculateJwkThumbprint(jwk: JWK, digestAlgorithm?: DigestAlgorithm): Promise<string> {
export async function calculateJwkThumbprint(hasher: Hasher | AsyncHasher, jwk: JWK, digestAlgorithm?: DigestAlgorithm): Promise<string> {
if (!jwk || typeof jwk !== 'object') {
throw new TypeError('JWK must be an object');
}
Expand Down Expand Up @@ -48,8 +44,8 @@ export async function calculateJwkThumbprint(jwk: JWK, digestAlgorithm?: DigestA
default:
throw Error('"kty" (Key Type) Parameter missing or unsupported');
}
const data = u8a.fromString(JSON.stringify(components), 'utf-8');
return u8a.toString(await digest(algorithm, data), 'base64url');
const digest = await hasher(JSON.stringify(components), algorithm);
return u8a.toString(digest, 'base64url');
}

export async function getDigestAlgorithmFromJwkThumbprintUri(uri: string): Promise<DigestAlgorithm> {
Expand All @@ -64,7 +60,11 @@ export async function getDigestAlgorithmFromJwkThumbprintUri(uri: string): Promi
return algorithm;
}

export async function calculateJwkThumbprintUri(jwk: JWK, digestAlgorithm: DigestAlgorithm = 'sha256'): Promise<string> {
const thumbprint = await calculateJwkThumbprint(jwk, digestAlgorithm);
export async function calculateJwkThumbprintUri(
hasher: AsyncHasher | Hasher,
jwk: JWK,
digestAlgorithm: DigestAlgorithm = 'sha256',
): Promise<string> {
const thumbprint = await calculateJwkThumbprint(hasher, jwk, digestAlgorithm);
return `urn:ietf:params:oauth:jwk-thumbprint:sha-${digestAlgorithm.slice(-3)}:${thumbprint}`;
}
13 changes: 13 additions & 0 deletions packages/issuer-rest/lib/IssuerTokenEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,24 @@ export const handleTokenRequest = <T extends object>({

try {
const fullUrl = accessTokenEndpoint ?? request.protocol + '://' + request.get('host') + request.originalUrl
if (!issuer.hasher) {
console.error(
'Unable to handle token request with dpop header. Missing hasher for calculating jwk thumbprint in handleTokenRequest method. You can provide the hasher in the VcIssuer or VcIssuerBuilder',
)
// Server needs to be reconfigured to work properly
return sendErrorResponse(response, 500, {
error: 'server_error',
error_description: 'Internal server error',
})
}

dPoPJwk = await verifyDPoP(
{ method: request.method, headers: request.headers, fullUrl },
{
jwtVerifyCallback: dpop.dPoPVerifyJwtCallback,
expectAccessToken: false,
maxIatAgeInSeconds: undefined,
hasher: issuer.hasher,
},
)
} catch (error) {
Expand All @@ -99,6 +111,7 @@ export const handleTokenRequest = <T extends object>({
interval,
tokenExpiresIn,
dPoPJwk,
hasher: issuer.hasher,
})
return response.status(200).json(responseBody)
} catch (error) {
Expand Down
9 changes: 8 additions & 1 deletion packages/issuer/lib/VcIssuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from '@sphereon/oid4vci-common'
import { CredentialEventNames, CredentialOfferEventNames, EVENTS } from '@sphereon/oid4vci-common/dist/events'
import { CredentialIssuerMetadataOptsV1_0_13 } from '@sphereon/oid4vci-common/dist/types/v1_0_13.types'
import { CompactSdJwtVc, CredentialMapper, InitiatorType, SubSystem, System, W3CVerifiableCredential } from '@sphereon/ssi-types'
import { AsyncHasher, CompactSdJwtVc, CredentialMapper, InitiatorType, SubSystem, System, W3CVerifiableCredential } from '@sphereon/ssi-types'

import { assertValidPinNumber, createCredentialOfferObject, createCredentialOfferURIFromObject } from './functions'
import { LookupStateManager } from './state-manager'
Expand All @@ -56,6 +56,7 @@ export class VcIssuer<DIDDoc extends object> {
private readonly _cNonces: IStateManager<CNonceState>
private readonly _uris?: IStateManager<URIState>
private readonly _cNonceExpiresIn: number
private readonly _hasher?: AsyncHasher

constructor(
issuerMetadata: CredentialIssuerMetadataOptsV1_0_13,
Expand All @@ -70,6 +71,7 @@ export class VcIssuer<DIDDoc extends object> {
jwtVerifyCallback?: JWTVerifyCallback<DIDDoc>
credentialDataSupplier?: CredentialDataSupplier
cNonceExpiresIn?: number | undefined // expiration duration in seconds
hasher?: AsyncHasher
},
) {
this.setDefaultTokenEndpoint(issuerMetadata)
Expand All @@ -82,6 +84,7 @@ export class VcIssuer<DIDDoc extends object> {
this._jwtVerifyCallback = args?.jwtVerifyCallback
this._credentialDataSupplier = args?.credentialDataSupplier
this._cNonceExpiresIn = (args?.cNonceExpiresIn ?? (process.env.C_NONCE_EXPIRES_IN ? parseInt(process.env.C_NONCE_EXPIRES_IN) : 300)) as number
this._hasher = args.hasher
}

public getCredentialOfferSessionById(id: string): Promise<CredentialOfferSession> {
Expand Down Expand Up @@ -680,4 +683,8 @@ export class VcIssuer<DIDDoc extends object> {
public get issuerMetadata() {
return this._issuerMetadata
}

public get hasher() {
return this._hasher
}
}
8 changes: 8 additions & 0 deletions packages/issuer/lib/builder/VcIssuerBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
URIState,
} from '@sphereon/oid4vci-common'
import { CredentialIssuerMetadataOptsV1_0_13 } from '@sphereon/oid4vci-common/dist/types/v1_0_13.types'
import { AsyncHasher, Hasher } from '@sphereon/ssi-types'

import { VcIssuer } from '../VcIssuer'
import { MemoryStates } from '../state-manager'
Expand All @@ -32,6 +33,7 @@ export class VcIssuerBuilder<DIDDoc extends object> {
credentialSignerCallback?: CredentialSignerCallback<DIDDoc>
jwtVerifyCallback?: JWTVerifyCallback<DIDDoc>
credentialDataSupplier?: CredentialDataSupplier
hasher?: AsyncHasher

public withIssuerMetadata(issuerMetadata: IssuerMetadata) {
if (!issuerMetadata.credential_configurations_supported) {
Expand All @@ -46,6 +48,11 @@ export class VcIssuerBuilder<DIDDoc extends object> {
return this
}

public withHasher(hasher: Hasher | AsyncHasher) {
this.hasher = hasher as AsyncHasher
return this
}

public withDefaultCredentialOfferBaseUri(baseUri: string) {
this.defaultCredentialOfferBaseUri = baseUri
return this
Expand Down Expand Up @@ -182,6 +189,7 @@ export class VcIssuerBuilder<DIDDoc extends object> {
cNonces: this.cNonceStateManager,
cNonceExpiresIn: this.cNonceExpiresIn,
uris: this.credentialOfferURIManager,
hasher: this.hasher,
})
}
}
26 changes: 24 additions & 2 deletions packages/issuer/lib/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
USER_PIN_REQUIRED_ERROR,
USER_PIN_TX_CODE_SPEC_ERROR,
} from '@sphereon/oid4vci-common'
import { AsyncHasher } from '@sphereon/ssi-types'

import { isPreAuthorizedCodeExpired } from '../functions'

Expand All @@ -44,13 +45,22 @@ export const generateAccessToken = async (
preAuthorizedCode?: string
alg?: Alg
dPoPJwk?: JWK
hasher?: AsyncHasher
},
): Promise<string> => {
const { dPoPJwk, accessTokenIssuer, alg, accessTokenSignerCallback, tokenExpiresIn, preAuthorizedCode } = opts
// JWT uses seconds for iat and exp
const iat = new Date().getTime() / 1000
const exp = iat + tokenExpiresIn
const cnf = dPoPJwk ? { cnf: { jkt: await calculateJwkThumbprint(dPoPJwk, 'sha256') } } : undefined

let cnf: { cnf: { jkt: string } } | undefined = undefined
if (dPoPJwk) {
if (!opts.hasher) {
throw new Error('Missing hasher in generateAccessToken method. You can provide the hasher in the VcIssuer or VcIssuerBuilder')
}
cnf = { cnf: { jkt: await calculateJwkThumbprint(opts.hasher, dPoPJwk, 'sha256') } }
}

const jwt: Jwt = {
header: { typ: 'JWT', alg: alg ?? Alg.ES256 },
payload: {
Expand Down Expand Up @@ -202,9 +212,20 @@ export const createAccessTokenResponse = async (
accessTokenIssuer: string
interval?: number
dPoPJwk?: JWK
hasher?: AsyncHasher
},
) => {
const { dPoPJwk, credentialOfferSessions, cNonces, cNonceExpiresIn, tokenExpiresIn, accessTokenIssuer, accessTokenSignerCallback, interval } = opts
const {
dPoPJwk,
credentialOfferSessions,
cNonces,
cNonceExpiresIn,
tokenExpiresIn,
accessTokenIssuer,
accessTokenSignerCallback,
interval,
hasher,
} = opts
// Pre-auth flow
const preAuthorizedCode = request[PRE_AUTH_CODE_LITERAL] as string

Expand All @@ -217,6 +238,7 @@ export const createAccessTokenResponse = async (
preAuthorizedCode,
accessTokenIssuer,
dPoPJwk,
hasher,
})

const response: AccessTokenResponse = {
Expand Down
2 changes: 1 addition & 1 deletion packages/siop-oid4vp/lib/__tests__/IT.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
} from '../'
import { checkSIOPSpecVersionSupported } from '../helpers/SIOPSpecVersion'

import { getVerifyJwtCallback, internalSignature } from './DidJwtTestUtils'
import { getVerifyJwtCallback, internalSignature, publicJwkFromPrivateKeyHex } from './DidJwtTestUtils'
import { getResolver } from './ResolverTestUtils'
import { mockedGetEnterpriseAuthToken, WELL_KNOWN_OPENID_FEDERATION } from './TestUtils'
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/siop-oid4vp/lib/authorization-response/Payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const createResponsePayload = async (
responseOpts: AuthorizationResponseOpts,
idTokenPayload?: IDTokenPayload,
): Promise<AuthorizationResponsePayload | undefined> => {
await assertValidResponseOpts(responseOpts)
assertValidResponseOpts(responseOpts)
if (!authorizationRequest) {
throw new Error(SIOPErrors.NO_REQUEST)
}
Expand Down
1 change: 1 addition & 0 deletions packages/siop-oid4vp/lib/authorization-response/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface AuthorizationResponseOpts {
tokenType?: string
refreshToken?: string
presentationExchange?: PresentationExchangeResponseOpts
hasher?: Hasher
}

export interface PresentationExchangeResponseOpts {
Expand Down
13 changes: 8 additions & 5 deletions packages/siop-oid4vp/lib/id-token/IDToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class IDToken {
const did = jwtIssuer.didUrl.split('#')[0]
this._payload.sub = did

const issuer = this._responseOpts.registration.issuer || this._payload.iss
const issuer = this.responseOpts.registration.issuer || this._payload.iss
if (!issuer || !(issuer.includes(ResponseIss.SELF_ISSUED_V2) || issuer === this._payload.sub)) {
throw new Error(SIOPErrors.NO_SELF_ISSUED_ISS)
}
Expand All @@ -114,15 +114,18 @@ export class IDToken {
this._payload.sub = jwtIssuer.issuer

const header = { x5c: jwtIssuer.x5c, typ: 'JWT' }
this._jwt = await this._responseOpts.createJwtCallback(jwtIssuer, { header, payload: this._payload })
this._jwt = await this.responseOpts.createJwtCallback(jwtIssuer, { header, payload: this._payload })
} else if (jwtIssuer.method === 'jwk') {
const jwkThumbprintUri = await calculateJwkThumbprintUri(jwtIssuer.jwk as JWK)
if (!this.responseOpts.hasher) {
throw new Error('Missing hasher in jwt opts. You can provide the hasher in the OP/OPBuilder or RP/RPBuilder')
}
const jwkThumbprintUri = await calculateJwkThumbprintUri(this.responseOpts.hasher, jwtIssuer.jwk as JWK)
this._payload.sub = jwkThumbprintUri
this._payload.iss = jwkThumbprintUri
this._payload.sub_jwk = jwtIssuer.jwk

const header = { jwk: jwtIssuer.jwk, alg: jwtIssuer.jwk.alg, typ: 'JWT' }
this._jwt = await this._responseOpts.createJwtCallback(jwtIssuer, { header, payload: this._payload })
this._jwt = await this.responseOpts.createJwtCallback(jwtIssuer, { header, payload: this._payload })
} else {
throw new Error(`JwtIssuer method '${(jwtIssuer as JwtIssuer).method}' not implemented`)
}
Expand Down Expand Up @@ -158,7 +161,7 @@ export class IDToken {
this.assertValidResponseJWT(parsedJwt)
const idTokenPayload = parsedJwt.payload as IDTokenPayload

const jwtVerifier = await getJwtVerifierWithContext(parsedJwt, { type: 'id-token' })
const jwtVerifier = await getJwtVerifierWithContext(parsedJwt, { type: 'id-token', hasher: verifyOpts.hasher })
const verificationResult = await verifyOpts.verifyJwtCallback(jwtVerifier, { ...parsedJwt, raw: this._jwt })
if (!verificationResult) {
throw Error(SIOPErrors.ERROR_VERIFYING_SIGNATURE)
Expand Down
1 change: 1 addition & 0 deletions packages/siop-oid4vp/lib/op/Opts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const createResponseOptsFromBuilderOrExistingOpts = (opts: {
jwtIssuer: responseOpts?.jwtIssuer,
createJwtCallback: opts.builder.createJwtCallback,
responseMode: opts.builder.responseMode,
hasher: opts.builder.hasher,
...(responseOpts?.version
? { version: responseOpts.version }
: Array.isArray(opts.builder.supportedVersions) && opts.builder.supportedVersions.length > 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export const AuthorizationResponseOptsSchemaObj = {
},
"presentationExchange": {
"$ref": "#/definitions/PresentationExchangeResponseOpts"
},
"hasher": {
"$ref": "#/definitions/Hasher"
}
},
"required": [
Expand Down Expand Up @@ -2235,6 +2238,14 @@ export const AuthorizationResponseOptsSchemaObj = {
"id_token",
"token_response"
]
},
"Hasher": {
"properties": {
"isFunction": {
"type": "boolean",
"const": true
}
}
}
}
};
Loading

0 comments on commit 8d869ee

Please sign in to comment.