Skip to content
Open
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
2 changes: 1 addition & 1 deletion __tests__/example/example5.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as crypto from 'crypto';
import fs from 'fs';
import { Verifier, Document, MDoc, DeviceResponse } from '../../src/index';

describe('example 5: device response contains a partial x5chain of the issuer certificate', () => {
describe('example 5: device response (using presentationDefinition) contains a partial x5chain of the issuer certificate', () => {
it('issuer signature should be valid', async () => {
const devicePrivatePEM = '-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIKWuHzvetdYpe5cErlOrU1bipA0OFtbBpJBdXCzRIVbz\n-----END PRIVATE KEY-----';
const devicePrivateKey = await jose.exportJWK(crypto.createPrivateKey({ key: devicePrivatePEM }));
Expand Down
80 changes: 80 additions & 0 deletions __tests__/example/example6.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as jose from 'jose';
import * as crypto from 'crypto';
import fs from 'fs';
import { Verifier, Document, MDoc, DeviceResponse } from '../../src/index';
import { DcqlQuery } from '../../src/mdoc/model/DcqlQuery';

describe('example 6: device response (using dcqlQuery) contains a partial x5chain of the issuer certificate', () => {
it('issuer signature should be valid', async () => {
const devicePrivatePEM = '-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIKWuHzvetdYpe5cErlOrU1bipA0OFtbBpJBdXCzRIVbz\n-----END PRIVATE KEY-----';
const devicePrivateKey = await jose.exportJWK(crypto.createPrivateKey({ key: devicePrivatePEM }));
const devicePublicKey: jose.JWK = {
kty: devicePrivateKey.kty,
crv: devicePrivateKey.crv,
x: devicePrivateKey.x,
};

// A test IACA Root Certificate that the Issuer has shared publicly
// The openssl command to generate this certificate can be found @ https://github.com/auth0-lab/mdl/issues/37#issuecomment-2618717656
const issuerIacaRootCertificate = '-----BEGIN CERTIFICATE-----\nMIICuzCCAh2gAwIBAgIUS9ewqx43m6VHiP5koCeEd09JOIwwCgYIKoZIzj0EAwQw\nOzELMAkGA1UEBhMCVVMxDDAKBgNVBAoMA01ETDEeMBwGA1UEAwwVSUFDQSBSb290\nIENlcnRpZmljYXRlMB4XDTI1MDEyODEyNTUzNloXDTM1MDEyNjEyNTUzNlowOzEL\nMAkGA1UEBhMCVVMxDDAKBgNVBAoMA01ETDEeMBwGA1UEAwwVSUFDQSBSb290IENl\ncnRpZmljYXRlMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBBt6kxI26+zkCddEN\ntuCUddthenpnDC7yT2ZgNvisCl6ZXRYI+oljvxgky53SZ18qixN4jtvnUOx/UuE3\nYDo0pZMBjk7CX2aKE91tG3kMt0G0LRnHSnkioCTPcDd67pN5myw8oEGHF5PQC9ai\nRZd30V4faCz+kZGO0ilLWGL0EElWILKjgbswgbgwHQYDVR0OBBYEFLvAYj7DJGBy\nMlzxdrwHYrSRPb03MHYGA1UdIwRvMG2AFLvAYj7DJGByMlzxdrwHYrSRPb03oT+k\nPTA7MQswCQYDVQQGEwJVUzEMMAoGA1UECgwDTURMMR4wHAYDVQQDDBVJQUNBIFJv\nb3QgQ2VydGlmaWNhdGWCFEvXsKseN5ulR4j+ZKAnhHdPSTiMMA8GA1UdEwEB/wQF\nMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMEA4GLADCBhwJCAbauH2Uj\noaB7fcKJqBgXqWfSXm5wqo6tEECM4gjtmIKPJnVSBbfcTn2bw7zIl2eBHzRdPX85\nPliPBGxjzyAoQcZ3AkES0U2MiwkDdoyUrb3k3jGOo02ayiCmtfy7y1OVZQWaH2HF\nMgQBbITyFHAZ0HUNpWIT+8527nB7POnJkguMovTIcA==\n-----END CERTIFICATE-----\n';
const anotherIssuerRootCertificate = fs.readFileSync(`${__dirname}/issuer.pem`, 'utf-8');

// A test Document Signing Certificate that has been signed by the IACA Root Certificate above
const issuerDocumentSigningCertificate = '-----BEGIN CERTIFICATE-----\nMIIB1zCCATigAwIBAgIURoTE4I1tg1T7wyVF6YJxJqu2SLgwCgYIKoZIzj0EAwIw\nOzELMAkGA1UEBhMCVVMxDDAKBgNVBAoMA01ETDEeMBwGA1UEAwwVSUFDQSBSb290\nIENlcnRpZmljYXRlMB4XDTI1MDEyODEyNTU0M1oXDTI2MDEyODEyNTU0M1owQjEL\nMAkGA1UEBhMCVVMxDDAKBgNVBAoMA01ETDElMCMGA1UEAwwcRG9jdW1lbnQgU2ln\nbmluZyBDZXJ0aWZpY2F0ZTAqMAUGAytlcAMhACis1tWdJ2MW+6zokonq9bxhsLO5\nR6E0bFiLnYEWR4t+o0IwQDAdBgNVHQ4EFgQUgxDu5pET///uNotJBKr1gZOZNogw\nHwYDVR0jBBgwFoAUu8BiPsMkYHIyXPF2vAditJE9vTcwCgYIKoZIzj0EAwIDgYwA\nMIGIAkIA4YMSiBuUGrfU1UKeCbYwzp0ZoQhcL+HNCEtgLFW6LtDB4tP+T9A/O5bS\nWV6P+e3mWti13BKCraPRUkKVQA1qyNcCQgFXTRr1Xt+ufVjl1XqnJo0KITN91TyL\n4GKJeBxGYWDFgyvbpCNUs5XeiGejkvhz/8E0fYzCqZmqEIlp6IcvgV8c0A==\n-----END CERTIFICATE-----\n';
const issuerDocumentSigningKeyPem = '-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIEQLHY7fwKG6Av4VP3uByNKMyS7/sJKk4ntbzL8nSq0t\n-----END PRIVATE KEY-----\n';
const issuerDocumentSigningKey = await jose.exportJWK(crypto.createPrivateKey({ key: issuerDocumentSigningKeyPem }));

const document = new Document('org.iso.18013.5.1.mDL')
.addIssuerNameSpace('org.iso.18013.5.1', {
family_name: 'Jones',
given_name: 'Ava',
birth_date: '2007-03-25',
})
.useDigestAlgorithm('SHA-256')
.addValidityInfo({
signed: new Date(),
})
.addDeviceKeyInfo({ deviceKey: devicePublicKey });

const signedDoc = await document.sign({
issuerPrivateKey: issuerDocumentSigningKey,
issuerCertificate: issuerDocumentSigningCertificate,
alg: 'EdDSA',
});

const issuerMDoc = new MDoc([signedDoc]).encode();
const dcqlQuery: DcqlQuery = {
credentials: [
{
id: 'family_name_only',
format: 'mso_mdoc',
meta: {
doctype_value: 'org.iso.18013.5.1.mDL',
},
claims: [
{
path: ['org.iso.18013.5.1', 'family_name'],
intent_to_retain: false,
},
],
},
],
};

const deviceResponseMDoc = await DeviceResponse.from(issuerMDoc)
.usingDcqlQuery(dcqlQuery)
.usingSessionTranscriptForOID4VP('', '', '', '')
.authenticateWithSignature(devicePrivateKey, 'EdDSA')
.sign();

const verifier = new Verifier([issuerIacaRootCertificate, anotherIssuerRootCertificate]);
const diagnosticInfo = await verifier.getDiagnosticInformation(deviceResponseMDoc.encode(), {});

expect(diagnosticInfo.issuerSignature).toEqual({
alg: 'EdDSA',
digests: { 'org.iso.18013.5.1': 3 },
isValid: true,
reasons: [],
});
});
});
26 changes: 26 additions & 0 deletions __tests__/issuing/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,29 @@ export const PRESENTATION_DEFINITION_2 = {
},
],
};

export const DCQL_QUERY = {
credentials: [
{
id: 'mdl-test-all-data-dcql',
format: 'mso_mdoc',
meta: {
doctype_value: 'org.iso.18013.5.1.mDL',
},
claims: [
{ path: ["$['org.iso.18013.5.1']['family_name']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['given_name']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['birth_date']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['issue_date']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['expiry_date']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['issuing_country']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['issuing_authority']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['issuing_jurisdiction']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['document_number']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['portrait']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['driving_privileges']"], intent_to_retain: false },
{ path: ["$['org.iso.18013.5.1']['un_distinguishing_sign']"], intent_to_retain: false },
],
},
],
};
238 changes: 238 additions & 0 deletions __tests__/issuing/deviceResponseWithDcql.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { randomFillSync } from 'node:crypto';
import * as jose from 'jose';
import {
MDoc,
Document,
Verifier,
parse,
DeviceResponse,
DeviceSignedDocument,
} from '../../src';
import { DEVICE_JWK, ISSUER_CERTIFICATE, ISSUER_PRIVATE_KEY_JWK, DCQL_QUERY } from './config';
import { DataItem, cborEncode } from '../../src/cbor';

const { d, ...publicKeyJWK } = DEVICE_JWK as jose.JWK;

describe('issuing a device response', () => {
let encoded: Uint8Array;
let parsedDocument: DeviceSignedDocument;
let mdoc: MDoc;

const signed = new Date('2023-10-24T14:55:18Z');
const validUntil = new Date(signed);
validUntil.setFullYear(signed.getFullYear() + 30);

beforeAll(async () => {
const issuerPrivateKey = ISSUER_PRIVATE_KEY_JWK;

// this is the ISSUER side
{
const document = await new Document('org.iso.18013.5.1.mDL')
.addIssuerNameSpace('org.iso.18013.5.1', {
family_name: 'Jones',
given_name: 'Ava',
birth_date: '2007-03-25',
issue_date: '2023-09-01',
expiry_date: '2028-09-30',
issuing_country: 'US',
issuing_authority: 'NY DMV',
document_number: '01-856-5050',
portrait: 'bstr',
driving_privileges: [
{
vehicle_category_code: 'C',
issue_date: '2022-09-02',
expiry_date: '2027-09-20',
},
],
un_distinguishing_sign: 'tbd-us.ny.dmv',

sex: 'F',
height: '5\' 8"',
weight: '120lb',
eye_colour: 'brown',
hair_colour: 'brown',
resident_addres: '123 Street Rd',
resident_city: 'Brooklyn',
resident_state: 'NY',
resident_postal_code: '19001',
resident_country: 'US',
issuing_jurisdiction: 'New York',
})
.useDigestAlgorithm('SHA-512')
.addValidityInfo({
signed,
validUntil,
})
.addDeviceKeyInfo({ deviceKey: publicKeyJWK })
.sign({
issuerPrivateKey,
issuerCertificate: ISSUER_CERTIFICATE,
alg: 'ES256',
});

mdoc = new MDoc([document]);
}
});

describe('using OID4VP handover', () => {
const verifierGeneratedNonce = 'abcdefg';
const mdocGeneratedNonce = '123456';
const clientId = 'Cq1anPb8vZU5j5C0d7hcsbuJLBpIawUJIDQRi2Ebwb4';
const responseUri = 'http://localhost:4000/api/presentation_request/dc8999df-d6ea-4c84-9985-37a8b81a82ec/callback';

const getSessionTranscriptBytes = (clId: string, respUri: string, nonce: string, mdocNonce: string) => cborEncode(
DataItem.fromData([
null, // DeviceEngagementBytes
null, // EReaderKeyBytes
[mdocNonce, clId, respUri, nonce], // Handover = OID4VPHandover
]),
);

beforeAll(async () => {
// This is the Device side
const devicePrivateKey = DEVICE_JWK;
const deviceResponseMDoc = await DeviceResponse.from(mdoc)
.usingDcqlQuery(DCQL_QUERY)
.usingSessionTranscriptForOID4VP(mdocGeneratedNonce, clientId, responseUri, verifierGeneratedNonce)
.authenticateWithSignature(devicePrivateKey, 'ES256')
.addDeviceNameSpace('com.foobar-device', { test: 1234 })
.sign();

encoded = deviceResponseMDoc.encode();
const parsedMDOC = parse(encoded);
[parsedDocument] = parsedMDOC.documents as DeviceSignedDocument[];
});

it('should be verifiable', async () => {
const verifier = new Verifier([ISSUER_CERTIFICATE]);
await verifier.verify(encoded, {
encodedSessionTranscript: getSessionTranscriptBytes(clientId, responseUri, verifierGeneratedNonce, mdocGeneratedNonce),
});
});

describe('should not be verifiable', () => {
[
['clientId', { clientId: 'wrong', responseUri, verifierGeneratedNonce, mdocGeneratedNonce }] as const,
['responseUri', { clientId, responseUri: 'wrong', verifierGeneratedNonce, mdocGeneratedNonce }] as const,
['verifierGeneratedNonce', { clientId, responseUri, verifierGeneratedNonce: 'wrong', mdocGeneratedNonce }] as const,
['mdocGeneratedNonce', { clientId, responseUri, verifierGeneratedNonce, mdocGeneratedNonce: 'wrong' }] as const,
].forEach(([name, values]) => {
it(`with a different ${name}`, async () => {
try {
const verifier = new Verifier([ISSUER_CERTIFICATE]);
await verifier.verify(encoded, {
encodedSessionTranscript: getSessionTranscriptBytes(values.clientId, values.responseUri, values.verifierGeneratedNonce, values.mdocGeneratedNonce),
});
throw new Error('should not validate with different transcripts');
} catch (error) {
expect(error.message).toMatch('Unable to verify deviceAuth signature (ECDSA/EdDSA): Device signature must be valid');
}
});
});
});

it('should contain the validity info', () => {
const { validityInfo } = parsedDocument.issuerSigned.issuerAuth.decodedPayload;
expect(validityInfo).toBeDefined();
expect(validityInfo.signed).toEqual(signed);
expect(validityInfo.validFrom).toEqual(signed);
expect(validityInfo.validUntil).toEqual(validUntil);
expect(validityInfo).not.toHaveProperty('expectedUpdate');
});

it('should contain the device namespaces', () => {
expect(parsedDocument.getDeviceNameSpace('com.foobar-device'))
.toEqual({ test: 1234 });
});

it('should generate the signature without payload', () => {
expect(parsedDocument.deviceSigned.deviceAuth.deviceSignature?.payload).toBeNull();
});
});

describe('using WebAPI handover', () => {
// The actual value for the engagements & the key do not matter,
// as long as the device and the reader agree on what value to use.
const eReaderKeyBytes: Buffer = randomFillSync(Buffer.alloc(32));
const readerEngagementBytes = randomFillSync(Buffer.alloc(32));
const deviceEngagementBytes = randomFillSync(Buffer.alloc(32));

const getSessionTranscriptBytes = (
rdrEngtBytes: Buffer,
devEngtBytes: Buffer,
eRdrKeyBytes: Buffer,
) => cborEncode(
DataItem.fromData([
new DataItem({ buffer: devEngtBytes }),
new DataItem({ buffer: eRdrKeyBytes }),
rdrEngtBytes,
]),
);

beforeAll(async () => {
// Nothing more to do on the verifier side.

// This is the Device side
{
const devicePrivateKey = DEVICE_JWK;
const deviceResponseMDoc = await DeviceResponse.from(mdoc)
.usingDcqlQuery(DCQL_QUERY)
.usingSessionTranscriptForWebAPI(deviceEngagementBytes, readerEngagementBytes, eReaderKeyBytes)
.authenticateWithSignature(devicePrivateKey, 'ES256')
.addDeviceNameSpace('com.foobar-device', { test: 1234 })
.sign();
encoded = deviceResponseMDoc.encode();
}

const parsedMDOC = parse(encoded);
[parsedDocument] = parsedMDOC.documents as DeviceSignedDocument[];
});

it('should be verifiable', async () => {
const verifier = new Verifier([ISSUER_CERTIFICATE]);
await verifier.verify(encoded, {
encodedSessionTranscript: getSessionTranscriptBytes(readerEngagementBytes, deviceEngagementBytes, eReaderKeyBytes),
});
});

describe('should not be verifiable', () => {
const wrong = randomFillSync(Buffer.alloc(32));
[
['readerEngagementBytes', { readerEngagementBytes: wrong, deviceEngagementBytes, eReaderKeyBytes }] as const,
['deviceEngagementBytes', { readerEngagementBytes, deviceEngagementBytes: wrong, eReaderKeyBytes }] as const,
['eReaderKeyBytes', { readerEngagementBytes, deviceEngagementBytes, eReaderKeyBytes: wrong }] as const,
].forEach(([name, values]) => {
it(`with a different ${name}`, async () => {
const verifier = new Verifier([ISSUER_CERTIFICATE]);
try {
await verifier.verify(encoded, {
encodedSessionTranscript: getSessionTranscriptBytes(values.readerEngagementBytes, values.deviceEngagementBytes, values.eReaderKeyBytes),
});
throw new Error('should not validate with different transcripts');
} catch (error) {
expect(error.message).toMatch('Unable to verify deviceAuth signature (ECDSA/EdDSA): Device signature must be valid');
}
});
});
});

it('should contain the validity info', () => {
const { validityInfo } = parsedDocument.issuerSigned.issuerAuth.decodedPayload;
expect(validityInfo).toBeDefined();
expect(validityInfo.signed).toEqual(signed);
expect(validityInfo.validFrom).toEqual(signed);
expect(validityInfo.validUntil).toEqual(validUntil);
expect(validityInfo).not.toHaveProperty('expectedUpdate');
});

it('should contain the device namespaces', () => {
expect(parsedDocument.getDeviceNameSpace('com.foobar-device'))
.toEqual({ test: 1234 });
});

it('should generate the signature without payload', () => {
expect(parsedDocument.deviceSigned.deviceAuth.deviceSignature?.payload).toBeNull();
});
});
});
32 changes: 32 additions & 0 deletions src/mdoc/model/DcqlQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type DcqlQuery = {
credentials: DcqlQueryCredential[];
credential_sets?: DcqlCredentialSetQuery[];
};

export type DcqlQueryCredential = {
id: string;
format: string;
multiple?: boolean;
meta: {
vct_values?: string[];
doctype_value?: string;
};
trusted_authorities?: any;
require_cryptographic_holder_binding?: boolean;
claims: DcqlClaim[];
claim_sets?: string[][];
};

export type DcqlCredentialSetQuery = {
options: string[][];
required?: boolean;
};

export type DcqlClaim = {
id: string;
path: string[];
values?: (string | number | boolean)[];
intent_to_retain?: boolean;
};


Loading