@sd-jwt/sd-jwt-vc
Version:
sd-jwt draft 7 implementation in typescript
685 lines (598 loc) • 21 kB
text/typescript
import Crypto from 'node:crypto';
import { afterEach } from 'node:test';
import { hasher as digest, generateSalt } from '@owf/crypto';
import type { DisclosureFrame, Signer, Verifier } from '@sd-jwt/core';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, beforeAll, describe, expect, test, vitest } from 'vitest';
import { SDJwtVcInstance } from '..';
import type { SdJwtVcPayload } from '../sd-jwt-vc-payload';
import type { TypeMetadataFormat } from '../sd-jwt-vc-type-metadata-format';
const exampleVctm = {
vct: 'http://example.com/example',
name: 'ExampleCredentialType',
description: 'An example credential type',
};
const baseVctm: TypeMetadataFormat = {
vct: 'http://example.com/base',
name: 'BaseCredentialType',
description: 'A base credential type',
claims: [
{
path: ['firstName'],
display: [{ locale: 'en', label: 'First Name' }],
},
],
display: [
{
locale: 'en',
name: 'Base Credential',
description: 'Base description',
},
],
};
const extendingVctm: TypeMetadataFormat = {
vct: 'http://example.com/extending',
name: 'ExtendingCredentialType',
description: 'A credential type that extends the base',
extends: 'http://example.com/base',
claims: [
{
path: ['lastName'],
display: [{ locale: 'en', label: 'Last Name' }],
},
],
display: [
{
locale: 'en',
name: 'Extended Credential',
description: 'Extended description',
},
{
locale: 'de',
name: 'Erweiterte Berechtigung',
description: 'Erweiterte Beschreibung',
},
],
};
const middleVctm: TypeMetadataFormat = {
vct: 'http://example.com/middle',
name: 'MiddleCredentialType',
description: 'Middle type in chain',
extends: 'http://example.com/extending',
claims: [
{
path: ['age'],
display: [{ locale: 'en', label: 'Age' }],
},
],
};
const overridingVctm: TypeMetadataFormat = {
vct: 'http://example.com/overriding',
name: 'OverridingCredentialType',
description: 'A credential type that overrides a claim from the base',
extends: 'http://example.com/base',
claims: [
{
path: ['firstName'],
display: [{ locale: 'en', label: 'Given Name' }], // Override with different label
sd: 'always' as const,
},
{
path: ['middleName'],
display: [{ locale: 'en', label: 'Middle Name' }],
},
],
};
const circularVctm: TypeMetadataFormat = {
vct: 'http://example.com/circular',
name: 'CircularCredentialType',
extends: 'http://example.com/circular',
};
const deepVctm: TypeMetadataFormat = {
vct: 'http://example.com/deep',
name: 'DeepCredentialType',
extends: 'http://example.com/middle',
};
const baseWithSdAlways: TypeMetadataFormat = {
vct: 'http://example.com/base-sd-always',
name: 'BaseWithSdAlways',
claims: [
{
path: ['sensitiveData'],
sd: 'always' as const,
display: [{ locale: 'en', label: 'Sensitive Data' }],
},
],
};
const invalidExtendingSdChange: TypeMetadataFormat = {
vct: 'http://example.com/invalid-sd-change',
name: 'InvalidSdChange',
extends: 'http://example.com/base-sd-always',
claims: [
{
path: ['sensitiveData'],
sd: 'never' as const, // Invalid: trying to change from 'always' to 'never'
display: [{ locale: 'en', label: 'Sensitive Data' }],
},
],
};
const validExtendingSdChange: TypeMetadataFormat = {
vct: 'http://example.com/valid-sd-change',
name: 'ValidSdChange',
extends: 'http://example.com/base',
claims: [
{
path: ['firstName'],
sd: 'always' as const, // Valid: base doesn't have sd or has 'allowed'
display: [{ locale: 'en', label: 'First Name' }],
},
],
};
const vctWithCustomProperties: TypeMetadataFormat = {
vct: 'http://example.com/custom-properties',
name: 'CustomProperties',
claims: [
{
path: ['firstName'],
sd: 'always' as const, // Valid: base doesn't have sd or has 'allowed'
display: [{ locale: 'en', label: 'First Name' }],
anotherCustom: 'property',
},
],
test: 'something',
};
const restHandlers = [
http.get('http://example.com/example', () => {
const res: TypeMetadataFormat = exampleVctm;
return HttpResponse.json(res);
}),
http.get('http://example.com/timeout', () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(HttpResponse.json({}));
}, 10000);
});
}),
http.get('http://example.com/base', () => {
return HttpResponse.json(baseVctm);
}),
http.get('http://example.com/extending', () => {
return HttpResponse.json(extendingVctm);
}),
http.get('http://example.com/middle', () => {
return HttpResponse.json(middleVctm);
}),
http.get('http://example.com/overriding', () => {
return HttpResponse.json(overridingVctm);
}),
http.get('http://example.com/circular', () => {
return HttpResponse.json(circularVctm);
}),
http.get('http://example.com/deep', () => {
return HttpResponse.json(deepVctm);
}),
http.get('http://example.com/base-sd-always', () => {
return HttpResponse.json(baseWithSdAlways);
}),
http.get('http://example.com/invalid-sd-change', () => {
return HttpResponse.json(invalidExtendingSdChange);
}),
http.get('http://example.com/valid-sd-change', () => {
return HttpResponse.json(validExtendingSdChange);
}),
http.get('http://example.com/custom-properties', () => {
return HttpResponse.json(vctWithCustomProperties);
}),
http.get('http://example.com/invalid', () => {
// Return invalid type metadata (missing required 'vct' field)
return HttpResponse.json({
name: 'InvalidCredentialType',
description: 'Missing required vct field',
});
}),
];
//this value could be generated on demand to make it easier when changing the values
const vctIntegrity =
'sha256-e8bf419e6b860595f385611fc6172f1e95c18de3c80eef57c865f49e03747637';
const server = setupServer(...restHandlers);
const iss = 'ExampleIssuer';
const vct = 'http://example.com/example';
const iat = Math.floor(Date.now() / 1000); // current time in seconds
const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519');
const createSignerVerifier = () => {
const signer: Signer = async (data: string) => {
const sig = Crypto.sign(null, Buffer.from(data), privateKey);
return Buffer.from(sig).toString('base64url');
};
const verifier: Verifier = async (data: string, sig: string) => {
return Crypto.verify(
null,
Buffer.from(data),
publicKey,
Buffer.from(sig, 'base64url'),
);
};
return { signer, verifier };
};
describe('App', () => {
const { signer, verifier } = createSignerVerifier();
const sdjwt = new SDJwtVcInstance({
signer,
signAlg: 'EdDSA',
verifier,
hasher: digest,
hashAlg: 'sha-256',
saltGenerator: generateSalt,
loadTypeMetadataFormat: true,
timeout: 1000,
});
const claims = {
firstname: 'John',
};
const disclosureFrame = {
_sd: ['firstname'],
};
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
test('VCT Validation', async () => {
// The method is private, so TS complains, but you can use spies on private method just fine.
// @ts-expect-error
const validateIntegritySpy = vitest.spyOn(sdjwt, 'validateIntegrity');
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct,
'vct#integrity': vctIntegrity,
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
await sdjwt.verify(encodedSdjwt);
// Ensure validateIntegrity method was called
expect(validateIntegritySpy).toHaveBeenCalledWith(
expect.any(Response),
vct,
vctIntegrity,
);
});
test('VCT Validation with timeout', async () => {
const vct = 'http://example.com/timeout';
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct,
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
await expect(sdjwt.verify(encodedSdjwt)).rejects.toThrowError(
`Request to ${vct} timed out`,
);
});
test('VCT Metadata retrieval', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct,
'vct#Integrity': vctIntegrity,
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
// Check mergedTypeMetadata
expect(resolvedTypeMetadata?.mergedTypeMetadata).to.deep.eq({
description: 'An example credential type',
name: 'ExampleCredentialType',
vct: 'http://example.com/example',
});
// Check typeMetadataChain - should have only one document (no extends)
expect(resolvedTypeMetadata?.typeMetadataChain).toHaveLength(1);
expect(resolvedTypeMetadata?.typeMetadataChain[0].vct).toBe(
'http://example.com/example',
);
// Check vctValues - should have only one value
expect(resolvedTypeMetadata?.vctValues).toHaveLength(1);
expect(resolvedTypeMetadata?.vctValues[0]).toBe(
'http://example.com/example',
);
});
test('VCT with extends - simple chain', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/extending',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
// Check mergedTypeMetadata - should merge claims from both base and extending types
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims).toHaveLength(2);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].path).toEqual([
'firstName',
]);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[1].path).toEqual([
'lastName',
]);
// Display from extending type completely replaces base display (section 8.2)
expect(resolvedTypeMetadata?.mergedTypeMetadata.display).toHaveLength(2);
expect(resolvedTypeMetadata?.mergedTypeMetadata.display?.[0]).toEqual({
locale: 'en',
name: 'Extended Credential',
description: 'Extended description',
});
expect(resolvedTypeMetadata?.mergedTypeMetadata.display?.[1]).toEqual({
locale: 'de',
name: 'Erweiterte Berechtigung',
description: 'Erweiterte Beschreibung',
});
// Top-level properties should come from extending type
expect(resolvedTypeMetadata?.mergedTypeMetadata.name).toBe(
'ExtendingCredentialType',
);
expect(resolvedTypeMetadata?.mergedTypeMetadata.description).toBe(
'A credential type that extends the base',
);
// Check typeMetadataChain - should have 2 documents in chain
expect(resolvedTypeMetadata?.typeMetadataChain).toHaveLength(2);
expect(resolvedTypeMetadata?.typeMetadataChain[0].vct).toBe(
'http://example.com/extending',
);
expect(resolvedTypeMetadata?.typeMetadataChain[1].vct).toBe(
'http://example.com/base',
);
// Check vctValues - should have 2 values
expect(resolvedTypeMetadata?.vctValues).toHaveLength(2);
expect(resolvedTypeMetadata?.vctValues[0]).toBe(
'http://example.com/extending',
);
expect(resolvedTypeMetadata?.vctValues[1]).toBe('http://example.com/base');
});
test('VCT with extends - multi-level chain', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/middle',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
// Check mergedTypeMetadata - should merge claims from base -> extending -> middle
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims).toHaveLength(3);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].path).toEqual([
'firstName',
]);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[1].path).toEqual([
'lastName',
]);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[2].path).toEqual([
'age',
]);
// Top-level properties should come from the most derived type
expect(resolvedTypeMetadata?.mergedTypeMetadata.name).toBe(
'MiddleCredentialType',
);
expect(resolvedTypeMetadata?.mergedTypeMetadata.description).toBe(
'Middle type in chain',
);
// Check typeMetadataChain - should have 3 documents in chain
expect(resolvedTypeMetadata?.typeMetadataChain).toHaveLength(3);
expect(resolvedTypeMetadata?.typeMetadataChain[0].vct).toBe(
'http://example.com/middle',
);
expect(resolvedTypeMetadata?.typeMetadataChain[1].vct).toBe(
'http://example.com/extending',
);
expect(resolvedTypeMetadata?.typeMetadataChain[2].vct).toBe(
'http://example.com/base',
);
// Check vctValues - should have 3 values
expect(resolvedTypeMetadata?.vctValues).toHaveLength(3);
expect(resolvedTypeMetadata?.vctValues[0]).toBe(
'http://example.com/middle',
);
expect(resolvedTypeMetadata?.vctValues[1]).toBe(
'http://example.com/extending',
);
expect(resolvedTypeMetadata?.vctValues[2]).toBe('http://example.com/base');
});
test('VCT with circular dependency should throw error', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/circular',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
await expect(sdjwt.getVct(encodedSdjwt)).rejects.toThrowError(
'Circular dependency detected in VCT extends chain: http://example.com/circular',
);
});
test('VCT with max depth exceeded should throw error', async () => {
const sdjwtWithShallowDepth = new SDJwtVcInstance({
signer,
signAlg: 'EdDSA',
verifier,
hasher: digest,
hashAlg: 'sha-256',
saltGenerator: generateSalt,
loadTypeMetadataFormat: true,
timeout: 1000,
maxVctExtendsDepth: 1, // Only allow 1 level of extends
});
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/middle', // This has 2 levels of extends
...claims,
};
const encodedSdjwt = await sdjwtWithShallowDepth.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
await expect(
sdjwtWithShallowDepth.getVct(encodedSdjwt),
).rejects.toThrowError('Maximum VCT extends depth of 1 exceeded');
});
test('VCT extends chain should work in verify method', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/extending',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
// Should not throw and should resolve the extends chain
const result = await sdjwt.verify(encodedSdjwt);
expect(result.payload.vct).toBe('http://example.com/extending');
// Check that typeMetadata was populated with resolved chain
expect(result.typeMetadata?.mergedTypeMetadata.claims).toHaveLength(2);
expect(result.typeMetadata?.typeMetadataChain).toHaveLength(2);
expect(result.typeMetadata?.vctValues).toHaveLength(2);
});
test('VCT with overriding claim metadata', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/overriding',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
// Check mergedTypeMetadata - should have 2 claims: overridden firstName and new middleName
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims).toHaveLength(2);
// First claim should be the overridden firstName with new label and sd property
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].path).toEqual([
'firstName',
]);
expect(
resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].display?.[0].label,
).toBe('Given Name');
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].sd).toBe(
'always',
);
// Second claim should be the new middleName
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[1].path).toEqual([
'middleName',
]);
expect(
resolvedTypeMetadata?.mergedTypeMetadata.claims?.[1].display?.[0].label,
).toBe('Middle Name');
// Check typeMetadataChain - should have 2 documents
expect(resolvedTypeMetadata?.typeMetadataChain).toHaveLength(2);
expect(resolvedTypeMetadata?.typeMetadataChain[0].vct).toBe(
'http://example.com/overriding',
);
expect(resolvedTypeMetadata?.typeMetadataChain[1].vct).toBe(
'http://example.com/base',
);
// Check vctValues
expect(resolvedTypeMetadata?.vctValues).toHaveLength(2);
expect(resolvedTypeMetadata?.vctValues[0]).toBe(
'http://example.com/overriding',
);
expect(resolvedTypeMetadata?.vctValues[1]).toBe('http://example.com/base');
});
test('VCT with valid sd property change (allowed to always)', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/valid-sd-change',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
// Check mergedTypeMetadata - should successfully merge - changing from undefined/allowed to always is valid
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims).toHaveLength(1);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].path).toEqual([
'firstName',
]);
expect(resolvedTypeMetadata?.mergedTypeMetadata.claims?.[0].sd).toBe(
'always',
);
// Check typeMetadataChain
expect(resolvedTypeMetadata?.typeMetadataChain).toHaveLength(2);
expect(resolvedTypeMetadata?.vctValues).toHaveLength(2);
});
test('VCT with invalid sd property change (always to never) should throw error', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/invalid-sd-change',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
await expect(sdjwt.getVct(encodedSdjwt)).rejects.toThrowError(
"Cannot change 'sd' property from 'always' to 'never' for claim at path [\"sensitiveData\"]",
);
});
test('VCT extending type without display should inherit base display', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/middle', // middle doesn't define display
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
// Check mergedTypeMetadata - since middle doesn't define display, it should inherit from extending which has display
expect(resolvedTypeMetadata?.mergedTypeMetadata.display).toHaveLength(2);
expect(resolvedTypeMetadata?.mergedTypeMetadata.display?.[0].locale).toBe(
'en',
);
expect(resolvedTypeMetadata?.mergedTypeMetadata.display?.[1].locale).toBe(
'de',
);
// Check typeMetadataChain - should have 3 documents
expect(resolvedTypeMetadata?.typeMetadataChain).toHaveLength(3);
expect(resolvedTypeMetadata?.vctValues).toHaveLength(3);
});
test('VCT with custom properties are kept', async () => {
const expectedPayload: SdJwtVcPayload = {
iat,
iss,
vct: 'http://example.com/custom-properties',
...claims,
};
const encodedSdjwt = await sdjwt.issue(
expectedPayload,
disclosureFrame as unknown as DisclosureFrame<SdJwtVcPayload>,
);
const resolvedTypeMetadata = await sdjwt.getVct(encodedSdjwt);
expect(resolvedTypeMetadata?.mergedTypeMetadata).toEqual(
vctWithCustomProperties,
);
});
});