diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4550b8d..bb61290 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,14 +1,19 @@ import { SDJWTException, uint8ArrayToBase64Url } from '@sd-jwt/utils'; import { Jwt } from './jwt'; import { KBJwt } from './kbjwt'; -import { SDJwt, pack } from './sdjwt'; +import { + type SDJJWTJson, + type SDJWTJsonFlattened, + SDJwt, + pack, + type SDJWTType, +} from './sdjwt'; import { type DisclosureFrame, type Hasher, type KBOptions, KB_JWT_TYP, type PresentationFrame, - type SDJWTCompact, type SDJWTConfig, type JwtPayload, } from '@sd-jwt/types'; @@ -19,6 +24,10 @@ export * from './kbjwt'; export * from './jwt'; export * from './decoy'; +export type SerializationJson = 'json' | 'json-flattended'; + +export type Serialization = 'compact' | SerializationJson; + export type SdJwtPayload = Record; export class SDJwtInstance { @@ -77,10 +86,11 @@ export class SDJwtInstance { public async issue( payload: Payload, disclosureFrame?: DisclosureFrame, - options?: { + options: { header?: object; // This is for customizing the header of the jwt - }, - ): Promise { + serialization?: Serialization; // This is for specifying the serialization of the jwt, default is compact + } = { serialization: 'compact' }, + ): Promise { if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } @@ -125,7 +135,9 @@ export class SDJwtInstance { jwt, disclosures, }); - + if (options?.serialization !== 'compact') { + return sdJwt.encodeSDJwtJson(options.serialization); + } return sdJwt.encodeSDJwt(); } @@ -141,12 +153,13 @@ export class SDJwtInstance { } public async present>( - encodedSDJwt: string, + encodedSDJwt: SDJWTType, presentationFrame?: PresentationFrame, options?: { kb?: KBOptions; }, - ): Promise { + ): Promise { + //TODO: update if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } @@ -178,7 +191,7 @@ export class SDJwtInstance { // If requiredClaimKeys is provided, it will check if the required claim keys are presentation in the SD JWT // If requireKeyBindings is true, it will check if the key binding JWT is presentation and verify it public async verify( - encodedSDJwt: string, + encodedSDJwt: SDJWTType, requiredClaimKeys?: string[], requireKeyBindings?: boolean, ) { @@ -240,23 +253,52 @@ export class SDJwtInstance { return { payload, header, kb }; } + /** + * Calculate the SD hash + * @param presentSdJwtWithoutKb + * @param sdjwt + * @param hasher + * @returns + */ private async calculateSDHash( - presentSdJwtWithoutKb: string, + presentSdJwtWithoutKb: SDJWTType, sdjwt: SDJwt, hasher: Hasher, ) { + let data: string; if (!sdjwt.jwt || !sdjwt.jwt.payload) { throw new SDJWTException('Invalid SD JWT'); } const { _sd_alg } = getSDAlgAndPayload(sdjwt.jwt.payload); - const sdHash = await hasher(presentSdJwtWithoutKb, _sd_alg); + if ( + typeof presentSdJwtWithoutKb !== 'string' && + (presentSdJwtWithoutKb as SDJJWTJson).signatures + ) { + const sdJwtJson = presentSdJwtWithoutKb as SDJJWTJson; + data = `${sdJwtJson.signatures[0].protected}.${sdJwtJson.payload}.${sdJwtJson.signatures[0].signature}~`; + for (const disclosure of sdJwtJson.signatures[0].header.disclosures) { + data += `${disclosure}~`; + } + } else if ( + typeof presentSdJwtWithoutKb !== 'string' && + (presentSdJwtWithoutKb as SDJWTJsonFlattened).signature + ) { + const sdJwtJsonFlattened = presentSdJwtWithoutKb as SDJWTJsonFlattened; + data = `${sdJwtJsonFlattened.protected}.${sdJwtJsonFlattened.payload}.${sdJwtJsonFlattened.signature}~`; + for (const disclosure of sdJwtJsonFlattened.header?.disclosures ?? []) { + data += `${disclosure}~`; + } + } else { + data = presentSdJwtWithoutKb as string; + } + const sdHash = await hasher(data, _sd_alg); const sdHashStr = uint8ArrayToBase64Url(sdHash); return sdHashStr; } // This function is for validating the SD JWT // Just checking signature and return its the claims - public async validate(encodedSDJwt: string) { + public async validate(encodedSDJwt: SDJWTType) { if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } @@ -276,18 +318,21 @@ export class SDJwtInstance { this.userConfig = { ...this.userConfig, ...newConfig }; } - public encode(sdJwt: SDJwt): SDJWTCompact { - return sdJwt.encodeSDJwt(); + public encode(sdJwt: SDJwt, type: SDJWTType = 'compact') { + if (type === 'json') { + return sdJwt.encodeSDJwt(); + } + return sdJwt.encodeSDJwtJson(); } - public decode(endcodedSDJwt: SDJWTCompact) { + public decode(endcodedSDJwt: SDJWTType) { if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } return SDJwt.fromEncode(endcodedSDJwt, this.userConfig.hasher); } - public async keys(endcodedSDJwt: SDJWTCompact) { + public async keys(endcodedSDJwt: SDJWTType) { if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } @@ -295,7 +340,7 @@ export class SDJwtInstance { return sdjwt.keys(this.userConfig.hasher); } - public async presentableKeys(endcodedSDJwt: SDJWTCompact) { + public async presentableKeys(endcodedSDJwt: SDJWTType) { if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } @@ -303,7 +348,7 @@ export class SDJwtInstance { return sdjwt.presentableKeys(this.userConfig.hasher); } - public async getClaims(endcodedSDJwt: SDJWTCompact) { + public async getClaims(endcodedSDJwt: SDJWTType) { if (!this.userConfig.hasher) { throw new SDJWTException('Hasher not found'); } diff --git a/packages/core/src/sdjwt.ts b/packages/core/src/sdjwt.ts index 654200d..ba966d2 100644 --- a/packages/core/src/sdjwt.ts +++ b/packages/core/src/sdjwt.ts @@ -1,5 +1,10 @@ import { createDecoy } from './decoy'; -import { SDJWTException, Disclosure } from '@sd-jwt/utils'; +import { + SDJWTException, + Disclosure, + base64urlEncode, + base64urlDecode, +} from '@sd-jwt/utils'; import { Jwt } from './jwt'; import { KBJwt } from './kbjwt'; import { @@ -18,6 +23,47 @@ import { } from '@sd-jwt/types'; import { createHashMapping, getSDAlgAndPayload, unpack } from '@sd-jwt/decode'; import { transformPresentationFrame } from '@sd-jwt/present'; +import type { Serialization, SerializationJson } from '.'; + +type Signature = { + // The "protected" member MUST be present and contain the value BASE64URL(UTF8(JWS Protected Header)) when the JWS Protected Header value is non-empty; otherwise, it MUST be absent. These Header Parameter values are integrity protected. + protected: string; + // The "header" member MUST be present and contain the value JWS Unprotected Header when the JWS Unprotected Header value is non-empty; otherwise, it MUST be absent. This value is represented as an unencoded JSON object, rather than as a string. These Header Parameter values are not integrity protected. + header: { + // only included in the first signature of the signature array + disclosures: string[]; + kid?: string; + // only included in the first signature of the signature array + kb_jwt?: string; + }; + // The "signature" member MUST be present and contain the value BASE64URL(JWS Signature). + signature: string; +}; + +/** + * General Json serialization of a SD-JWT based on https://www.rfc-editor.org/rfc/rfc7515.html#section-7.2.1 for JWT and extended + */ +export type SDJJWTJson = { + // the "payload" member MUST be present and contain the value BASE64URL(JWS Payload). + payload: string; + // The "signatures" member value MUST be an array of JSON objects. Each object represents a signature or MAC over the JWS Payload and the JWS Protected Header. + signatures: Array; +}; + +export type SDJWTJsonFlattened = { + header?: { + // An array of strings where each element is an individual Disclosure as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-09.html#creating_disclosures + disclosures?: Array; + // Present only in an SD-JWT+KB, the Key Binding JWT as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-09.html#kb-jwt + kb_jwt?: string; + }; + // the "payload" member MUST be present and contain the value BASE64URL(JWS Payload). + payload: string; + protected: string; + signature: string; +}; + +export type SDJWTType = SDJJWTJson | SDJWTJsonFlattened | SDJWTCompact; export type SDJwtData< Header extends Record, @@ -46,51 +92,125 @@ export class SDJwt< this.kbJwt = data?.kbJwt; } + /** + * Decode a SDJwt from a compact string or a JSON object + * @param sdjwt + * @param hasher + * @returns + */ public static async decodeSDJwt< Header extends Record = Record, Payload extends Record = Record, KBHeader extends kbHeader = kbHeader, KBPayload extends kbPayload = kbPayload, >( - sdjwt: SDJWTCompact, + sdjwt: SDJWTType, hasher: Hasher, ): Promise<{ jwt: Jwt; disclosures: Array; kbJwt?: KBJwt; }> { - const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR); - const jwt = Jwt.fromEncode(encodedJwt); + if (typeof sdjwt === 'string') { + const [encodedJwt, ...encodedDisclosures] = sdjwt.split(SD_SEPARATOR); + const jwt = Jwt.fromEncode(encodedJwt); - if (!jwt.payload) { - throw new Error('Payload is undefined on the JWT. Invalid state reached'); - } + if (!jwt.payload) { + throw new Error( + 'Payload is undefined on the JWT. Invalid state reached', + ); + } + + if (encodedDisclosures.length === 0) { + return { + jwt, + disclosures: [], + }; + } + + const encodedKeyBindingJwt = encodedDisclosures.pop(); + const kbJwt = encodedKeyBindingJwt + ? KBJwt.fromKBEncode(encodedKeyBindingJwt) + : undefined; + + const { _sd_alg } = getSDAlgAndPayload(jwt.payload); + + const disclosures = await Promise.all( + (encodedDisclosures as Array).map((ed) => + Disclosure.fromEncode(ed, { alg: _sd_alg, hasher }), + ), + ); - if (encodedDisclosures.length === 0) { return { jwt, - disclosures: [], + disclosures, + kbJwt, }; } + if (typeof (sdjwt as SDJJWTJson).signatures !== 'undefined') { + const sdJJWTJson = sdjwt as SDJJWTJson; + const payload = JSON.parse(base64urlDecode(sdJJWTJson.payload)); + //TODO: unclear if this is the correct way to parse the header + const header = JSON.parse( + base64urlDecode(sdJJWTJson.signatures[0].protected), + ); + const kbJwt = sdJJWTJson.signatures[0].header.kb_jwt + ? KBJwt.fromKBEncode( + sdJJWTJson.signatures[0].header.kb_jwt, + ) + : undefined; + //TODO: with the current implementation, only one signature is returned since it has to be a jwt + const jwt = new Jwt({ + header, + payload, + signature: sdJJWTJson.signatures[0].signature, + }); + const { _sd_alg } = getSDAlgAndPayload(payload); + return { + jwt, + disclosures: await SDJwt.decodeDisclosures( + sdJJWTJson.signatures[0].header.disclosures, + _sd_alg, + hasher, + ), + kbJwt, + }; + } + const sdjwtJson = sdjwt as SDJWTJsonFlattened; + const header = JSON.parse(base64urlDecode(sdjwtJson.protected)); + const payload = JSON.parse(base64urlDecode(sdjwtJson.payload)); + const jwt = new Jwt({ + header, + payload, + signature: sdjwtJson.signature, + }); - const encodedKeyBindingJwt = encodedDisclosures.pop(); - const kbJwt = encodedKeyBindingJwt - ? KBJwt.fromKBEncode(encodedKeyBindingJwt) + const { _sd_alg } = getSDAlgAndPayload(jwt.payload as Payload); + const kbJwt = sdjwtJson.header?.kb_jwt + ? KBJwt.fromKBEncode(sdjwtJson.header.kb_jwt) : undefined; - const { _sd_alg } = getSDAlgAndPayload(jwt.payload); + return { + jwt, + disclosures: await SDJwt.decodeDisclosures( + header.disclosures || [], + _sd_alg, + hasher, + ), + kbJwt, + }; + } - const disclosures = await Promise.all( + private static decodeDisclosures( + encodedDisclosures: string[], + _sd_alg: string, + hasher: Hasher, + ): Promise { + return Promise.all( (encodedDisclosures as Array).map((ed) => Disclosure.fromEncode(ed, { alg: _sd_alg, hasher }), ), ); - - return { - jwt, - disclosures, - kbJwt, - }; } public static async fromEncode< @@ -98,10 +218,7 @@ export class SDJwt< Payload extends Record = Record, KBHeader extends kbHeader = kbHeader, KBPayload extends kbPayload = kbPayload, - >( - encodedSdJwt: SDJWTCompact, - hasher: Hasher, - ): Promise> { + >(encodedSdJwt: SDJWTType, hasher: Hasher): Promise> { const { jwt, disclosures, kbJwt } = await SDJwt.decodeSDJwt< Header, Payload, @@ -119,7 +236,8 @@ export class SDJwt< public async present>( presentFrame: PresentationFrame | undefined, hasher: Hasher, - ): Promise { + type: Serialization = 'compact', + ): Promise { if (!this.jwt?.payload || !this.disclosures) { throw new SDJWTException('Invalid sd-jwt: jwt or disclosures is missing'); } @@ -143,10 +261,18 @@ export class SDJwt< disclosures, kbJwt: this.kbJwt, }); - return presentSDJwt.encodeSDJwt(); + if (type === 'compact') { + return presentSDJwt.encodeSDJwt(); + } + return presentSDJwt.encodeSDJwtJson(type); } + /** + * Encodes the SDJwt to a compact string + * @returns + */ public encodeSDJwt(): SDJWTCompact { + //TODO: when we have multiple signatures, we are not able to encode it to a compact string and should throw an error const data: string[] = []; if (!this.jwt) { @@ -167,6 +293,47 @@ export class SDJwt< return data.join(SD_SEPARATOR); } + /** + * Encodes the SDJwt to a JSON object according to sd-jwt spec, either general or flattened + * @returns + */ + public encodeSDJwtJson( + type: SerializationJson = 'json', + ): SDJWTJsonFlattened | SDJJWTJson { + // check if disclosures should be empty or not included if not present + const disclosures = this.disclosures?.map((d) => d.encode()) ?? []; + if (type === 'json-flattended') { + return { + header: { + disclosures, + }, + payload: base64urlEncode(JSON.stringify(this.jwt?.payload)), + protected: base64urlEncode(JSON.stringify(this.jwt?.header)), + signature: this.jwt?.signature as string, + }; + } + const signatures: Array = []; + + signatures.push({ + // unproctected header + header: { + disclosures, + //TODO: validate if this is the correct kid + kid: this.jwt?.header?.kid as string, + kb_jwt: (this.kbJwt?.encodeJwt() as string) || undefined, + }, + // protected header + protected: base64urlEncode(JSON.stringify(this.jwt?.header)), + // signature of the proctected header and payload + signature: this.jwt?.signature as string, + }); + //TODO: add support for multiple signatures, right now only one is supported + return { + payload: base64urlEncode(JSON.stringify(this.jwt?.payload as unknown)), + signatures, + }; + } + public async keys(hasher: Hasher): Promise { return listKeys(await this.getClaims(hasher)).sort(); } diff --git a/packages/core/src/test/sdjwt.spec.ts b/packages/core/src/test/sdjwt.spec.ts index c39d6e9..fbb418e 100644 --- a/packages/core/src/test/sdjwt.spec.ts +++ b/packages/core/src/test/sdjwt.spec.ts @@ -2,7 +2,7 @@ import { Disclosure } from '@sd-jwt/utils'; import { Jwt } from '../jwt'; import { SDJwt, listKeys, pack } from '../sdjwt'; import Crypto from 'node:crypto'; -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, beforeAll } from 'vitest'; import type { DisclosureFrame, Signer } from '@sd-jwt/types'; import { generateSalt, digest as hasher } from '@sd-jwt/crypto-nodejs'; import { unpack, createHashMapping } from '@sd-jwt/decode'; @@ -10,15 +10,29 @@ import { unpack, createHashMapping } from '@sd-jwt/decode'; const hash = { alg: 'SHA256', hasher }; describe('SD JWT', () => { + let privateKey: Crypto.KeyObject; + + beforeAll(async () => { + const key = { + crv: 'Ed25519', + d: 'ae3x8nwFurD3HSsUFs8dVeeOV9PGduQ_C3kvWd876uo', + x: '6XoweLGtj3SHJBHyw1CF-q-2lLgXHGoybW_7iADkg8Y', + kty: 'OKP', + }; + privateKey = await Crypto.createPrivateKey({ + key, + format: 'jwk', + }); + }); + test('create and encode', async () => { - const { privateKey } = Crypto.generateKeyPairSync('ed25519'); const testSigner: Signer = async (data: string) => { const sig = Crypto.sign(null, Buffer.from(data), privateKey); return Buffer.from(sig).toString('base64url'); }; const jwt = new Jwt({ - header: { alg: 'EdDSA' }, + header: { alg: 'EdDSA', kid: 'key1' }, payload: { foo: 'bar' }, }); @@ -29,12 +43,44 @@ describe('SD JWT', () => { }); expect(sdJwt).toBeDefined(); + // test compact encoding const encoded = sdJwt.encodeSDJwt(); - expect(encoded).toBeDefined(); + + expect(encoded).toBe( + 'eyJhbGciOiJFZERTQSIsImtpZCI6ImtleTEifQ.eyJmb28iOiJiYXIifQ.kHjXb8fplSbRTv51xm5NJm4TQHr6TtqWj8anpPd9hMPCf8RMO_J2j5wp8TWJbVt2PXYQdiIQmcVEnB-h9TOKBQ~', + ); + + // test json serialization + const encodedJson = sdJwt.encodeSDJwtJson('json'); + expect(encodedJson).toEqual({ + payload: 'eyJmb28iOiJiYXIifQ', + signatures: [ + { + header: { + disclosures: [], + kid: 'key1', + }, + protected: 'eyJhbGciOiJFZERTQSIsImtpZCI6ImtleTEifQ', + signature: + 'kHjXb8fplSbRTv51xm5NJm4TQHr6TtqWj8anpPd9hMPCf8RMO_J2j5wp8TWJbVt2PXYQdiIQmcVEnB-h9TOKBQ', + }, + ], + }); + + // test json serialization flattened + const encodedJsonFlattened = sdJwt.encodeSDJwtJson('json-flattended'); + expect(encodedJsonFlattened).toEqual({ + header: { + disclosures: [], + }, + payload: 'eyJmb28iOiJiYXIifQ', + protected: 'eyJhbGciOiJFZERTQSIsImtpZCI6ImtleTEifQ', + signature: + 'kHjXb8fplSbRTv51xm5NJm4TQHr6TtqWj8anpPd9hMPCf8RMO_J2j5wp8TWJbVt2PXYQdiIQmcVEnB-h9TOKBQ', + }); }); test('decode', async () => { - const { privateKey } = Crypto.generateKeyPairSync('ed25519'); const testSigner: Signer = async (data: string) => { const sig = Crypto.sign(null, Buffer.from(data), privateKey); return Buffer.from(sig).toString('base64url'); @@ -52,15 +98,34 @@ describe('SD JWT', () => { }); const encoded = sdJwt.encodeSDJwt(); - const newSdJwt = await SDJwt.fromEncode(encoded, hasher); expect(newSdJwt).toBeDefined(); const newJwt = newSdJwt.jwt; expect(newJwt?.header).toEqual(jwt.header); expect(newJwt?.payload).toEqual(jwt.payload); expect(newJwt?.signature).toEqual(jwt.signature); + + const encodedJson = sdJwt.encodeSDJwtJson('json'); + const newSdJwtJson = await SDJwt.fromEncode(encodedJson, hasher); + expect(newSdJwtJson).toBeDefined(); + const newJwt1 = newSdJwt.jwt; + expect(newJwt1?.header).toEqual(jwt.header); + expect(newJwt1?.payload).toEqual(jwt.payload); + expect(newJwt1?.signature).toEqual(jwt.signature); + + const encodedJsonFlattened = sdJwt.encodeSDJwtJson('json-flattended'); + const newSdJwtJsonFlattended = await SDJwt.fromEncode( + encodedJsonFlattened, + hasher, + ); + expect(newSdJwtJsonFlattended).toBeDefined(); + const newJwt2 = newSdJwtJsonFlattended.jwt; + expect(newJwt2?.header).toEqual(jwt.header); + expect(newJwt2?.payload).toEqual(jwt.payload); + expect(newJwt2?.signature).toEqual(jwt.signature); }); + //not clear why we need this test test('decode compatibilty', async () => { const { privateKey } = Crypto.generateKeyPairSync('ed25519'); const testSigner: Signer = async (data: string) => {