Skip to content

Commit

Permalink
fix: always verify nonce, extract nonce from VP
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <timo@animo.id>
  • Loading branch information
TimoGlastra committed Apr 11, 2024
1 parent d1aca96 commit c774452
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 18 deletions.
43 changes: 33 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,41 @@ 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') {
console.log(allNonces, firstNonce, merged.nonce, verifiedIdToken.payload.nonce, oid4vp.nonce);
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();
});
});

0 comments on commit c774452

Please sign in to comment.