diff --git a/src/authorization-response/AuthorizationResponse.ts b/src/authorization-response/AuthorizationResponse.ts index 8f7d306..4f04191 100644 --- a/src/authorization-response/AuthorizationResponse.ts +++ b/src/authorization-response/AuthorizationResponse.ts @@ -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( @@ -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, + }, }); } @@ -129,7 +138,10 @@ export class AuthorizationResponse { public async verify(verifyOpts: VerifyAuthorizationResponseOpts): Promise { // 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); } @@ -137,19 +149,30 @@ export class AuthorizationResponse { 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(); + 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 }), diff --git a/src/authorization-response/OpenID4VP.ts b/src/authorization-response/OpenID4VP.ts index 0f0dec4..fb4e602 100644 --- a/src/authorization-response/OpenID4VP.ts +++ b/src/authorization-response/OpenID4VP.ts @@ -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'; @@ -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 => { +): Promise => { const presentations = await extractPresentationsFromAuthorizationResponse(authorizationResponse, { hasher: verifyOpts.hasher }); const presentationDefinitions = verifyOpts.presentationDefinitions ? Array.isArray(verifyOpts.presentationDefinitions) @@ -53,12 +91,22 @@ export const verifyPresentations = async ( }, }); - const nonces: Set = 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 diff --git a/test/IT.spec.ts b/test/IT.spec.ts index 31af940..131c105 100644 --- a/test/IT.spec.ts +++ b/test/IT.spec.ts @@ -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', diff --git a/test/SdJwt.spec.ts b/test/SdJwt.spec.ts index 3e571cf..8a0f387 100644 --- a/test/SdJwt.spec.ts +++ b/test/SdJwt.spec.ts @@ -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 { @@ -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], @@ -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 => ({ 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(); + }); });