Skip to content

Commit

Permalink
feat: hash validation on Verify page when key is missing (#153)
Browse files Browse the repository at this point in the history
* feat: Ensure hash validation on Verify page when key is missing from the payload

* test: change position of mock value in some test cases

* test: add some test cases to check hash feature in verify page

* refactor: remove unused code

* refactor: change the condition to check the hash in verify page

* refactor: deep copy object in verify page

* docs: update doc in verify app

* docs: update doc in verify app
  • Loading branch information
ldhyen99 authored Nov 5, 2024
1 parent dfd3059 commit 7518b02
Show file tree
Hide file tree
Showing 3 changed files with 291 additions and 33 deletions.
9 changes: 9 additions & 0 deletions documentation/docs/mock-apps/verify-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ sequenceDiagram
```

## Rendering Verified Credential

The UI of this page includes these information fields: Type, Issued by and Issue date. Besides, the page also contains the tab panel for HTML template, JSON data and the download button.

With the download button, it will download the JSON data or JWT data if the credential is JWT.

## Hash validation (if it exists in the URL)

To ensure the integrity of the credential, a hash value is included in the verification link. This hash value is generated using the SHA-256 algorithm, computed from the credential itself. The resulting hash is appended to the verification link as a query parameter.

Upon retrieval of the credential, the application will compute its hash and compare it with the provided value in the URL. If the computed hash matches the provided hash, the credential is considered valid and unaltered.

The hash is optional and can be omitted from the verification link. If the hash is not provided, the credential will not be validated against it.
265 changes: 254 additions & 11 deletions packages/mock-app/src/__tests__/Verify.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import * as jose from 'jose';
import { Router as RouterDom, useLocation } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { Router as RouterDom } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { UnsignedCredential, VerifiableCredential } from '@vckit/core-types';
import { computeHash, decryptCredential, publicAPI, verifyVC } from '@mock-app/services';
import Verify from '../pages/Verify';
import { privateAPI, publicAPI, verifyVC } from '@mock-app/services';
import { VerifiableCredential } from '@vckit/core-types';

console.error = jest.fn();
jest.mock('@mock-app/components', () => ({
Expand Down Expand Up @@ -31,18 +32,24 @@ jest.mock('@vckit/renderer', () => ({
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
// Mock the search query string

useLocation: jest.fn(() => ({
search:
'q=%7B"payload"%3A%7B"uri"%3A"http%3A%2F%2Flocalhost%3A3333%2Fv1%2Fverifiable-credentials%2Fd1fc233a-0e32-4c36-8131-2be5fef7a243.json"%7D%7D',
}),
})),
}));
jest.mock(
'../components/CredentialTabs/CredentialTabs',
() =>
({ credential }: { credential: VerifiableCredential }) => {
const name = credential.credentialSubject?.name;
const renderTemplate = credential.render.template;
({
credential,
decodedEnvelopedVC,
}: {
credential: VerifiableCredential;
decodedEnvelopedVC?: UnsignedCredential;
}) => {
const name = decodedEnvelopedVC?.credentialSubject?.name ?? credential?.credentialSubject?.name;
const renderTemplate = decodedEnvelopedVC?.render?.template ?? credential?.render?.template ?? '';
// Simulate the rendering of the credential with the name
const rendered = renderTemplate.replace('{{name}}', name);
return <div dangerouslySetInnerHTML={{ __html: rendered }} />;
Expand Down Expand Up @@ -141,7 +148,7 @@ describe('Verify', () => {
});
});

it('should render success screen for active credential', async () => {
it('should render success screen for active credential without hash', async () => {
jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockEncryptedCredential);
// Simulate the response of a verify VC request succeeding due to the active status.
(verifyVC as jest.Mock).mockImplementation(() => ({
Expand Down Expand Up @@ -184,4 +191,240 @@ describe('Verify', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('John Doe');
});
});

it('should render success screen for valid payload with hash and lds proof without key', async () => {
const mockPayloadValidHash = {
payload: {
uri: 'http://localhost:3334/v1/verifiable-credentials/6c70251a-f2e7-48a0-a86c-e1027f0e7143.json',
hash: 'c60a35053e0d9f64e2072ad1d995182169dc05eaeded065b128e006681149ba3',
},
};
// URL-encode the payload for use as a query parameter
const encodedPayload = `q=${encodeURIComponent(JSON.stringify(mockPayloadValidHash))}`;
(useLocation as any).mockImplementation(() => ({
search: encodedPayload,
}));

jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockEncryptedCredential);

(computeHash as any).mockImplementation(() => mockPayloadValidHash.payload.hash);

(verifyVC as jest.Mock).mockImplementation(() => ({
verified: true,
}));

await act(async () => {
render(
<RouterDom location={history.location} navigator={history}>
<Verify />
</RouterDom>,
);
});

await waitFor(() => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('John Doe');
});
});

it('should render success screen for valid hash and enveloping proof without key', async () => {
const mockPayloadValidHash = {
payload: {
uri: 'http://localhost:3334/v1/verifiable-credentials/6c70251a-f2e7-48a0-a86c-e1027f0e7143.json',
hash: '595d8d20c586c6f55f8a758f294674fa85069db5c518a0f4cbbd3fd61f46522f',
},
};
// URL-encode the payload for use as a query parameter
const encodedPayload = `q=${encodeURIComponent(JSON.stringify(mockPayloadValidHash))}`;
(useLocation as any).mockImplementation(() => ({
search: encodedPayload,
}));

const mockCredentialEnvelopingProof = {
'@context': ['https://www.w3.org/ns/credentials/v2', 'https://vocabulary.uncefact.org/untp/dpp/0.5.0/'],
type: 'EnvelopedVerifiableCredential',
id: 'data:application/vc-ld+jwt,eyJhbGciOiJIUzI1NiIsImlzcyI6ImRpZDp3ZWI6dW5jZWZhY3QuZ2l0aHViLmlvOnByb2plY3QtdmNraXQ6dGVzdC1hbmQtZGV2ZWxvcG1lbnQiLCJ0eXAiOiJ2Yy1sZCtqd3QifQ.eyJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cDovL2V4YW1wbGUuY29tL2JpdHN0cmluZy1zdGF0dXMtbGlzdC8xIzAiLCJ0eXBlIjoiQml0c3RyaW5nU3RhdHVzTGlzdEVudHJ5Iiwic3RhdHVzUHVycG9zZSI6InJldm9jYXRpb24iLCJzdGF0dXNMaXN0SW5kZXgiOjAsInN0YXR1c0xpc3RDcmVkZW50aWFsIjoiaHR0cDovL2V4YW1wbGUuY29tL2JpdHN0cmluZy1zdGF0dXMtbGlzdC8xIn0sInJlbmRlciI6eyJ0ZW1wbGF0ZSI6IjxoMT57e25hbWV9fTwvaDE-In0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsibmFtZSI6IkpvaG4gRG9lIn19.cn7DawmWIcyONNqMNMQrDISUMQjEmT7SqRn8kG1aAMk',
};
jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockCredentialEnvelopingProof);

(computeHash as any).mockImplementation(() => mockPayloadValidHash.payload.hash);

(verifyVC as jest.Mock).mockImplementation(() => ({
verified: true,
}));

(jose as any).decodeJwt.mockImplementation(() => ({
...mockEncryptedCredential,
}));

await act(async () => {
render(
<RouterDom location={history.location} navigator={history}>
<Verify />
</RouterDom>,
);
});

await waitFor(() => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('John Doe');
});
});

it('should render success screen for valid hash and enveloping proof with key', async () => {
const mockPayloadValidHash = {
payload: {
uri: 'http://localhost:3334/v1/verifiable-credentials/6c70251a-f2e7-48a0-a86c-e1027f0e7143.json',
hash: '595d8d20c586c6f55f8a758f294674fa85069db5c518a0f4cbbd3fd61f46522f',
key: 'key',
},
};
// URL-encode the payload for use as a query parameter
const encodedPayload = `q=${encodeURIComponent(JSON.stringify(mockPayloadValidHash))}`;
(useLocation as any).mockImplementation(() => ({
search: encodedPayload,
}));

const mockNewEncryptedCredential = {
cipherText:
'LGoWS4IlsMnp4OrMZ1Pm85hAS+y6iQ2kj3j0ZSQzpB8De30T/1IbQr42XPlUZSxqP2El9qBFvRz9sk0yQ4jGTw8jLKLEv9ZGu3svfj/oAygVB8hexOLQc6fHq0Jw5h+zXTNye6syOfaq7+jxGOJC3xBjOuqfqprnw6Idli6GAJ/LOdj+/C/OZoEMNvEEH+l51MWWBz2m3J5RxvfeNnaqfKylfYquf0Ajk/Eba6QtVFGrMcgY6kkgQu4FCkWMHwS89vDy4guEzecYQYXn4WtCJc0lnIMwYzIbVs9Sm03lIwk60nKyt1XU1Cu8tQyjGjOl6RiODsNjq1yNXFxUXwf75wAcwdY0qpsFD79MWyPHnOQApQxvwJx3a4exjSV+36y4Zjtv/6lu9Epq25+kEwdlevRSYU5KYg7tMhG7sDtyOvHJ9WksBX6O5OuZ1rzP89l/+0vDagdeiF4XbtAo9CcdfeRvxPzEaw7X55DdpVzv9YVYuMSi/IKxa3XLbkmR2eBIUz/ZdxpXXoMmnovSs4tHAwJzu3U5KMf2/dfjCpFbOwUZQ/j+RNlDFeQMuQzW8Xd2l3IfAHev0SXR5td1hvC1RbNj4loQHWugdbwMXf7AG7DhEmK7F3u0deNyuPlayqRUkxC8sWTiWlzJz9vM0KEb4dB9giGkPdtXgLqk3paiGk4Tqa9218yaP9E+wF0lcu0NElcR3nlW2aEsPFFQddbuD8jRHPDThCeH20+9mfNL3FcyllviK4dBjysWURc0tXeAWoloxwcphqIgjyDn3xZHJAfPKfIz+i8q3vgp15eAYs3fIV+GUp4r3bAk4qoIVx0cOn/Oea2XXwsp3zdNk3V+1rDKZdGmXFwQBCbutXOBrOpgZMaNgMVL9iq4umVIRZAXhG1mW9reLycqUMdoxIVvjqWbc5F+7uAUHwZCNBpmuLFVuK/hJbFBMb7/aDCA4P+9PUGEYAAfo8ZDrg5uRLX0vcAeYqB0WlgAdAH7/Tw8mSbyMoV8U7oNf5BNrpHvgAqGQjLmF9884nt6vRK0/p3BWV0ClLWND5DTQUfdTHqDIBnp1ZnlWGIZinhzNTg=',
iv: 'qeMaTopj5mExcu4/',
tag: 'ARRrKWEDQVZrSh1w0+L7VA==',
type: 'aes-256-gcm',
};
jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockNewEncryptedCredential);

(computeHash as any).mockImplementation(() => mockPayloadValidHash.payload.hash);

(decryptCredential as any).mockImplementation(() => JSON.stringify(mockEncryptedCredential));

(verifyVC as jest.Mock).mockImplementation(() => ({
verified: true,
}));

await act(async () => {
render(
<RouterDom location={history.location} navigator={history}>
<Verify />
</RouterDom>,
);
});

await waitFor(() => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('John Doe');
});
});

it('should render success screen for valid enveloping proof without hash and key', async () => {
const mockPayloadWithoutHash = {
payload: {
uri: 'http://localhost:3334/v1/verifiable-credentials/6c70251a-f2e7-48a0-a86c-e1027f0e7143.json',
},
};
// URL-encode the payload for use as a query parameter
const encodedPayload = `q=${encodeURIComponent(JSON.stringify(mockPayloadWithoutHash))}`;
(useLocation as any).mockImplementation(() => ({
search: encodedPayload,
}));

const mockCredentialEnvelopingProof = {
'@context': ['https://www.w3.org/ns/credentials/v2', 'https://vocabulary.uncefact.org/untp/dpp/0.5.0/'],
type: 'EnvelopedVerifiableCredential',
id: 'data:application/vc-ld+jwt,eyJhbGciOiJIUzI1NiIsImlzcyI6ImRpZDp3ZWI6dW5jZWZhY3QuZ2l0aHViLmlvOnByb2plY3QtdmNraXQ6dGVzdC1hbmQtZGV2ZWxvcG1lbnQiLCJ0eXAiOiJ2Yy1sZCtqd3QifQ.eyJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cDovL2V4YW1wbGUuY29tL2JpdHN0cmluZy1zdGF0dXMtbGlzdC8xIzAiLCJ0eXBlIjoiQml0c3RyaW5nU3RhdHVzTGlzdEVudHJ5Iiwic3RhdHVzUHVycG9zZSI6InJldm9jYXRpb24iLCJzdGF0dXNMaXN0SW5kZXgiOjAsInN0YXR1c0xpc3RDcmVkZW50aWFsIjoiaHR0cDovL2V4YW1wbGUuY29tL2JpdHN0cmluZy1zdGF0dXMtbGlzdC8xIn0sInJlbmRlciI6eyJ0ZW1wbGF0ZSI6IjxoMT57e25hbWV9fTwvaDE-In0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsibmFtZSI6IkpvaG4gRG9lIn19.cn7DawmWIcyONNqMNMQrDISUMQjEmT7SqRn8kG1aAMk',
};
jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockCredentialEnvelopingProof);

(verifyVC as jest.Mock).mockImplementation(() => ({
verified: true,
}));

(jose as any).decodeJwt.mockImplementation(() => ({
...mockEncryptedCredential,
}));

await act(async () => {
render(
<RouterDom location={history.location} navigator={history}>
<Verify />
</RouterDom>,
);
});

await waitFor(() => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('John Doe');
});
});

it('should render success screen for valid enveloping proof and key without hash', async () => {
const mockPayloadWithoutHash = {
payload: {
uri: 'http://localhost:3334/v1/verifiable-credentials/6c70251a-f2e7-48a0-a86c-e1027f0e7143.json',
key: 'key',
},
};

// URL-encode the payload for use as a query parameter
const encodedPayload = `q=${encodeURIComponent(JSON.stringify(mockPayloadWithoutHash))}`;
(useLocation as any).mockImplementation(() => ({
search: encodedPayload,
}));

const mockNewEncryptedCredential = {
cipherText:
'LGoWS4IlsMnp4OrMZ1Pm85hAS+y6iQ2kj3j0ZSQzpB8De30T/1IbQr42XPlUZSxqP2El9qBFvRz9sk0yQ4jGTw8jLKLEv9ZGu3svfj/oAygVB8hexOLQc6fHq0Jw5h+zXTNye6syOfaq7+jxGOJC3xBjOuqfqprnw6Idli6GAJ/LOdj+/C/OZoEMNvEEH+l51MWWBz2m3J5RxvfeNnaqfKylfYquf0Ajk/Eba6QtVFGrMcgY6kkgQu4FCkWMHwS89vDy4guEzecYQYXn4WtCJc0lnIMwYzIbVs9Sm03lIwk60nKyt1XU1Cu8tQyjGjOl6RiODsNjq1yNXFxUXwf75wAcwdY0qpsFD79MWyPHnOQApQxvwJx3a4exjSV+36y4Zjtv/6lu9Epq25+kEwdlevRSYU5KYg7tMhG7sDtyOvHJ9WksBX6O5OuZ1rzP89l/+0vDagdeiF4XbtAo9CcdfeRvxPzEaw7X55DdpVzv9YVYuMSi/IKxa3XLbkmR2eBIUz/ZdxpXXoMmnovSs4tHAwJzu3U5KMf2/dfjCpFbOwUZQ/j+RNlDFeQMuQzW8Xd2l3IfAHev0SXR5td1hvC1RbNj4loQHWugdbwMXf7AG7DhEmK7F3u0deNyuPlayqRUkxC8sWTiWlzJz9vM0KEb4dB9giGkPdtXgLqk3paiGk4Tqa9218yaP9E+wF0lcu0NElcR3nlW2aEsPFFQddbuD8jRHPDThCeH20+9mfNL3FcyllviK4dBjysWURc0tXeAWoloxwcphqIgjyDn3xZHJAfPKfIz+i8q3vgp15eAYs3fIV+GUp4r3bAk4qoIVx0cOn/Oea2XXwsp3zdNk3V+1rDKZdGmXFwQBCbutXOBrOpgZMaNgMVL9iq4umVIRZAXhG1mW9reLycqUMdoxIVvjqWbc5F+7uAUHwZCNBpmuLFVuK/hJbFBMb7/aDCA4P+9PUGEYAAfo8ZDrg5uRLX0vcAeYqB0WlgAdAH7/Tw8mSbyMoV8U7oNf5BNrpHvgAqGQjLmF9884nt6vRK0/p3BWV0ClLWND5DTQUfdTHqDIBnp1ZnlWGIZinhzNTg=',
iv: 'qeMaTopj5mExcu4/',
tag: 'ARRrKWEDQVZrSh1w0+L7VA==',
type: 'aes-256-gcm',
};
jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockNewEncryptedCredential);

(decryptCredential as any).mockImplementation(() => JSON.stringify(mockEncryptedCredential));
(verifyVC as jest.Mock).mockImplementation(() => ({
verified: true,
}));

await act(async () => {
render(
<RouterDom location={history.location} navigator={history}>
<Verify />
</RouterDom>,
);
});

await waitFor(() => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('John Doe');
});
});

it('should show error screen when payload lacks key and hash is invalid', async () => {
const mockPayloadInvalidHash = {
payload: {
uri: 'http://localhost:3334/v1/verifiable-credentials/6c70251a-f2e7-48a0-a86c-e1027f0e7143.json',
hash: 'invalid-hash',
},
};
// URL-encode the payload for use as a query parameter
const encodedPayload = `q=${encodeURIComponent(JSON.stringify(mockPayloadInvalidHash))}`;
(useLocation as any).mockImplementation(() => ({
search: encodedPayload,
}));

jest.spyOn(publicAPI, 'get').mockResolvedValueOnce(mockEncryptedCredential);

(computeHash as any).mockImplementation(() => 'valid-hash');

(verifyVC as jest.Mock).mockImplementation(() => ({
verified: true,
}));

await act(async () => {
render(
<RouterDom location={history.location} navigator={history}>
<Verify />
</RouterDom>,
);
});

await waitFor(() => {
expect(screen.getByText('Hash invalid')).toBeInTheDocument();
});
});
});
50 changes: 28 additions & 22 deletions packages/mock-app/src/pages/Verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Status } from '@mock-app/components';
import { computeHash, decryptCredential, publicAPI, verifyVC } from '@mock-app/services';
import { IVerifyResult, VerifiableCredential } from '@vckit/core-types';
import * as jose from 'jose';
import _ from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { BackButton } from '../components/BackButton';
Expand Down Expand Up @@ -54,34 +55,39 @@ const Verify = () => {
const { payload } = JSON.parse(payloadQuery);
const { uri, key, hash } = payload;
const encryptedCredential = await publicAPI.get(uri);
if (encryptedCredential?.credentialSubject) {
return setCredential(encryptedCredential);
}

if (
encryptedCredential?.type?.includes('EnvelopedVerifiableCredential') &&
encryptedCredential?.id?.startsWith('data:application/')
) {
return setCredential(encryptedCredential);
}
const verifyHash = (credential: VerifiableCredential) => {
if (hash) {
const computedHash = computeHash(credential);
if (computedHash !== hash) return displayErrorUI(['Hash invalid']);
}

return setCredential(credential);
};

const { cipherText, iv, tag, type } = encryptedCredential;

const credentialJsonString = decryptCredential({
cipherText,
key,
iv,
tag,
type,
});

const credentialObject = JSON.parse(credentialJsonString);
const credentialHash = computeHash(credentialObject);
if (credentialHash !== hash) {
return displayErrorUI();
let credentialObject;
if (
'cipherText' in encryptedCredential &&
'iv' in encryptedCredential &&
'tag' in encryptedCredential &&
'type' in encryptedCredential
) {
const credentialJsonString = decryptCredential({
cipherText,
key,
iv,
tag,
type,
});

credentialObject = JSON.parse(credentialJsonString);
} else {
credentialObject = _.cloneDeep(encryptedCredential);
}

setCredential(credentialObject);
return verifyHash(credentialObject);
} catch (error) {
displayErrorUI();
}
Expand Down

0 comments on commit 7518b02

Please sign in to comment.