diff --git a/README.md b/README.md index cb1ec65d..19528413 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This is the reference implmentation of [IETF SD-JWT specification](https://data Hopae, a founding member of OpenWallet Foundation, is building wallet module in Typesript and need this project as a core component. -Currently compliant with: **draft-ietf-oauth-selective-disclosure-jwt-06** +Currently compliant with: **draft-ietf-oauth-selective-disclosure-jwt-07** ## **Background** diff --git a/examples/kb.ts b/examples/kb.ts index b13071dd..787617a5 100644 --- a/examples/kb.ts +++ b/examples/kb.ts @@ -23,7 +23,7 @@ export const createKeyPair = () => { aud: 'https://example.com', nonce: '1234', custom: 'data', - _sd_hash: '1234', + sd_hash: '1234', }; const encodedSdjwt = await sdjwt.issue(claims, privateKey, disclosureFrame, { diff --git a/src/index.ts b/src/index.ts index 558c6100..7a64f570 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { generateSalt, digest, getHasher } from './crypto'; +import { SDJWTException } from './error'; import { Jwt } from './jwt'; import { KBJwt } from './kbjwt'; import { SDJwt, pack } from './sdjwt'; @@ -25,6 +26,8 @@ export const defaultConfig: Required = { omitTyp: false, hasher: digest, saltGenerator: generateSalt, + signer: null, + verifier: null, }; export class SDJwtInstance { @@ -135,48 +138,43 @@ export class SDJwtInstance { publicKey: Uint8Array | KeyLike; }; }, - ): Promise { + ) { const sdjwt = SDJwt.fromEncode(encodedSDJwt); if (!sdjwt.jwt) { - return false; - } - const validated = await this.validate(encodedSDJwt, publicKey); - if (!validated) { - return false; + throw new SDJWTException('Invalid SD JWT'); } + const { payload, header } = await this.validate(encodedSDJwt, publicKey); if (requiredClaimKeys) { const keys = await sdjwt.keys(); const missingKeys = requiredClaimKeys.filter((k) => !keys.includes(k)); if (missingKeys.length > 0) { - return false; + throw new SDJWTException( + 'Missing required claim keys: ' + missingKeys.join(', '), + ); } } if (options?.kb) { if (!sdjwt.kbJwt) { - return false; - } - const kbVerified = await sdjwt.kbJwt.verify(options.kb.publicKey); - if (!kbVerified) { - return false; + throw new SDJWTException('Key Binding JWT not exist'); } + const kb = await sdjwt.kbJwt.verify(options.kb.publicKey); + return { payload, header, kb }; } - return true; + return { payload, header }; } - public async validate( - encodedSDJwt: string, - publicKey: Uint8Array | KeyLike, - ): Promise { + public async validate(encodedSDJwt: string, publicKey: Uint8Array | KeyLike) { const sdjwt = SDJwt.fromEncode(encodedSDJwt); if (!sdjwt.jwt) { - return false; + throw new SDJWTException('Invalid SD JWT'); } - const verified = await sdjwt.jwt.verify(publicKey); - return verified; + const verifiedPayloads = await sdjwt.jwt.verify(publicKey); + const claims = await sdjwt.getClaims(); + return { payload: claims, header: verifiedPayloads.header }; } public config(newConfig: SDJWTConfig) { diff --git a/src/jwt.ts b/src/jwt.ts index 1ab037c8..f2d5f1bf 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -1,7 +1,7 @@ import { Base64Url } from './base64url'; import { SDJWTException } from './error'; import * as jose from 'jose'; -import { Base64urlString } from './type'; +import { Base64urlString, Signer, Verifier } from './type'; export type JwtData< Header extends Record, @@ -86,6 +86,19 @@ export class Jwt< return encodedJwt; } + public async signWithSigner(signer: Signer) { + if (!this.header || !this.payload) { + throw new SDJWTException('Sign Error: Invalid JWT'); + } + + const header = Base64Url.encode(JSON.stringify(this.header)); + const payload = Base64Url.encode(JSON.stringify(this.payload)); + const data = `${header}.${payload}`; + this.signature = await signer(data); + + return this.encodeJwt(); + } + public encodeJwt(): string { if (!this.header || !this.payload || !this.signature) { throw new SDJWTException('Serialize Error: Invalid JWT'); @@ -108,8 +121,24 @@ export class Jwt< try { await jose.jwtVerify(jwt, publicKey); } catch (e) { - return false; + throw new SDJWTException('Verify Error: Invalid JWT Signature'); + } + return { payload: this.payload, header: this.header }; + } + + public async verifyWithVerifier(verifier: Verifier) { + if (!this.header || !this.payload || !this.signature) { + throw new SDJWTException('Verify Error: Invalid JWT'); + } + + const header = Base64Url.encode(JSON.stringify(this.header)); + const payload = Base64Url.encode(JSON.stringify(this.payload)); + const data = `${header}.${payload}`; + + const verified = verifier(data, this.signature); + if (!verified) { + throw new SDJWTException('Verify Error: Invalid JWT Signature'); } - return true; + return { payload: this.payload, header: this.header }; } } diff --git a/src/kbjwt.ts b/src/kbjwt.ts index 60ad1df9..c4b40595 100644 --- a/src/kbjwt.ts +++ b/src/kbjwt.ts @@ -1,7 +1,7 @@ import { KeyLike } from 'jose'; import { SDJWTException } from './error'; import { Jwt } from './jwt'; -import { kbHeader, kbPayload } from './type'; +import { Verifier, kbHeader, kbPayload } from './type'; export class KBJwt< Header extends kbHeader = kbHeader, @@ -14,13 +14,29 @@ export class KBJwt< !this.payload?.iat || !this.payload?.aud || !this.payload?.nonce || - !this.payload?._sd_hash + // this is for backward compatibility with version 06 + !(this.payload?.sd_hash || (this.payload as any)?._sd_hash) ) { throw new SDJWTException('Invalid Key Binding Jwt'); } return await super.verify(publicKey); } + public async verifyWithVerifier(verifier: Verifier) { + if ( + !this.header?.alg || + !this.header.typ || + !this.payload?.iat || + !this.payload?.aud || + !this.payload?.nonce || + // this is for backward compatibility with version 06 + !(this.payload?.sd_hash || (this.payload as any)?._sd_hash) + ) { + throw new SDJWTException('Invalid Key Binding Jwt'); + } + return await super.verifyWithVerifier(verifier); + } + public static fromKBEncode< Header extends kbHeader = kbHeader, Payload extends kbPayload = kbPayload, diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index e84f10e3..30ddc561 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -23,7 +23,7 @@ describe('index', () => { kb: { alg: 'EdDSA', payload: { - _sd_hash: 'sha-256', + sd_hash: 'sha-256', aud: '1', iat: 1, nonce: '342', @@ -50,7 +50,7 @@ describe('index', () => { kb: { alg: 'EdDSA', payload: { - _sd_hash: 'sha-256', + sd_hash: 'sha-256', aud: '1', iat: 1, nonce: '342', diff --git a/src/test/jwt.spec.ts b/src/test/jwt.spec.ts index 09d2e5aa..be5677a8 100644 --- a/src/test/jwt.spec.ts +++ b/src/test/jwt.spec.ts @@ -1,6 +1,7 @@ import { SDJWTException } from '../error'; import { Jwt } from '../jwt'; import Crypto from 'node:crypto'; +import { Signer, Verifier } from '../type'; describe('JWT', () => { test('create', async () => { @@ -43,11 +44,15 @@ describe('JWT', () => { const encodedJwt = await jwt.sign(privateKey); const newJwt = Jwt.fromEncode(encodedJwt); const verified = await newJwt.verify(publicKey); - expect(verified).toBe(true); - const notVerified = await newJwt.verify( - Crypto.generateKeyPairSync('ed25519').privateKey, - ); - expect(notVerified).toBe(false); + expect(verified).toStrictEqual({ + header: { alg: 'EdDSA' }, + payload: { foo: 'bar' }, + }); + try { + await newJwt.verify(Crypto.generateKeyPairSync('ed25519').privateKey); + } catch (e: unknown) { + expect(e).toBeInstanceOf(SDJWTException); + } }); test('encode', async () => { @@ -104,4 +109,53 @@ describe('JWT', () => { expect(e).toBeInstanceOf(SDJWTException); } }); + + test('custom signer', async () => { + const { privateKey, publicKey } = 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' }, + payload: { foo: 'bar' }, + }); + + const encodedJwt = await jwt.signWithSigner(testSigner); + const encodedJwt2 = await jwt.sign(privateKey); + expect(encodedJwt).toEqual(encodedJwt2); + + const newJwt = Jwt.fromEncode(encodedJwt); + const verified = await newJwt.verify(publicKey); + expect(verified).toStrictEqual({ + header: { alg: 'EdDSA' }, + payload: { foo: 'bar' }, + }); + }); + + test('custom verifier', async () => { + const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); + const testVerifier: Verifier = async (data: string, sig: string) => { + return Crypto.verify( + null, + Buffer.from(data), + publicKey, + Buffer.from(sig, 'base64url'), + ); + }; + + const jwt = new Jwt({ + header: { alg: 'EdDSA' }, + payload: { foo: 'bar' }, + }); + + const encodedJwt = await jwt.sign(privateKey); + const newJwt = Jwt.fromEncode(encodedJwt); + const verified = await newJwt.verifyWithVerifier(testVerifier); + expect(verified).toStrictEqual({ + header: { alg: 'EdDSA' }, + payload: { foo: 'bar' }, + }); + }); }); diff --git a/src/test/kbjwt.spec.ts b/src/test/kbjwt.spec.ts index ddf8ad3c..13fed9d5 100644 --- a/src/test/kbjwt.spec.ts +++ b/src/test/kbjwt.spec.ts @@ -1,6 +1,6 @@ import { SDJWTException } from '../error'; import { KBJwt } from '../kbjwt'; -import { KB_JWT_TYP, SDJWTCompact } from '../type'; +import { KB_JWT_TYP, Verifier } from '../type'; import Crypto from 'node:crypto'; describe('KB JWT', () => { @@ -14,7 +14,7 @@ describe('KB JWT', () => { iat: 1, aud: 'aud', nonce: 'nonce', - _sd_hash: 'hash', + sd_hash: 'hash', }, }); @@ -26,7 +26,7 @@ describe('KB JWT', () => { iat: 1, aud: 'aud', nonce: 'nonce', - _sd_hash: 'hash', + sd_hash: 'hash', }); }); @@ -41,7 +41,7 @@ describe('KB JWT', () => { iat: 1, aud: 'aud', nonce: 'nonce', - _sd_hash: 'hash', + sd_hash: 'hash', }, }); const encodedKbJwt = await kbJwt.sign(privateKey); @@ -54,7 +54,7 @@ describe('KB JWT', () => { iat: 1, aud: 'aud', nonce: 'nonce', - _sd_hash: 'hash', + sd_hash: 'hash', }); }); @@ -69,13 +69,24 @@ describe('KB JWT', () => { iat: 1, aud: 'aud', nonce: 'nonce', - _sd_hash: 'hash', + sd_hash: 'hash', }, }); const encodedKbJwt = await kbJwt.sign(privateKey); const decoded = KBJwt.fromKBEncode(encodedKbJwt); const verified = await decoded.verify(publicKey); - expect(verified).toBe(true); + expect(verified).toStrictEqual({ + header: { + typ: KB_JWT_TYP, + alg: 'EdDSA', + }, + payload: { + iat: 1, + aud: 'aud', + nonce: 'nonce', + sd_hash: 'hash', + }, + }); }); test('verify failed', async () => { @@ -89,7 +100,7 @@ describe('KB JWT', () => { iat: 1, aud: 'aud', nonce: 'nonce', - _sd_hash: '', + sd_hash: '', }, }); const encodedKbJwt = await kbJwt.sign(privateKey); @@ -101,4 +112,114 @@ describe('KB JWT', () => { expect(error.message).toBe('Invalid Key Binding Jwt'); } }); + + test('verify with custom Verifier', async () => { + const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); + const testVerifier: Verifier = async (data: string, sig: string) => { + return Crypto.verify( + null, + Buffer.from(data), + publicKey, + Buffer.from(sig, 'base64url'), + ); + }; + + const kbJwt = new KBJwt({ + header: { + typ: KB_JWT_TYP, + alg: 'EdDSA', + }, + payload: { + iat: 1, + aud: 'aud', + nonce: 'nonce', + sd_hash: 'hash', + }, + }); + + const encodedKbJwt = await kbJwt.sign(privateKey); + const decoded = KBJwt.fromKBEncode(encodedKbJwt); + const verified = await decoded.verifyWithVerifier(testVerifier); + expect(verified).toStrictEqual({ + header: { + typ: KB_JWT_TYP, + alg: 'EdDSA', + }, + payload: { + iat: 1, + aud: 'aud', + nonce: 'nonce', + sd_hash: 'hash', + }, + }); + }); + + test('verify failed with custom Verifier', async () => { + const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); + const testVerifier: Verifier = async (data: string, sig: string) => { + return Crypto.verify( + null, + Buffer.from(data), + publicKey, + Buffer.from(sig, 'base64url'), + ); + }; + + const kbJwt = new KBJwt({ + header: { + typ: KB_JWT_TYP, + alg: 'EdDSA', + }, + payload: { + iat: 1, + aud: 'aud', + nonce: 'nonce', + sd_hash: '', + }, + }); + + const encodedKbJwt = await kbJwt.sign(privateKey); + const decoded = KBJwt.fromKBEncode(encodedKbJwt); + try { + await decoded.verifyWithVerifier(testVerifier); + } catch (e: unknown) { + const error = e as SDJWTException; + expect(error.message).toBe('Invalid Key Binding Jwt'); + } + }); + + test('compatibility test for version 06', async () => { + const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); + const kbJwt = new KBJwt({ + header: { + typ: KB_JWT_TYP, + alg: 'EdDSA', + }, + payload: { + iat: 1, + aud: 'aud', + nonce: 'nonce', + sd_hash: 'hash', + }, + }); + + (kbJwt.payload as any)!['_sd_hash'] = 'hash'; + delete (kbJwt.payload as any)!.sd_hash; + + const encodedKbJwt = await kbJwt.sign(privateKey); + const decoded = KBJwt.fromKBEncode(encodedKbJwt); + const verified = await decoded.verify(publicKey); + expect(verified).toStrictEqual({ + header: { + typ: KB_JWT_TYP, + alg: 'EdDSA', + }, + payload: { + iat: 1, + aud: 'aud', + nonce: 'nonce', + _sd_hash: 'hash', + }, + }); + }); }); diff --git a/src/type.ts b/src/type.ts index 02f8cead..6260af90 100644 --- a/src/type.ts +++ b/src/type.ts @@ -14,6 +14,8 @@ export type SDJWTConfig = { omitTyp?: boolean; hasher?: Hasher; saltGenerator?: SaltGenerator; + signer?: Signer | null; + verifier?: Verifier | null; }; export type kbHeader = { typ: 'kb+jwt'; alg: string }; @@ -21,15 +23,15 @@ export type kbPayload = { iat: number; aud: string; nonce: string; - _sd_hash: string; + sd_hash: string; }; export type KeyBinding = Jwt; export type OrPromise = T | Promise; -export type Signer = (data: string) => OrPromise; -export type Verifier = (data: string, sig: Uint8Array) => OrPromise; +export type Signer = (data: string) => OrPromise; +export type Verifier = (data: string, sig: string) => OrPromise; export type Hasher = (data: string) => Promise; export type SaltGenerator = (length: number) => string; diff --git a/test/app-e2e.spec.ts b/test/app-e2e.spec.ts index 63cad7d6..65dc8b63 100644 --- a/test/app-e2e.spec.ts +++ b/test/app-e2e.spec.ts @@ -47,7 +47,7 @@ describe('App', () => { const encodedSdjwt = await sdjwt.issue(claims, privateKey, disclosureFrame); expect(encodedSdjwt).toBeDefined(); const validated = await sdjwt.validate(encodedSdjwt, publicKey); - expect(validated).toEqual(true); + expect(validated).toBeDefined(); const decoded = sdjwt.decode(encodedSdjwt); const keys = await decoded.keys(); @@ -102,7 +102,7 @@ describe('App', () => { publicKey, requiredClaimKeys, ); - expect(verified).toEqual(true); + expect(verified).toBeDefined(); }); test('From JSON (complex)', async () => { @@ -184,7 +184,11 @@ async function JSONtest(filename: string) { const validated = await sdjwt.validate(encodedSdjwt, publicKey); - expect(validated).toEqual(true); + expect(validated).toBeDefined(); + expect(validated).toStrictEqual({ + header: { alg: 'EdDSA', typ: 'sd-jwt' }, + payload: test.claims, + }); const presentedSDJwt = await sdjwt.present( encodedSdjwt, @@ -203,7 +207,11 @@ async function JSONtest(filename: string) { test.requiredClaimKeys, ); - expect(verified).toEqual(true); + expect(verified).toBeDefined(); + expect(verified).toStrictEqual({ + header: { alg: 'EdDSA', typ: 'sd-jwt' }, + payload: test.claims, + }); } type TestJson = {