diff --git a/.github/workflows/build-test-publish-on-push-cached.yaml b/.github/workflows/build-test-publish-on-push-cached.yaml index e04eac2..7ab31e6 100644 --- a/.github/workflows/build-test-publish-on-push-cached.yaml +++ b/.github/workflows/build-test-publish-on-push-cached.yaml @@ -84,8 +84,9 @@ jobs: path: coverage - uses: codecov/codecov-action@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} lint: needs: build @@ -108,11 +109,11 @@ jobs: node-version: 20 cache: 'pnpm' # we are not using the github action for biome, but the package.json script. this makes sure we are using the same versions. - - name: Run Biome + - name: Run Biome run: pnpm run biome:ci # Only run this job when the push is on main, next or unstable - publish: + publish: if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/unstable') # needs permissions to write tags to the repository permissions: diff --git a/packages/core/src/jwt.ts b/packages/core/src/jwt.ts index 4f0e017..1ecf9d0 100644 --- a/packages/core/src/jwt.ts +++ b/packages/core/src/jwt.ts @@ -9,6 +9,7 @@ export type JwtData< header?: Header; payload?: Payload; signature?: Base64urlString; + encoded?: string; }; // This class is used to create and verify JWT @@ -20,11 +21,13 @@ export class Jwt< public header?: Header; public payload?: Payload; public signature?: Base64urlString; + private encoded?: string; constructor(data?: JwtData) { this.header = data?.header; this.payload = data?.payload; this.signature = data?.signature; + this.encoded = data?.encoded; } public static decodeJWT< @@ -48,6 +51,7 @@ export class Jwt< header, payload, signature, + encoded: encodedJwt, }); return jwt; @@ -55,28 +59,47 @@ export class Jwt< public setHeader(header: Header): Jwt { this.header = header; + this.encoded = undefined; return this; } public setPayload(payload: Payload): Jwt { this.payload = payload; + this.encoded = undefined; return this; } - public async sign(signer: Signer) { + protected getUnsignedToken() { if (!this.header || !this.payload) { - throw new SDJWTException('Sign Error: Invalid JWT'); + throw new SDJWTException('Serialize Error: Invalid JWT'); + } + + if (this.encoded) { + const parts = this.encoded.split('.'); + if (parts.length !== 3) { + throw new SDJWTException(`Invalid JWT format: ${this.encoded}`); + } + const unsignedToken = parts.slice(0, 2).join('.'); + return unsignedToken; } const header = Base64urlEncode(JSON.stringify(this.header)); const payload = Base64urlEncode(JSON.stringify(this.payload)); - const data = `${header}.${payload}`; + return `${header}.${payload}`; + } + + public async sign(signer: Signer) { + const data = this.getUnsignedToken(); this.signature = await signer(data); return this.encodeJwt(); } public encodeJwt(): string { + if (this.encoded) { + return this.encoded; + } + if (!this.header || !this.payload || !this.signature) { throw new SDJWTException('Serialize Error: Invalid JWT'); } @@ -85,18 +108,16 @@ export class Jwt< const payload = Base64urlEncode(JSON.stringify(this.payload)); const signature = this.signature; const compact = `${header}.${payload}.${signature}`; + this.encoded = compact; return compact; } public async verify(verifier: Verifier) { - if (!this.header || !this.payload || !this.signature) { - throw new SDJWTException('Verify Error: Invalid JWT'); + if (!this.signature) { + throw new SDJWTException('Verify Error: no signature in JWT'); } - - const header = Base64urlEncode(JSON.stringify(this.header)); - const payload = Base64urlEncode(JSON.stringify(this.payload)); - const data = `${header}.${payload}`; + const data = this.getUnsignedToken(); const verified = await verifier(data, this.signature); if (!verified) { diff --git a/packages/core/src/kbjwt.ts b/packages/core/src/kbjwt.ts index c70073b..4266a4b 100644 --- a/packages/core/src/kbjwt.ts +++ b/packages/core/src/kbjwt.ts @@ -36,9 +36,7 @@ export class KBJwt< throw new SDJWTException('Invalid Key Binding Jwt'); } - const header = Base64urlEncode(JSON.stringify(this.header)); - const payload = Base64urlEncode(JSON.stringify(this.payload)); - const data = `${header}.${payload}`; + const data = this.getUnsignedToken(); const verified = await values.verifier( data, this.signature, @@ -63,6 +61,7 @@ export class KBJwt< header, payload, signature, + encoded: encodedJwt, }); return jwt; diff --git a/packages/core/src/test/index.spec.ts b/packages/core/src/test/index.spec.ts index 2cc7d11..687da0c 100644 --- a/packages/core/src/test/index.spec.ts +++ b/packages/core/src/test/index.spec.ts @@ -2,9 +2,15 @@ import { SDJwtInstance, type SdJwtPayload } from '../index'; import type { Signer, Verifier, KbVerifier, JwtPayload } from '@sd-jwt/types'; import Crypto, { type KeyLike } from 'node:crypto'; import { describe, expect, test } from 'vitest'; -import { digest, generateSalt } from '@sd-jwt/crypto-nodejs'; +import { digest, generateSalt, ES256 } from '@sd-jwt/crypto-nodejs'; import { importJWK, exportJWK, type JWK } from 'jose'; +// Extract the major version as a number +const nodeVersionMajor = Number.parseInt( + process.version.split('.')[0].substring(1), + 10, +); + export const createSignerVerifier = () => { const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); const signer: Signer = async (data: string) => { @@ -590,4 +596,33 @@ describe('index', () => { expect(decoded.disclosures).toBeDefined(); expect(decoded.kbJwt).toBeDefined(); }); + + (nodeVersionMajor < 20 ? test.skip : test)( + 'validate sd-jwt that created in other implemenation', + async () => { + const publicKeyExampleJwt: JsonWebKey = { + kty: 'EC', + crv: 'P-256', + x: 'b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ', + y: 'Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8', + }; + const kbPubkey: JsonWebKey = { + kty: 'EC', + crv: 'P-256', + x: 'TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc', + y: 'ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ', + }; + const encodedJwt = + 'eyJhbGciOiAiRVMyNTYiLCAidHlwIjogInZjK3NkLWp3dCIsICJraWQiOiAiZG9jLXNpZ25lci0wNS0yNS0yMDIyIn0.eyJfc2QiOiBbIjA5dktySk1PbHlUV00wc2pwdV9wZE9CVkJRMk0xeTNLaHBINTE1blhrcFkiLCAiMnJzakdiYUMwa3k4bVQwcEpyUGlvV1RxMF9kYXcxc1g3NnBvVWxnQ3diSSIsICJFa084ZGhXMGRIRUpidlVIbEVfVkNldUM5dVJFTE9pZUxaaGg3WGJVVHRBIiwgIklsRHpJS2VpWmREd3BxcEs2WmZieXBoRnZ6NUZnbldhLXNONndxUVhDaXciLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiamRyVEU4WWNiWTRFaWZ1Z2loaUFlX0JQZWt4SlFaSUNlaVVRd1k5UXF4SSIsICJqc3U5eVZ1bHdRUWxoRmxNXzNKbHpNYVNGemdsaFFHMERwZmF5UXdMVUs0Il0sICJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9pc3N1ZXIiLCAiaWF0IjogMTY4MzAwMDAwMCwgImV4cCI6IDE4ODMwMDAwMDAsICJ2Y3QiOiAiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLmNvbS9pZGVudGl0eV9jcmVkZW50aWFsIiwgIl9zZF9hbGciOiAic2hhLTI1NiIsICJjbmYiOiB7Imp3ayI6IHsia3R5IjogIkVDIiwgImNydiI6ICJQLTI1NiIsICJ4IjogIlRDQUVSMTladnUzT0hGNGo0VzR2ZlNWb0hJUDFJTGlsRGxzN3ZDZUdlbWMiLCAieSI6ICJaeGppV1diWk1RR0hWV0tWUTRoYlNJaXJzVmZ1ZWNDRTZ0NGpUOUYySFpRIn19fQ.QXgzrePAdq_WZVGCwDxP-l8h0iyckrHBNidxVqGtKJ0LMzObqgaXUD1cgGEf7d9TexPkBcgQYqjuzlfbeCxxuA~WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodHRwczovL2V4YW1wbGUuY29tL3ZlcmlmaWVyIiwgImlhdCI6IDE3MDk5OTYxODUsICJzZF9oYXNoIjogIjc4cFFEazJOblNEM1dKQm5SN015aWpmeUVqcGJ5a01yRnlpb2ZYSjlsN0kifQ.7k4goAlxM4a3tHnvCBCe70j_I-BCwtzhBRXQNk9cWJnQWxxt2kIqCyzcwzzUc0gTwtbGWVQoeWCiL5K6y3a4VQ'; + + const sdjwt = new SDJwtInstance({ + hasher: digest, + verifier: await ES256.getVerifier(publicKeyExampleJwt), + kbVerifier: await ES256.getVerifier(kbPubkey), + }); + + const decode = await sdjwt.verify(encodedJwt, undefined, true); + expect(decode).toBeDefined(); + }, + ); }); diff --git a/packages/core/src/test/jwt.spec.ts b/packages/core/src/test/jwt.spec.ts index 2dd5916..be512cc 100644 --- a/packages/core/src/test/jwt.spec.ts +++ b/packages/core/src/test/jwt.spec.ts @@ -138,4 +138,65 @@ describe('JWT', () => { expect(e).toBeInstanceOf(SDJWTException); } }); + + test('getUnsignedToken failed', 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' }, + }); + + try { + await jwt.sign(testSigner); + } catch (e: unknown) { + expect(e).toBeInstanceOf(SDJWTException); + } + }); + + test('wrong encoded field', 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' }, + encoded: 'asfasfafaf.dfasfafafasf', // it has to be 3 parts + }); + + try { + await jwt.sign(testSigner); + } catch (e: unknown) { + expect(e).toBeInstanceOf(SDJWTException); + } + }); + + test('verify failed no signature', 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' }, + }); + + try { + await jwt.verify(testVerifier); + } catch (e: unknown) { + expect(e).toBeInstanceOf(SDJWTException); + } + }); }); diff --git a/packages/core/src/test/sdjwt.spec.ts b/packages/core/src/test/sdjwt.spec.ts index 1a9d4f2..c39d6e9 100644 --- a/packages/core/src/test/sdjwt.spec.ts +++ b/packages/core/src/test/sdjwt.spec.ts @@ -376,7 +376,9 @@ describe('SD JWT', () => { const credential = sdJwt.encodeSDJwt(); const decoded = await SDJwt.decodeSDJwt(credential, hasher); - expect(jwt).toEqual(decoded.jwt); + expect(jwt.header).toEqual(decoded.jwt.header); + expect(jwt.payload).toEqual(decoded.jwt.payload); + expect(jwt.signature).toEqual(decoded.jwt.signature); expect(decoded.disclosures).toEqual([]); }); });