Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ and this project (loosely) adheres to [Semantic Versioning](https://semver.org/s

## 2.0.0 - 2025-XX-XX

### Added

- Support embedding Type Metadata documents in the JWS unprotected header via the `vctm` parameter, as per [SD-JWT VC Spec Section 6.3.5](https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-05.html#section-6.3.5).

### Changed

- The `typ` header for issued SD-JWT VCs is now `dc+sd-jwt` as per [draft-08](https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-08.html#section-3.2.1)
- The `Verifier` will accept both `vc+sd-jwt` and `dc+sd-jwt`.

### Removed

- Removed deprecated `createVCSDJWT` method from the `Issuer` class. Use `createSignedVCSDJWT` instead.

## 1.3.0 - 2024-10-07

### Security
Expand Down
50 changes: 47 additions & 3 deletions src/issuer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { DisclosureFrame, base64decode, decodeDisclosures, decodeJWT } from '@meeco/sd-jwt';
import { generateKeyPair } from 'jose';

import { DisclosureFrame, decodeDisclosures, decodeJWT } from '@meeco/sd-jwt';
import { Issuer } from './issuer';
import { hasherCallbackFn, signerCallbackFn } from './test-utils/helpers';
import {
Expand Down Expand Up @@ -70,7 +69,13 @@ describe('Issuer', () => {
],
};

const VCSDJwt = await issuer.createVCSDJWT(vcClaims, payload, sdVCClaimsDisclosureFrame, undefined, sdVCHeader);
const VCSDJwt = await issuer.createSignedVCSDJWT({
vcClaims,
sdJWTPayload: payload,
sdVCClaimsDisclosureFrame,
saltGenerator: undefined,
sdJWTHeader: sdVCHeader,
});

expect(VCSDJwt).toBeDefined();
expect(typeof VCSDJwt).toBe('string');
Expand Down Expand Up @@ -117,6 +122,45 @@ describe('Issuer', () => {
expect(jwtHeader.typ).toEqual(ValidTypValues.DCSDJWT);
});

it('should embed type metadata in vctm header when provided', async () => {
const payload: CreateSDJWTPayload = {
iat: Math.floor(Date.now() / 1000),
cnf: { jwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } },
iss: 'https://valid.issuer.url',
vct: 'test_vct_for_vctm',
};
const vcClaims: VCClaims = { data: 'some_claim_data' };
const typeMetadataDoc1 = { vct: 'test_vct_for_vctm', name: 'Test Credential Type 1' };
const typeMetadataDoc2String = JSON.stringify({ vct: 'test_vct_for_vctm_extended', name: 'Extended Type' });

const VCSDJwtWithVctm = await issuer.createSignedVCSDJWT({
vcClaims,
sdJWTPayload: payload,
typeMetadataGlueDocuments: [typeMetadataDoc1, typeMetadataDoc2String],
});

const { header: headerWithVctm } = decodeJWT(VCSDJwtWithVctm.split(SD_JWT_FORMAT_SEPARATOR).shift() || '');
expect(headerWithVctm.vctm).toBeDefined();
expect(Array.isArray(headerWithVctm.vctm)).toBe(true);

expect((headerWithVctm.vctm as any[]).length).toBe(2);

const decodedDoc1 = JSON.parse(base64decode((headerWithVctm.vctm as any[])[0]));

const decodedDoc2 = JSON.parse(base64decode((headerWithVctm.vctm as any[])[1]));

expect(decodedDoc1).toEqual(typeMetadataDoc1);
expect(decodedDoc2).toEqual(JSON.parse(typeMetadataDoc2String));

// Test without vctm
const VCSDJwtWithoutVctm = await issuer.createSignedVCSDJWT({
vcClaims,
sdJWTPayload: payload,
});
const { header: headerWithoutVctm } = decodeJWT(VCSDJwtWithoutVctm.split(SD_JWT_FORMAT_SEPARATOR).shift() || '');
expect(headerWithoutVctm.vctm).toBeUndefined();
});

describe('Issuer', () => {
describe('constructor', () => {
it('should throw an error if signer callback is not provided', () => {
Expand Down
80 changes: 31 additions & 49 deletions src/issuer.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { DisclosureFrame, JWTHeaderParameters, SDJWTPayload, SaltGenerator, issueSDJWT } from '@meeco/sd-jwt';
import { DisclosureFrame, JWTHeaderParameters, SDJWTPayload, base64encode, issueSDJWT } from '@meeco/sd-jwt';
import { SDJWTVCError } from './errors.js';
import {
CreateSDJWTPayload,
CreateSignedJWTOpts,
HasherConfig,
JWT,
ReservedJWTClaimKeys,
SignerConfig,
VCClaims,
} from './types.js';
import { CreateSignedJWTOpts, HasherConfig, JWT, ReservedJWTClaimKeys, SignerConfig, VCClaims } from './types.js';
import { ValidTypValues, isValidUrl } from './util.js';

export class Issuer {
Expand Down Expand Up @@ -42,58 +34,48 @@ export class Issuer {
return this.hasher;
}

/**
* Creates a VC SD-JWT.
* @deprecated This method will be removed in the next version. Use `createSignedVCSDJWT` instead.
* @param claims The VC claims.
* @param sdJWTPayload The SD-JWT payload.
* @param sdVCClaimsDisclosureFrame The SD-VC claims disclosure frame.
* @param saltGenerator The salt generator.
* @param sdJWTHeader additional header parameters
* @throws An error if the VC SD-JWT cannot be created.
* @returns The VC SD-JWT.
*/
async createVCSDJWT(
vcClaims: VCClaims,
sdJWTPayload: CreateSDJWTPayload,
sdVCClaimsDisclosureFrame: DisclosureFrame = {},
saltGenerator?: SaltGenerator,
sdJWTHeader?: Omit<JWTHeaderParameters, 'typ' | 'alg'>,
): Promise<JWT> {
return this.createSignedVCSDJWT({ vcClaims, sdJWTPayload, sdVCClaimsDisclosureFrame, saltGenerator, sdJWTHeader });
}

/**
* Creates a signed SD-JWT VC.
*/
async createSignedVCSDJWT(opts: CreateSignedJWTOpts): Promise<JWT> {
const { vcClaims, sdJWTPayload, sdVCClaimsDisclosureFrame = {}, saltGenerator, sdJWTHeader } = opts;
const {
vcClaims,
sdJWTPayload,
sdVCClaimsDisclosureFrame = {},
saltGenerator,
sdJWTHeader,
typeMetadataGlueDocuments,
} = opts;
if (!vcClaims) throw new SDJWTVCError('vcClaims is required');
if (!sdJWTPayload) throw new SDJWTVCError('sdJWTPayload is required');

this.validateVCClaims(vcClaims as VCClaims);
this.validateSDJWTPayload(sdJWTPayload);
this.validateSDVCClaimsDisclosureFrame(sdVCClaimsDisclosureFrame);

const header: JWTHeaderParameters & { vctm?: string[] } = {
...sdJWTHeader,
typ: Issuer.SD_JWT_TYP,
alg: this.signer.alg,
};

if (typeMetadataGlueDocuments && typeMetadataGlueDocuments.length > 0) {
header.vctm = typeMetadataGlueDocuments.map((doc) => {
const docString = typeof doc === 'string' ? doc : JSON.stringify(doc);
return base64encode(docString);
});
}

try {
const jwt = await issueSDJWT(
{
...sdJWTHeader,
typ: Issuer.SD_JWT_TYP,
alg: this.signer.alg,
},
{ ...sdJWTPayload, ...vcClaims },
sdVCClaimsDisclosureFrame,
{
signer: this.signer.callback,
hash: {
alg: this.hasher.alg,
callback: this.hasher.callback,
},
cnf: sdJWTPayload?.cnf,
generateSalt: saltGenerator,
const jwt = await issueSDJWT(header, { ...sdJWTPayload, ...vcClaims }, sdVCClaimsDisclosureFrame, {
signer: this.signer.callback,
hash: {
alg: this.hasher.alg,
callback: this.hasher.callback,
},
);
cnf: sdJWTPayload?.cnf,
generateSalt: saltGenerator,
});

return jwt;
} catch (error: any) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface CreateSignedJWTOpts {
sdVCClaimsDisclosureFrame?: DisclosureFrame;
saltGenerator?: SaltGenerator;
sdJWTHeader?: Omit<JWTHeaderParameters, 'typ' | 'alg'>;
typeMetadataGlueDocuments?: Array<Record<string, any> | string>;
}

export interface PresentSDJWTPayload extends JWTPayload {
Expand Down