Skip to content
This repository has been archived by the owner on Oct 2, 2024. It is now read-only.

fix: always verify nonce, extract nonce from VP #76

Merged
Merged
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
42 changes: 32 additions & 10 deletions src/authorization-response/AuthorizationResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export class AuthorizationResponse {
await assertValidResponseOpts(responseOpts);
}
const idToken = authorizationResponsePayload.id_token ? await IDToken.fromIDToken(authorizationResponsePayload.id_token) : undefined;
return new AuthorizationResponse({ authorizationResponsePayload, idToken, responseOpts });
return new AuthorizationResponse({
authorizationResponsePayload,
idToken,
responseOpts,
});
}

static async fromAuthorizationRequest(
Expand Down Expand Up @@ -114,13 +118,18 @@ export class AuthorizationResponse {
});

if (hasVpToken) {
const wrappedPresentations = await extractPresentationsFromAuthorizationResponse(response, { hasher: verifyOpts.hasher });
const wrappedPresentations = await extractPresentationsFromAuthorizationResponse(response, {
hasher: verifyOpts.hasher,
});

await assertValidVerifiablePresentations({
presentationDefinitions,
presentations: wrappedPresentations,
verificationCallback: verifyOpts.verification.presentationVerificationCallback,
opts: { ...responseOpts.presentationExchange, hasher: verifyOpts.hasher },
opts: {
...responseOpts.presentationExchange,
hasher: verifyOpts.hasher,
},
});
}

Expand All @@ -129,27 +138,40 @@ export class AuthorizationResponse {

public async verify(verifyOpts: VerifyAuthorizationResponseOpts): Promise<VerifiedAuthorizationResponse> {
// Merge payloads checks for inconsistencies in properties which are present in both the auth request and request object
const merged = await this.mergedPayloads({ consistencyCheck: true, hasher: verifyOpts.hasher });
const merged = await this.mergedPayloads({
consistencyCheck: true,
hasher: verifyOpts.hasher,
});
if (verifyOpts.state && merged.state !== verifyOpts.state) {
throw Error(SIOPErrors.BAD_STATE);
}

const verifiedIdToken = await this.idToken?.verify(verifyOpts);
const oid4vp = await verifyPresentations(this, verifyOpts);

const nonce = merged.nonce ?? oid4vp.nonce ?? verifiedIdToken?.payload.nonce;
const state = merged.state ?? verifiedIdToken?.payload.state;
// Gather all nonces
const allNonces = new Set<string>();
if (oid4vp) allNonces.add(oid4vp.nonce);
if (verifiedIdToken) allNonces.add(verifiedIdToken.payload.nonce);
if (merged.nonce) allNonces.add(merged.nonce);

const firstNonce = Array.from(allNonces)[0];
if (allNonces.size !== 1 || typeof firstNonce !== 'string') {
throw new Error('both id token and VPs in vp token if present must have a nonce, and all nonces must be the same');
}
if (verifyOpts.nonce && firstNonce !== verifyOpts.nonce) {
throw Error(SIOPErrors.BAD_NONCE);
}

const state = merged.state ?? verifiedIdToken?.payload.state;
if (!state) {
throw Error(`State is required`);
} else if (oid4vp.presentationDefinitions.length > 0 && !nonce) {
throw Error('Nonce is required when using OID4VP');
throw Error('State is required');
}

return {
authorizationResponse: this,
verifyOpts,
nonce,
nonce: firstNonce,
state,
correlationId: verifyOpts.correlationId,
...(this.idToken && { idToken: verifiedIdToken }),
Expand Down
56 changes: 52 additions & 4 deletions src/authorization-response/OpenID4VP.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { IPresentationDefinition, PEX } from '@sphereon/pex';
import { Format } from '@sphereon/pex-models';
import { CredentialMapper, Hasher, PresentationSubmission, W3CVerifiablePresentation, WrappedVerifiablePresentation } from '@sphereon/ssi-types';
import {
CredentialMapper,
Hasher,
IVerifiablePresentation,
PresentationSubmission,
W3CVerifiablePresentation,
WrappedVerifiablePresentation,
} from '@sphereon/ssi-types';
import { decodeJWT } from 'did-jwt';

import { AuthorizationRequest } from '../authorization-request';
import { verifyRevocation } from '../helpers';
Expand All @@ -24,10 +32,40 @@ import {
VPTokenLocation,
} from './types';

function extractNonceFromWrappedVerifiablePresentation(wrappedVp: WrappedVerifiablePresentation): string | undefined {
// SD-JWT uses kb-jwt for the nonce
if (CredentialMapper.isWrappedSdJwtVerifiablePresentation(wrappedVp)) {
// TODO: replace this once `kbJwt.payload` is available on the decoded sd-jwt (pr in ssi-sdk)
// If it doesn't end with ~, it contains a kbJwt
if (!wrappedVp.presentation.compactSdJwtVc.endsWith('~')) {
const kbJwt = wrappedVp.presentation.compactSdJwtVc.split('~').pop();
const { payload } = decodeJWT(kbJwt);
return payload.nonce;
}

// No kb-jwt means no nonce (error will be handled later)
return undefined;
}

if (wrappedVp.format === 'jwt_vp') {
return wrappedVp.decoded.nonce;
}

// For LDP-VP a challenge is also fine
if (wrappedVp.format === 'ldp_vp') {
const w3cPresentation = wrappedVp.decoded as IVerifiablePresentation;
const proof = Array.isArray(w3cPresentation.proof) ? w3cPresentation.proof[0] : w3cPresentation.proof;

return proof.nonce ?? proof.challenge;
}

return undefined;
}

export const verifyPresentations = async (
authorizationResponse: AuthorizationResponse,
verifyOpts: VerifyAuthorizationResponseOpts,
): Promise<VerifiedOpenID4VPSubmission> => {
): Promise<VerifiedOpenID4VPSubmission | null> => {
const presentations = await extractPresentationsFromAuthorizationResponse(authorizationResponse, { hasher: verifyOpts.hasher });
const presentationDefinitions = verifyOpts.presentationDefinitions
? Array.isArray(verifyOpts.presentationDefinitions)
Expand All @@ -53,12 +91,22 @@ export const verifyPresentations = async (
},
});

const nonces: Set<string> = new Set(presentations.map((presentation) => presentation.decoded.nonce));
// If there are no presentations, and the `assertValidVerifiablePresentations` did not fail
// it means there's no oid4vp response and also not requested
if (presentations.length === 0) {
return null;
}

const nonces = new Set(presentations.map(extractNonceFromWrappedVerifiablePresentation));
if (presentations.length > 0 && nonces.size !== 1) {
throw Error(`${nonces.size} nonce values found for ${presentations.length}. Should be 1`);
}

const nonce = nonces[0];
// Nonce may be undefined
const nonce = Array.from(nonces)[0];
if (typeof nonce !== 'string') {
throw new Error('Expected all presentations to contain a nonce value');
}

const revocationVerification = verifyOpts.verification?.revocationOpts
? verifyOpts.verification.revocationOpts.revocationVerification
Expand Down
1 change: 1 addition & 0 deletions test/IT.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const presentationSignCallback: PresentationSignCallback = async (_args) => ({
created: '2018-09-14T21:19:10Z',
proofPurpose: 'authentication',
verificationMethod: 'did:example:ebfeb1f712ebc6f1c276e12ec21#keys-1',
nonce: 'qBrR7mqnY3Qr49dAZycPF8FzgE83m6H0c2l0bzP4xSg',
challenge: '1f44d55f-f161-4938-a659-f8026467f126',
domain: '4jt78h47fh47',
jws: 'eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..kTCYt5XsITJX1CxPCT8yAV-TVIw5WEuts01mq-pQy7UJiN5mgREEMGlv50aqzpqh4Qq_PbChOMqsLfRoPsnsgxD-WUcX16dUOqV0G_zS245-kronKb78cPktb3rk-BuQy72IFLN25DYuNzVBAh4vGHSrQyHUGlcTwLtjPAnKb78',
Expand Down
152 changes: 148 additions & 4 deletions test/SdJwt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,21 @@ const presentationSignCallback: PresentationSignCallback = async (_args) => {
payload: {
_sd_hash: expect.any(String),
iat: expect.any(Number),
nonce: undefined,
nonce: expect.any(String),
},
});

return SD_JWT_VC;
const header = {
...kbJwt.header,
alg: 'ES256K',
};
const payload = {
...kbJwt.payload,
aud: '123',
};

const kbJwtCompact = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
return SD_JWT_VC + kbJwtCompact;
};

function getPresentationDefinition(): IPresentationDefinition {
Expand Down Expand Up @@ -202,12 +212,20 @@ describe('RP and OP interaction should', () => {
const verifiedAuthReqWithJWT = await op.verifyAuthorizationRequest(parsedAuthReqURI.requestObjectJwt);
expect(verifiedAuthReqWithJWT.signer).toBeDefined();
expect(verifiedAuthReqWithJWT.issuer).toMatch(rpMockEntity.did);
const pex = new PresentationExchange({ allDIDs: [HOLDER_DID], allVerifiableCredentials: getVCs(), hasher });
const pex = new PresentationExchange({
allDIDs: [HOLDER_DID],
allVerifiableCredentials: getVCs(),
hasher,
});
const pd: PresentationDefinitionWithLocation[] = await PresentationExchange.findValidPresentationDefinitions(
parsedAuthReqURI.authorizationRequestPayload,
);
await pex.selectVerifiableCredentialsForSubmission(pd[0].definition);
const verifiablePresentationResult = await pex.createVerifiablePresentation(pd[0].definition, getVCs(), presentationSignCallback, {});
const verifiablePresentationResult = await pex.createVerifiablePresentation(pd[0].definition, getVCs(), presentationSignCallback, {
proofOptions: {
nonce: 'qBrR7mqnY3Qr49dAZycPF8FzgE83m6H0c2l0bzP4xSg',
},
});
const authenticationResponseWithJWT = await op.createAuthorizationResponse(verifiedAuthReqWithJWT, {
presentationExchange: {
verifiablePresentations: [verifiablePresentationResult.verifiablePresentation],
Expand All @@ -225,4 +243,130 @@ describe('RP and OP interaction should', () => {
expect(verifiedAuthResponseWithJWT.idToken.jwt).toBeDefined();
expect(verifiedAuthResponseWithJWT.idToken.payload.nonce).toMatch('qBrR7mqnY3Qr49dAZycPF8FzgE83m6H0c2l0bzP4xSg');
});

it('succeed when calling with presentation definitions and right verifiable presentation without id token', async () => {
const rpMockEntity = {
hexPrivateKey: '2bbd6a78be9ab2193bcf74aa6d39ab59c1d1e2f7e9ef899a38fb4d94d8aa90e2',
did: 'did:ethr:goerli:0x038f8d21b0446c46b05aecdc603f73831578e28857adba14de569f31f3e569c024',
didKey: 'did:ethr:goerli:0x038f8d21b0446c46b05aecdc603f73831578e28857adba14de569f31f3e569c024#controllerKey',
};

const opMockEntity = {
hexPrivateKey: '73d24dd0fb69abdc12e7a99d8f9a970fdc8ad90598cc64cff35b584220ace0c8',
did: 'did:ethr:goerli:0x03a1370d4dd249eabb23245aeb4aec988fbca598ff83db59144d89b3835371daca',
didKey: 'did:ethr:goerli:0x03a1370d4dd249eabb23245aeb4aec988fbca598ff83db59144d89b3835371daca#controllerKey',
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const verifyCallback = async (_args: IVerifyCallbackArgs): Promise<IVerifyCredentialResult> => ({ verified: true });

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const presentationVerificationCallback: PresentationVerificationCallback = async (_args) => {
return { verified: true };
};

const rp = RP.builder({
requestVersion: SupportedVersion.SIOPv2_D12_OID4VP_D18,
})
.withClientId(rpMockEntity.did)
.withHasher(hasher)
.withResponseType([ResponseType.VP_TOKEN])
.withRedirectUri(EXAMPLE_REDIRECT_URL)
.withPresentationDefinition({ definition: getPresentationDefinition() }, [PropertyTarget.REQUEST_OBJECT, PropertyTarget.AUTHORIZATION_REQUEST])
.withPresentationVerification(presentationVerificationCallback)
.withWellknownDIDVerifyCallback(verifyCallback)
.withRevocationVerification(RevocationVerification.NEVER)
.withRequestBy(PassBy.VALUE)
.withInternalSignature(rpMockEntity.hexPrivateKey, rpMockEntity.did, rpMockEntity.didKey, SigningAlgo.ES256K)
.withAuthorizationEndpoint('www.myauthorizationendpoint.com')
.addDidMethod('ethr')
.withClientMetadata({
client_id: WELL_KNOWN_OPENID_FEDERATION,
idTokenSigningAlgValuesSupported: [SigningAlgo.EDDSA],
requestObjectSigningAlgValuesSupported: [SigningAlgo.EDDSA, SigningAlgo.ES256],
responseTypesSupported: [ResponseType.VP_TOKEN],
vpFormatsSupported: { jwt_vc: { alg: [SigningAlgo.EDDSA] } },
subjectTypesSupported: [SubjectType.PAIRWISE],
subject_syntax_types_supported: ['did', 'did:ethr'],
passBy: PassBy.VALUE,
logo_uri: VERIFIER_LOGO_FOR_CLIENT,
clientName: VERIFIER_NAME_FOR_CLIENT,
'clientName#nl-NL': VERIFIER_NAME_FOR_CLIENT_NL + '2022100322',
clientPurpose: VERIFIERZ_PURPOSE_TO_VERIFY,
'clientPurpose#nl-NL': VERIFIERZ_PURPOSE_TO_VERIFY_NL,
})
.withSupportedVersions(SupportedVersion.SIOPv2_ID1)
.build();
const op = OP.builder()
.withPresentationSignCallback(presentationSignCallback)
.withExpiresIn(1000)
.withHasher(hasher)
.withWellknownDIDVerifyCallback(verifyCallback)
.addDidMethod('ethr')
.withInternalSignature(opMockEntity.hexPrivateKey, opMockEntity.did, opMockEntity.didKey, SigningAlgo.ES256K)
.withRegistration({
authorizationEndpoint: 'www.myauthorizationendpoint.com',
idTokenSigningAlgValuesSupported: [SigningAlgo.EDDSA],
issuer: ResponseIss.SELF_ISSUED_V2,
requestObjectSigningAlgValuesSupported: [SigningAlgo.EDDSA, SigningAlgo.ES256],
responseTypesSupported: [ResponseType.ID_TOKEN, ResponseType.VP_TOKEN],
vpFormats: { jwt_vc: { alg: [SigningAlgo.EDDSA] } },
scopesSupported: [Scope.OPENID_DIDAUTHN, Scope.OPENID],
subjectTypesSupported: [SubjectType.PAIRWISE],
subject_syntax_types_supported: [],
passBy: PassBy.VALUE,
logo_uri: VERIFIER_LOGO_FOR_CLIENT,
clientName: VERIFIER_NAME_FOR_CLIENT,
'clientName#nl-NL': VERIFIER_NAME_FOR_CLIENT_NL + '2022100323',
clientPurpose: VERIFIERZ_PURPOSE_TO_VERIFY,
'clientPurpose#nl-NL': VERIFIERZ_PURPOSE_TO_VERIFY_NL,
})
.withSupportedVersions(SupportedVersion.SIOPv2_ID1)
.build();

const requestURI = await rp.createAuthorizationRequestURI({
correlationId: '1234',
nonce: 'qBrR7mqnY3Qr49dAZycPF8FzgE83m6H0c2l0bzP4xSg',
state: 'b32f0087fc9816eb813fd11f',
});

// Let's test the parsing
const parsedAuthReqURI = await op.parseAuthorizationRequestURI(requestURI.encodedUri);
expect(parsedAuthReqURI.authorizationRequestPayload).toBeDefined();
expect(parsedAuthReqURI.requestObjectJwt).toBeDefined();

const verifiedAuthReqWithJWT = await op.verifyAuthorizationRequest(parsedAuthReqURI.requestObjectJwt);
expect(verifiedAuthReqWithJWT.signer).toBeDefined();
expect(verifiedAuthReqWithJWT.issuer).toMatch(rpMockEntity.did);
const pex = new PresentationExchange({
allDIDs: [HOLDER_DID],
allVerifiableCredentials: getVCs(),
hasher,
});
const pd: PresentationDefinitionWithLocation[] = await PresentationExchange.findValidPresentationDefinitions(
parsedAuthReqURI.authorizationRequestPayload,
);
await pex.selectVerifiableCredentialsForSubmission(pd[0].definition);
const verifiablePresentationResult = await pex.createVerifiablePresentation(pd[0].definition, getVCs(), presentationSignCallback, {
proofOptions: {
nonce: 'qBrR7mqnY3Qr49dAZycPF8FzgE83m6H0c2l0bzP4xSg',
},
});
const authenticationResponseWithJWT = await op.createAuthorizationResponse(verifiedAuthReqWithJWT, {
presentationExchange: {
verifiablePresentations: [verifiablePresentationResult.verifiablePresentation],
vpTokenLocation: VPTokenLocation.AUTHORIZATION_RESPONSE,
presentationSubmission: verifiablePresentationResult.presentationSubmission,
},
});
expect(authenticationResponseWithJWT.response.payload).toBeDefined();
expect(authenticationResponseWithJWT.response.idToken).toBeUndefined();

const verifiedAuthResponseWithJWT = await rp.verifyAuthorizationResponse(authenticationResponseWithJWT.response.payload, {
presentationDefinitions: [{ definition: pd[0].definition, location: pd[0].location }],
});

expect(verifiedAuthResponseWithJWT.oid4vpSubmission.nonce).toEqual('qBrR7mqnY3Qr49dAZycPF8FzgE83m6H0c2l0bzP4xSg');
expect(verifiedAuthResponseWithJWT.idToken).toBeUndefined();
});
});
Loading