diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 610347a..8b64aaa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,7 @@ export class SDJwtInstance { public static DEFAULT_hashAlg = 'sha-256'; - private userConfig: SDJWTConfig = {}; + protected userConfig: SDJWTConfig = {}; constructor(userConfig?: SDJWTConfig) { if (userConfig) { diff --git a/packages/jwt-status-list/README.md b/packages/jwt-status-list/README.md new file mode 100644 index 0000000..265555a --- /dev/null +++ b/packages/jwt-status-list/README.md @@ -0,0 +1,94 @@ +![License](https://img.shields.io/github/license/openwallet-foundation-labs/sd-jwt-js.svg) +![NPM](https://img.shields.io/npm/v/%40sd-jwt%2Fhash) +![Release](https://img.shields.io/github/v/release/openwallet-foundation-labs/sd-jwt-js) +![Stars](https://img.shields.io/github/stars/openwallet-foundation-labs/sd-jwt-js) + +# SD-JWT Implementation in JavaScript (TypeScript) + +## jwt-status-list +An implementation of the [Token Status List](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/) for a JWT representation, not for CBOR. +This library helps to verify the status of a specific entry in a JWT, and to generate a status list and pack it into a signed JWT. It does not provide any functions to manage the status list itself. + + + +## Installation + +To install this project, run the following command: + +```bash +# using npm +npm install @sd-jwt/jwt-status-list + +# using yarn +yarn add @sd-jwt/jwt-status-list + +# using pnpm +pnpm install @sd-jwt/jwt-status-list +``` + +Ensure you have Node.js installed as a prerequisite. +## Usage + +Creation of a JWT Status List: +```typescript +// pass the list as an array and the amount of bits per entry. +const list = new StatusList([1, 0, 1, 1, 1], 1); +const iss = 'https://example.com'; +const payload: JWTPayload = { + iss, + sub: `${iss}/statuslist/1`, + iat: new Date().getTime() / 1000, + ttl: 3000, // time to live in seconds, optional + exp: new Date().getTime() / 1000 + 3600, // optional +}; +const header: JWTHeaderParameters = { alg: 'ES256' }; + +const jwt = createHeaderAndPayload(list, payload, header); + +// Sign the JWT with the private key, e.g. using the `jose` library +const jwt = await new SignJWT(values.payload) + .setProtectedHeader(values.header) + .sign(privateKey); + +``` + +Interaction with a JWT status list on low level: +```typescript +//validation of the JWT is not provided by this library!!! + +// jwt that includes the status list reference +const reference = getStatusListFromJWT(jwt); + +// download the status list +const list = await fetch(reference.uri); + +//TODO: validate that the list jwt is signed by the issuer and is not expired!!! + +//extract the status list +const statusList = getListFromStatusListJWT(list); + +//get the status of a specific entry +const status = statusList.getStatus(reference.idx); +``` + +### Integration into sd-jwt-vc +The status list can be integrated into the [sd-jwt-vc](../sd-jwt-vc/README.md) library to provide a way to verify the status of a credential. In the [test folder](../sd-jwt-vc/src/test/index.spec.ts) you will find an example how to add the status reference to a credential and also how to verify the status of a credential. + +```typescript + +### Caching the status list +Depending on the `ttl` field if provided the status list can be cached for a certain amount of time. This library has no internal cache mechanism, so it is up to the user to implement it for example by providing a custom `fetchStatusList` function. + +## Development + +Install the dependencies: + +```bash +pnpm install +``` + +Run the tests: + +```bash +pnpm test +``` diff --git a/packages/jwt-status-list/package.json b/packages/jwt-status-list/package.json new file mode 100644 index 0000000..da37d26 --- /dev/null +++ b/packages/jwt-status-list/package.json @@ -0,0 +1,57 @@ +{ + "name": "@sd-jwt/jwt-status-list", + "version": "0.6.1", + "description": "Implementation based on https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf **/dist && tsup", + "lint": "biome lint ./src", + "test": "pnpm run test:node && pnpm run test:browser && pnpm run test:cov", + "test:node": "vitest run ./src/test/*.spec.ts", + "test:browser": "vitest run ./src/test/*.spec.ts --environment jsdom", + "test:cov": "vitest run --coverage" + }, + "keywords": ["sd-jwt-vc", "status-list", "sd-jwt"], + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "https://github.com/openwallet-foundation-labs/sd-jwt-js" + }, + "author": "Mirko Mollik ", + "homepage": "https://github.com/openwallet-foundation-labs/sd-jwt-js/wiki", + "bugs": { + "url": "https://github.com/openwallet-foundation-labs/sd-jwt-js/issues" + }, + "license": "Apache-2.0", + "devDependencies": { + "@types/pako": "^2.0.3", + "jose": "^5.2.2" + }, + "dependencies": { + "@sd-jwt/types": "workspace:*", + "base64url": "^3.0.1", + "pako": "^2.1.0" + }, + "publishConfig": { + "access": "public" + }, + "tsup": { + "entry": ["./src/index.ts"], + "sourceMap": true, + "splitting": false, + "clean": true, + "dts": true, + "format": ["cjs", "esm"] + }, + "gitHead": "ded40e4551bde7ae93083181bf26bd1b38bbfcfb" +} diff --git a/packages/jwt-status-list/src/index.ts b/packages/jwt-status-list/src/index.ts new file mode 100644 index 0000000..21aa70d --- /dev/null +++ b/packages/jwt-status-list/src/index.ts @@ -0,0 +1,3 @@ +export * from './status-list.js'; +export * from './status-list-jwt.js'; +export * from './types.js'; diff --git a/packages/jwt-status-list/src/status-list-jwt.ts b/packages/jwt-status-list/src/status-list-jwt.ts new file mode 100644 index 0000000..11dea0e --- /dev/null +++ b/packages/jwt-status-list/src/status-list-jwt.ts @@ -0,0 +1,73 @@ +import type { JwtPayload } from '@sd-jwt/types'; +import { StatusList } from './status-list.js'; +import type { + JWTwithStatusListPayload, + StatusListJWTHeaderParameters, + StatusListEntry, + StatusListJWTPayload, +} from './types.js'; +import base64Url from 'base64url'; + +/** + * Decode a JWT and return the payload. + * @param jwt JWT token in compact JWS serialization. + * @returns Payload of the JWT. + */ +function decodeJwt(jwt: string): T { + const parts = jwt.split('.'); + return JSON.parse(base64Url.decode(parts[1])); +} + +/** + * Adds the status list to the payload and header of a JWT. + * @param list + * @param payload + * @param header + * @returns The header and payload with the status list added. + */ +export function createHeaderAndPayload( + list: StatusList, + payload: JwtPayload, + header: StatusListJWTHeaderParameters, +) { + // validate if the required fieds are present based on https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html#section-5.1 + + if (!payload.iss) { + throw new Error('iss field is required'); + } + if (!payload.sub) { + throw new Error('sub field is required'); + } + if (!payload.iat) { + throw new Error('iat field is required'); + } + //exp and tll are optional. We will not validate the business logic of the values like exp > iat etc. + + header.typ = 'statuslist+jwt'; + payload.status_list = { + bits: list.getBitsPerStatus(), + lst: list.compressStatusList(), + }; + return { header, payload }; +} + +/** + * Get the status list from a JWT, but do not verify the signature. + * @param jwt + * @returns + */ +export function getListFromStatusListJWT(jwt: string): StatusList { + const payload = decodeJwt(jwt); + const statusList = payload.status_list; + return StatusList.decompressStatusList(statusList.lst, statusList.bits); +} + +/** + * Get the status list entry from a JWT, but do not verify the signature. + * @param jwt + * @returns + */ +export function getStatusListFromJWT(jwt: string): StatusListEntry { + const payload = decodeJwt(jwt); + return payload.status.status_list; +} diff --git a/packages/jwt-status-list/src/status-list.ts b/packages/jwt-status-list/src/status-list.ts new file mode 100644 index 0000000..e4c0b28 --- /dev/null +++ b/packages/jwt-status-list/src/status-list.ts @@ -0,0 +1,167 @@ +import { deflate, inflate } from 'pako'; +import base64Url from 'base64url'; +import type { BitsPerStatus } from './types.js'; +/** + * StatusListManager is a class that manages a list of statuses with variable bit size. + */ +export class StatusList { + private statusList: number[]; + private bitsPerStatus: BitsPerStatus; + private totalStatuses: number; + + /** + * Create a new StatusListManager instance. + * @param statusList + * @param bitsPerStatus + */ + constructor(statusList: number[], bitsPerStatus: BitsPerStatus) { + if (![1, 2, 4, 8].includes(bitsPerStatus)) { + throw new Error('bitsPerStatus must be 1, 2, 4, or 8'); + } + //check that the entries in the statusList are within the range of the bitsPerStatus + for (let i = 0; i < statusList.length; i++) { + if (statusList[i] > 2 ** bitsPerStatus) { + throw Error( + `Status value out of range at index ${i} with value ${statusList[i]}`, + ); + } + } + this.statusList = statusList; + this.bitsPerStatus = bitsPerStatus; + this.totalStatuses = statusList.length; + } + + /** + * Get the number of statuses. + * @returns + */ + getBitsPerStatus(): BitsPerStatus { + return this.bitsPerStatus; + } + + /** + * Get the status at a specific index. + * @param index + * @returns + */ + getStatus(index: number): number { + if (index < 0 || index >= this.totalStatuses) { + throw new Error('Index out of bounds'); + } + return this.statusList[index]; + } + + /** + * Set the status at a specific index. + * @param index + * @param value + */ + setStatus(index: number, value: number): void { + if (index < 0 || index >= this.totalStatuses) { + throw new Error('Index out of bounds'); + } + this.statusList[index] = value; + } + + /** + * Compress the status list. + * @returns + */ + compressStatusList(): string { + const byteArray = this.encodeStatusList(); + const compressed = deflate(byteArray, { level: 9 }); + return base64Url.encode(compressed as Buffer); + } + + /** + * Decompress the compressed status list and return a new StatusList instance. + * @param compressed + * @param bitsPerStatus + * @returns + */ + static decompressStatusList( + compressed: string, + bitsPerStatus: BitsPerStatus, + ): StatusList { + const decoded = new Uint8Array( + base64Url + .decode(compressed, 'binary') + .split('') + .map((c) => c.charCodeAt(0)), + ); + try { + const decompressed = inflate(decoded); + const statusList = StatusList.decodeStatusList( + decompressed, + bitsPerStatus, + ); + return new StatusList(statusList, bitsPerStatus); + } catch (err: unknown) { + throw new Error(`Decompression failed: ${err}`); + } + } + + /** + * Encode the status list into a byte array. + * @returns + **/ + public encodeStatusList(): Uint8Array { + const numBits = this.bitsPerStatus; + const numBytes = Math.ceil((this.totalStatuses * numBits) / 8); + const byteArray = new Uint8Array(numBytes); + let byteIndex = 0; + let bitIndex = 0; + let currentByte = ''; + for (let i = 0; i < this.totalStatuses; i++) { + const status = this.statusList[i]; + // Place bits from status into currentByte, starting from the most significant bit. + currentByte = status.toString(2).padStart(numBits, '0') + currentByte; + bitIndex += numBits; + + // If currentByte is full or this is the last status, add it to byteArray and reset currentByte and bitIndex. + if (bitIndex >= 8 || i === this.totalStatuses - 1) { + // If this is the last status and bitIndex is not a multiple of 8, shift currentByte to the left. + if (i === this.totalStatuses - 1 && bitIndex % 8 !== 0) { + currentByte = currentByte.padStart(8, '0'); + } + byteArray[byteIndex] = Number.parseInt(currentByte, 2); + currentByte = ''; + bitIndex = 0; + byteIndex++; + } + } + + return byteArray; + } + + /** + * Decode the byte array into a status list. + * @param byteArray + * @param bitsPerStatus + * @returns + */ + private static decodeStatusList( + byteArray: Uint8Array, + bitsPerStatus: BitsPerStatus, + ): number[] { + const numBits = bitsPerStatus; + const totalStatuses = (byteArray.length * 8) / numBits; + const statusList = new Array(totalStatuses); + let bitIndex = 0; // Current position in byte + for (let i = 0; i < totalStatuses; i++) { + const byte = byteArray[Math.floor((i * numBits) / 8)]; + let byteString = byte.toString(2); + if (byteString.length < 8) { + byteString = '0'.repeat(8 - byteString.length) + byteString; + } + const status = byteString.slice(bitIndex, bitIndex + numBits); + const group = Math.floor(i / (8 / numBits)); + const indexInGroup = i % (8 / numBits); + const position = + group * (8 / numBits) + (8 / numBits + -1 - indexInGroup); + statusList[position] = Number.parseInt(status, 2); + bitIndex = (bitIndex + numBits) % 8; + } + return statusList; + } +} diff --git a/packages/jwt-status-list/src/test/status-list-jwt.spec.ts b/packages/jwt-status-list/src/test/status-list-jwt.spec.ts new file mode 100644 index 0000000..025d35a --- /dev/null +++ b/packages/jwt-status-list/src/test/status-list-jwt.spec.ts @@ -0,0 +1,126 @@ +import { + createHeaderAndPayload, + getListFromStatusListJWT, + getStatusListFromJWT, +} from '../status-list-jwt'; +import type { + StatusListJWTHeaderParameters, + JWTwithStatusListPayload, +} from '../types'; +import { StatusList } from '../status-list'; +import { jwtVerify, type KeyLike, SignJWT } from 'jose'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { generateKeyPairSync } from 'node:crypto'; +import type { JwtPayload } from '@sd-jwt/types'; + +describe('JWTStatusList', () => { + let publicKey: KeyLike; + let privateKey: KeyLike; + + const header: StatusListJWTHeaderParameters = { + alg: 'ES256', + typ: 'statuslist+jwt', + }; + + beforeAll(() => { + // Generate a key pair for testing + const keyPair = generateKeyPairSync('ec', { + namedCurve: 'P-256', + }); + publicKey = keyPair.publicKey; + privateKey = keyPair.privateKey; + }); + + it('should create a JWT with a status list', async () => { + const statusList = new StatusList([1, 0, 1, 1, 1], 1); + const iss = 'https://example.com'; + const payload: JwtPayload = { + iss, + sub: `${iss}/statuslist/1`, + iat: new Date().getTime() / 1000, + }; + + const values = createHeaderAndPayload(statusList, payload, header); + + const jwt = await new SignJWT(values.payload) + .setProtectedHeader(values.header) + .sign(privateKey); + // Verify the signed JWT with the public key + const verified = await jwtVerify(jwt, publicKey); + expect(verified.payload.status_list).toEqual({ + bits: statusList.getBitsPerStatus(), + lst: statusList.compressStatusList(), + }); + expect(verified.protectedHeader.typ).toBe('statuslist+jwt'); + }); + + it('should get the status list from a JWT without verifying the signature', async () => { + const list = [1, 0, 1, 0, 1]; + const statusList = new StatusList(list, 1); + const iss = 'https://example.com'; + const payload: JwtPayload = { + iss, + sub: `${iss}/statuslist/1`, + iat: new Date().getTime() / 1000, + }; + + const values = createHeaderAndPayload(statusList, payload, header); + + const jwt = await new SignJWT(values.payload) + .setProtectedHeader(values.header) + .sign(privateKey); + + const extractedList = getListFromStatusListJWT(jwt); + for (let i = 0; i < list.length; i++) { + expect(extractedList.getStatus(i)).toBe(list[i]); + } + }); + + it('should throw an error if the JWT is invalid', async () => { + const list = [1, 0, 1, 0, 1]; + const statusList = new StatusList(list, 2); + const iss = 'https://example.com'; + let payload: JwtPayload = { + sub: `${iss}/statuslist/1`, + iat: new Date().getTime() / 1000, + }; + expect(() => { + createHeaderAndPayload(statusList, payload as JwtPayload, header); + }).toThrow('iss field is required'); + + payload = { + iss, + iat: new Date().getTime() / 1000, + }; + expect(() => createHeaderAndPayload(statusList, payload, header)).toThrow( + 'sub field is required', + ); + + payload = { + iss, + sub: `${iss}/statuslist/1`, + }; + expect(() => createHeaderAndPayload(statusList, payload, header)).toThrow( + 'iat field is required', + ); + }); + + it('should get the status entry from a JWT', async () => { + const payload: JWTwithStatusListPayload = { + iss: 'https://example.com', + sub: 'https://example.com/status/1', + iat: new Date().getTime() / 1000, + status: { + status_list: { + idx: 0, + uri: 'https://example.com/status/1', + }, + }, + }; + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: 'ES256' }) + .sign(privateKey); + const reference = getStatusListFromJWT(jwt); + expect(reference).toEqual(payload.status.status_list); + }); +}); diff --git a/packages/jwt-status-list/src/test/status-list.spec.ts b/packages/jwt-status-list/src/test/status-list.spec.ts new file mode 100644 index 0000000..cb9eaa7 --- /dev/null +++ b/packages/jwt-status-list/src/test/status-list.spec.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, test } from 'vitest'; +import { StatusList } from '../index'; +import type { BitsPerStatus } from '../types'; + +describe('StatusList', () => { + const listLength = 10000; + + it('test from the example with 1 bit status', () => { + const status: number[] = []; + status[0] = 1; + status[1] = 0; + status[2] = 0; + status[3] = 1; + status[4] = 1; + status[5] = 1; + status[6] = 0; + status[7] = 1; + status[8] = 1; + status[9] = 1; + status[10] = 0; + status[11] = 0; + status[12] = 0; + status[13] = 1; + status[14] = 0; + status[15] = 1; + const manager = new StatusList(status, 1); + const encoded = manager.compressStatusList(); + expect(encoded).toBe('eNrbuRgAAhcBXQ'); + const l = StatusList.decompressStatusList(encoded, 1); + for (let i = 0; i < status.length; i++) { + expect(l.getStatus(i)).toBe(status[i]); + } + }); + + it('test from the example with 2 bit status', () => { + const status: number[] = []; + status[0] = 1; + status[1] = 2; + status[2] = 0; + status[3] = 3; + status[4] = 0; + status[5] = 1; + status[6] = 0; + status[7] = 1; + status[8] = 1; + status[9] = 2; + status[10] = 3; + status[11] = 3; + const manager = new StatusList(status, 2); + const encoded = manager.compressStatusList(); + expect(encoded).toBe('eNo76fITAAPfAgc'); + const l = StatusList.decompressStatusList(encoded, 2); + for (let i = 0; i < status.length; i++) { + expect(l.getStatus(i)).toBe(status[i]); + } + }); + + // Test with different bitsPerStatus values + describe.each([ + [1 as BitsPerStatus], + [2 as BitsPerStatus], + [4 as BitsPerStatus], + [8 as BitsPerStatus], + ])('with %i bitsPerStatus', (bitsPerStatus) => { + let manager: StatusList; + + function createListe( + length: number, + bitsPerStatus: BitsPerStatus, + ): number[] { + const list: number[] = []; + for (let i = 0; i < length; i++) { + list.push(Math.floor(Math.random() * 2 ** bitsPerStatus)); + } + return list; + } + + it('should pass an incorrect list with wrong entries', () => { + expect(() => { + new StatusList([2 ** bitsPerStatus + 1], bitsPerStatus); + }).toThrowError(); + }); + + it('should compress and decompress status list correctly', () => { + const statusList = createListe(listLength, bitsPerStatus); + manager = new StatusList(statusList, bitsPerStatus); + const compressedStatusList = manager.compressStatusList(); + const decodedStatuslist = StatusList.decompressStatusList( + compressedStatusList, + bitsPerStatus, + ); + checkIfEqual(decodedStatuslist, statusList); + }); + + it('should return the bitsPerStatus value', () => { + const statusList = createListe( + listLength, + bitsPerStatus as BitsPerStatus, + ); + manager = new StatusList(statusList, bitsPerStatus as BitsPerStatus); + expect(manager.getBitsPerStatus()).toBe(bitsPerStatus); + }); + + it('getStatus returns the correct status', () => { + const statusList = createListe( + listLength, + bitsPerStatus as BitsPerStatus, + ); + manager = new StatusList(statusList, bitsPerStatus as BitsPerStatus); + + for (let i = 0; i < statusList.length; i++) { + expect(manager.getStatus(i)).toBe(statusList[i]); + } + }); + + it('setStatus sets the correct status', () => { + const statusList = createListe( + listLength, + bitsPerStatus as BitsPerStatus, + ); + manager = new StatusList(statusList, bitsPerStatus as BitsPerStatus); + + const newValue = Math.floor(Math.random() * 2 ** bitsPerStatus); + manager.setStatus(0, newValue); + expect(manager.getStatus(0)).toBe(newValue); + }); + + it('getStatus throws an error for out of bounds index', () => { + const statusList = createListe( + listLength, + bitsPerStatus as BitsPerStatus, + ); + manager = new StatusList(statusList, bitsPerStatus as BitsPerStatus); + + expect(() => manager.getStatus(-1)).toThrow('Index out of bounds'); + expect(() => manager.getStatus(listLength)).toThrow( + 'Index out of bounds', + ); + }); + + it('setStatus throws an error for out of bounds index', () => { + const statusList = createListe( + listLength, + bitsPerStatus as BitsPerStatus, + ); + manager = new StatusList(statusList, bitsPerStatus as BitsPerStatus); + + expect(() => manager.setStatus(-1, 5)).toThrow('Index out of bounds'); + expect(() => manager.setStatus(listLength, 6)).toThrow( + 'Index out of bounds', + ); + }); + + it('decompressStatusList throws an error when decompression fails', () => { + const statusList = createListe( + listLength, + bitsPerStatus as BitsPerStatus, + ); + manager = new StatusList(statusList, bitsPerStatus as BitsPerStatus); + + const invalidCompressedData = 'invalid data'; + + expect(() => + StatusList.decompressStatusList(invalidCompressedData, bitsPerStatus), + ).toThrowError(); + }); + + test('encodeStatusList covers remaining bits in last byte', () => { + const bitsPerStatus = 1; + const totalStatuses = 10; // Not a multiple of 8 + const statusList = Array(totalStatuses).fill(0); + const manager = new StatusList(statusList, bitsPerStatus); + const encoded = manager.compressStatusList(); + const decoded = StatusList.decompressStatusList(encoded, bitsPerStatus); + //technially we need to validate all the status but we are just checking the length + checkIfEqual(decoded, statusList); + }); + + /** + * Check if the status list is equal to the given list. + * @param statuslist1 + * @param rawStatusList + */ + function checkIfEqual(statuslist1: StatusList, rawStatusList: number[]) { + for (let i = 0; i < rawStatusList.length; i++) { + expect(statuslist1.getStatus(i)).toBe(rawStatusList[i]); + } + } + + describe('constructor', () => { + test.each<[number]>([ + [3], // Invalid bitsPerStatus value + [5], // Invalid bitsPerStatus value + [6], // Invalid bitsPerStatus value + [7], // Invalid bitsPerStatus value + [9], // Invalid bitsPerStatus value + [10], // Invalid bitsPerStatus value + ])( + 'throws an error for invalid bitsPerStatus value (%i)', + (bitsPerStatus) => { + expect(() => { + new StatusList([], bitsPerStatus as BitsPerStatus); + }).toThrowError('bitsPerStatus must be 1, 2, 4, or 8'); + }, + ); + + test.each<[BitsPerStatus]>([ + [1], // Valid bitsPerStatus value + [2], // Valid bitsPerStatus value + [4], // Valid bitsPerStatus value + [8], // Valid bitsPerStatus value + ])( + 'does not throw an error for valid bitsPerStatus value (%i)', + (bitsPerStatus) => { + expect(() => { + new StatusList([], bitsPerStatus); + }).not.toThrowError(); + }, + ); + }); + }); +}); diff --git a/packages/jwt-status-list/src/types.ts b/packages/jwt-status-list/src/types.ts new file mode 100644 index 0000000..276fd2d --- /dev/null +++ b/packages/jwt-status-list/src/types.ts @@ -0,0 +1,43 @@ +import type { JwtPayload } from '@sd-jwt/types'; + +/** + * Reference to a status list entry. + */ +export interface StatusListEntry { + idx: number; + uri: string; +} + +/** + * Payload for a JWT + */ +export interface JWTwithStatusListPayload extends JwtPayload { + status: { + status_list: StatusListEntry; + }; +} + +/** + * Payload for a JWT with a status list. + */ +export interface StatusListJWTPayload extends JwtPayload { + ttl?: number; + status_list: { + bits: BitsPerStatus; + lst: string; + }; +} + +/** + * BitsPerStatus type. + */ +export type BitsPerStatus = 1 | 2 | 4 | 8; + +/** + * Header parameters for a JWT. + */ +export type StatusListJWTHeaderParameters = { + alg: string; + typ: 'statuslist+jwt'; + [key: string]: unknown; +}; diff --git a/packages/jwt-status-list/tsconfig.json b/packages/jwt-status-list/tsconfig.json new file mode 100644 index 0000000..2a11ecd --- /dev/null +++ b/packages/jwt-status-list/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/packages/jwt-status-list/vitest.config.mts b/packages/jwt-status-list/vitest.config.mts new file mode 100644 index 0000000..5842dff --- /dev/null +++ b/packages/jwt-status-list/vitest.config.mts @@ -0,0 +1,4 @@ +// vite.config.ts +import { allEnvs } from '../../vitest.shared'; + +export default allEnvs; diff --git a/packages/sd-jwt-vc/README.md b/packages/sd-jwt-vc/README.md index 9c3b32c..067e2bd 100644 --- a/packages/sd-jwt-vc/README.md +++ b/packages/sd-jwt-vc/README.md @@ -83,8 +83,13 @@ const verified = await sdjwt.verify(presentation); Check out more details in our [documentation](https://github.com/openwallet-foundation-labs/sd-jwt-js/tree/main/docs) or [examples](https://github.com/openwallet-foundation-labs/sd-jwt-js/tree/main/examples) +### Revocation +To add revocation capabilities, you can use the `@sd-jwt/jwt-status-list` library to create a JWT Status List and include it in the SD-JWT-VC. + + ### Dependencies - @sd-jwt/core - @sd-jwt/types - @sd-jwt/utils +- @sd-jwt/jwt-status-list diff --git a/packages/sd-jwt-vc/package.json b/packages/sd-jwt-vc/package.json index 024cec2..78d0377 100644 --- a/packages/sd-jwt-vc/package.json +++ b/packages/sd-jwt-vc/package.json @@ -35,11 +35,13 @@ }, "license": "Apache-2.0", "dependencies": { - "@sd-jwt/core": "workspace:*" + "@sd-jwt/core": "workspace:*", + "@sd-jwt/jwt-status-list": "workspace:*" }, "devDependencies": { "@sd-jwt/crypto-nodejs": "workspace:*", - "@sd-jwt/types": "workspace:*" + "@sd-jwt/types": "workspace:*", + "jose": "^5.2.2" }, "publishConfig": { "access": "public" diff --git a/packages/sd-jwt-vc/src/index.ts b/packages/sd-jwt-vc/src/index.ts index 0a4a6f5..f79727c 100644 --- a/packages/sd-jwt-vc/src/index.ts +++ b/packages/sd-jwt-vc/src/index.ts @@ -1,16 +1,28 @@ -import { SDJwtInstance } from '@sd-jwt/core'; -import type { DisclosureFrame } from '@sd-jwt/types'; +import { Jwt, SDJwtInstance } from '@sd-jwt/core'; +import type { DisclosureFrame, Verifier } from '@sd-jwt/types'; import { SDJWTException } from '../../utils/dist'; import type { SdJwtVcPayload } from './sd-jwt-vc-payload'; - -export { SdJwtVcPayload } from './sd-jwt-vc-payload'; - +import type { SDJWTVCConfig } from './sd-jwt-vc-config'; +import { + type StatusListJWTHeaderParameters, + type StatusListJWTPayload, + getListFromStatusListJWT, +} from '@sd-jwt/jwt-status-list'; export class SDJwtVcInstance extends SDJwtInstance { /** * The type of the SD-JWT-VC set in the header.typ field. */ protected type = 'vc+sd-jwt'; + protected userConfig: SDJWTVCConfig = {}; + + constructor(userConfig?: SDJWTVCConfig) { + super(userConfig); + if (userConfig) { + this.userConfig = userConfig; + } + } + /** * Validates if the disclosureFrame contains any reserved fields. If so it will throw an error. * @param disclosureFrame @@ -34,4 +46,93 @@ export class SDJwtVcInstance extends SDJwtInstance { } } } + + /** + * Fetches the status list from the uri with a timeout of 10 seconds. + * @param uri The URI to fetch from. + * @returns A promise that resolves to a compact JWT. + */ + private async statusListFetcher(uri: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(uri, { signal: controller.signal }); + if (!response.ok) { + throw new Error( + `Error fetching status list: ${ + response.status + } ${await response.text()}`, + ); + } + + return response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Validates the status, throws an error if the status is not 0. + * @param status + * @returns + */ + private async statusValidator(status: number): Promise { + if (status !== 0) throw new SDJWTException('Status is not valid'); + return Promise.resolve(); + } + + /** + * Verifies the SD-JWT-VC. + */ + async verify( + encodedSDJwt: string, + requiredClaimKeys?: string[], + requireKeyBindings?: boolean, + ) { + // Call the parent class's verify method + const result = await super + .verify(encodedSDJwt, requiredClaimKeys, requireKeyBindings) + .then((res) => { + return { payload: res.payload as SdJwtVcPayload, header: res.header }; + }); + + if (result.payload.status) { + //checks if a status field is present in the payload based on https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html + if (result.payload.status.status_list) { + // fetch the status list from the uri + const fetcher = + this.userConfig.statusListFetcher ?? this.statusListFetcher; + // fetch the status list from the uri + const statusListJWT = await fetcher( + result.payload.status.status_list.uri, + ); + + const slJWT = Jwt.fromEncode< + StatusListJWTHeaderParameters, + StatusListJWTPayload + >(statusListJWT); + // check if the status list has a valid signature. The presence of the verifier is checked in the parent class. + await slJWT.verify(this.userConfig.verifier as Verifier); + + //check if the status list is expired + if (slJWT.payload?.exp && slJWT.payload.exp < Date.now() / 1000) { + throw new SDJWTException('Status list is expired'); + } + + // get the status list from the status list JWT + const statusList = getListFromStatusListJWT(statusListJWT); + const status = statusList.getStatus( + result.payload.status.status_list.idx, + ); + + // validate the status + const statusValidator = + this.userConfig.statusValidator ?? this.statusValidator; + await statusValidator(status); + } + } + + return result; + } } diff --git a/packages/sd-jwt-vc/src/sd-jwt-vc-config.ts b/packages/sd-jwt-vc/src/sd-jwt-vc-config.ts new file mode 100644 index 0000000..661cb8b --- /dev/null +++ b/packages/sd-jwt-vc/src/sd-jwt-vc-config.ts @@ -0,0 +1,11 @@ +import type { SDJWTConfig } from '@sd-jwt/types'; + +/** + * Configuration for SD-JWT-VC + */ +export type SDJWTVCConfig = SDJWTConfig & { + // A function that fetches the status list from the uri. If not provided, the library will assume that the response is a compact JWT. + statusListFetcher?: (uri: string) => Promise; + // validte the status and decide if the status is valid or not. If not provided, the code will continue if it is 0, otherwise it will throw an error. + statusValidator?: (status: number) => Promise; +}; diff --git a/packages/sd-jwt-vc/src/sd-jwt-vc-payload.ts b/packages/sd-jwt-vc/src/sd-jwt-vc-payload.ts index 836d5f2..62878a4 100644 --- a/packages/sd-jwt-vc/src/sd-jwt-vc-payload.ts +++ b/packages/sd-jwt-vc/src/sd-jwt-vc-payload.ts @@ -1,4 +1,5 @@ import type { SdJwtPayload } from '@sd-jwt/core'; +import type { SDJWTVCStatusReference } from './sd-jwt-vc-status-reference'; export interface SdJwtVcPayload extends SdJwtPayload { // REQUIRED. The Issuer of the Verifiable Credential. The value of iss MUST be a URI. See [RFC7519] for more information. @@ -11,9 +12,8 @@ export interface SdJwtVcPayload extends SdJwtPayload { cnf?: unknown; // REQUIRED. The type of the Verifiable Credential, e.g., https://credentials.example.com/identity_credential, as defined in Section 3.2.2.1.1. vct: string; - // OPTIONAL. The information on how to read the status of the Verifiable Credential. See [I-D.looker-oauth-jwt-cwt-status-list] for more information. - status?: unknown; - + // OPTIONAL. The information on how to read the status of the Verifiable Credential. See [https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html] for more information. + status?: SDJWTVCStatusReference; // OPTIONAL. The identifier of the Subject of the Verifiable Credential. The Issuer MAY use it to provide the Subject identifier known by the Issuer. There is no requirement for a binding to exist between sub and cnf claims. sub?: string; // OPTIONAL. The time of issuance of the Verifiable Credential. See [RFC7519] for more information. diff --git a/packages/sd-jwt-vc/src/sd-jwt-vc-status-reference.ts b/packages/sd-jwt-vc/src/sd-jwt-vc-status-reference.ts new file mode 100644 index 0000000..5aa97de --- /dev/null +++ b/packages/sd-jwt-vc/src/sd-jwt-vc-status-reference.ts @@ -0,0 +1,9 @@ +export interface SDJWTVCStatusReference { + // REQUIRED. implenentation according to https://www.ietf.org/archive/id/draft-ietf-oauth-status-list-02.html + status_list: { + // REQUIRED. index in the list of statuses + idx: number; + // REQUIRED. the reference to fetch the status list + uri: string; + }; +} diff --git a/packages/sd-jwt-vc/src/test/index.spec.ts b/packages/sd-jwt-vc/src/test/index.spec.ts index 03527db..b38cd9c 100644 --- a/packages/sd-jwt-vc/src/test/index.spec.ts +++ b/packages/sd-jwt-vc/src/test/index.spec.ts @@ -1,16 +1,27 @@ import { digest, generateSalt } from '@sd-jwt/crypto-nodejs'; -import type { DisclosureFrame, Signer, Verifier } from '@sd-jwt/types'; +import type { + DisclosureFrame, + Signer, + Verifier, + JwtPayload, +} from '@sd-jwt/types'; import { describe, test, expect } from 'vitest'; import { SDJwtVcInstance } from '..'; import type { SdJwtVcPayload } from '../sd-jwt-vc-payload'; import Crypto from 'node:crypto'; +import { + StatusList, + type StatusListJWTHeaderParameters, + createHeaderAndPayload, +} from '@sd-jwt/jwt-status-list'; +import { SignJWT } from 'jose'; const iss = 'ExampleIssuer'; const vct = 'https://example.com/schema/1'; const iat = new Date().getTime() / 1000; +const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); const createSignerVerifier = () => { - const { privateKey, publicKey } = Crypto.generateKeyPairSync('ed25519'); const signer: Signer = async (data: string) => { const sig = Crypto.sign(null, Buffer.from(data), privateKey); return Buffer.from(sig).toString('base64url'); @@ -26,6 +37,25 @@ const createSignerVerifier = () => { return { signer, verifier }; }; +const generateStatusList = async (): Promise => { + const statusList = new StatusList([0, 1, 0, 0, 0, 0, 1, 1], 1); + const payload: JwtPayload = { + iss: 'https://example.com', + sub: 'https://example.com/status/1', + iat: new Date().getTime() / 1000, + }; + const header: StatusListJWTHeaderParameters = { + alg: 'EdDSA', + typ: 'statuslist+jwt', + }; + const values = createHeaderAndPayload(statusList, payload, header); + return new SignJWT(values.payload) + .setProtectedHeader(values.header) + .sign(privateKey); +}; + +const statusListJWT = await generateStatusList(); + describe('App', () => { test('Example', async () => { const { signer, verifier } = createSignerVerifier(); @@ -53,3 +83,64 @@ describe('App', () => { expect(encodedSdjwt).rejects.toThrowError(); }); }); + +describe('Revocation', () => { + const { signer, verifier } = createSignerVerifier(); + const sdjwt = new SDJwtVcInstance({ + signer, + signAlg: 'EdDSA', + verifier, + hasher: digest, + hashAlg: 'SHA-256', + saltGenerator: generateSalt, + statusListFetcher(uri: string) { + // we emulate fetching the status list from the uri. Validation of the JWT is not done here in the test but should be done in the implementation. + return Promise.resolve(statusListJWT); + }, + // statusValidator(status: number) { + // // we are only accepting status 0 + // if (status === 0) return Promise.resolve(); + // throw new Error('Status is not valid'); + // }, + }); + + test('Test with a non revcoked credential', async () => { + const claims = { + firstname: 'John', + status: { + status_list: { + uri: 'https://example.com/status-list', + idx: 0, + }, + }, + }; + const expectedPayload: SdJwtVcPayload = { iat, iss, vct, ...claims }; + const encodedSdjwt = await sdjwt.issue(expectedPayload); + const result = await sdjwt.verify(encodedSdjwt); + expect(result).toBeDefined(); + }); + + test('Test with a revoked credential', async () => { + const claims = { + firstname: 'John', + status: { + status_list: { + uri: 'https://example.com/status-list', + idx: 1, + }, + }, + }; + const expectedPayload: SdJwtVcPayload = { iat, iss, vct, ...claims }; + const encodedSdjwt = await sdjwt.issue(expectedPayload); + const result = sdjwt.verify(encodedSdjwt); + expect(result).rejects.toThrowError('Status is not valid'); + }); + + test('test to fetch the statuslist', async () => { + //TODO: not implemented yet since we need to either mock the fetcher or use a real fetcher + }); + + test('test with an expired status list', async () => { + //TODO: needs to be implemented + }); +}); diff --git a/packages/types/src/type.ts b/packages/types/src/type.ts index 04d9c57..6129a0a 100644 --- a/packages/types/src/type.ts +++ b/packages/types/src/type.ts @@ -66,6 +66,7 @@ export interface JwtPayload { cnf?: { jwk: JsonWebKey; }; + exp?: number; [key: string]: unknown; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1062ea6..f046564 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 20.12.7 '@vitest/coverage-v8': specifier: ^1.2.2 - version: 1.5.1(vitest@1.5.1) + version: 1.5.1(vitest@1.5.1(@types/node@20.12.7)(jsdom@24.0.0)) husky: specifier: ^9.0.11 version: 9.0.11 @@ -28,13 +28,13 @@ importers: version: 24.0.0 lerna: specifier: ^8.1.2 - version: 8.1.2 + version: 8.1.2(encoding@0.1.13) ts-node: specifier: ^10.9.1 version: 10.9.2(@types/node@20.12.7)(typescript@5.4.5) tsup: specifier: ^8.0.2 - version: 8.0.2(ts-node@10.9.2)(typescript@5.4.5) + version: 8.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5))(typescript@5.4.5) typescript: specifier: ^5.3.2 version: 5.4.5 @@ -183,6 +183,25 @@ importers: specifier: workspace:* version: link:../node-crypto + packages/jwt-status-list: + dependencies: + '@sd-jwt/types': + specifier: workspace:* + version: link:../types + base64url: + specifier: ^3.0.1 + version: 3.0.1 + pako: + specifier: ^2.1.0 + version: 2.1.0 + devDependencies: + '@types/pako': + specifier: ^2.0.3 + version: 2.0.3 + jose: + specifier: ^5.2.2 + version: 5.2.4 + packages/node-crypto: {} packages/present: @@ -206,6 +225,9 @@ importers: '@sd-jwt/core': specifier: workspace:* version: link:../core + '@sd-jwt/jwt-status-list': + specifier: workspace:* + version: link:../jwt-status-list devDependencies: '@sd-jwt/crypto-nodejs': specifier: workspace:* @@ -213,6 +235,9 @@ importers: '@sd-jwt/types': specifier: workspace:* version: link:../types + jose: + specifier: ^5.2.2 + version: 5.2.4 packages/types: {} @@ -1053,6 +1078,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pako@2.0.3': + resolution: {integrity: sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==} + '@vitest/coverage-v8@1.5.1': resolution: {integrity: sha512-Zx+dYEDcZg+44ksjIWvWosIGlPLJB1PPpN3O8+Xrh/1qa7WSFA6Y8H7lsZJTYrxu4G2unk9tvP5TgjIGDliF1w==} peerDependencies: @@ -1212,6 +1240,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -2604,6 +2636,9 @@ packages: engines: {node: ^16.14.0 || >=18.0.0} hasBin: true + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3766,12 +3801,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 - '@lerna/create@8.1.2(typescript@5.4.5)': + '@lerna/create@8.1.2(encoding@0.1.13)(typescript@5.4.5)': dependencies: '@npmcli/run-script': 7.0.2 '@nx/devkit': 18.0.4(nx@18.0.4) '@octokit/plugin-enterprise-rest': 6.0.1 - '@octokit/rest': 19.0.11 + '@octokit/rest': 19.0.11(encoding@0.1.13) byte-size: 8.1.1 chalk: 4.1.0 clone-deep: 4.0.1 @@ -3801,7 +3836,7 @@ snapshots: make-dir: 4.0.0 minimatch: 3.0.5 multimatch: 5.0.0 - node-fetch: 2.6.7 + node-fetch: 2.6.7(encoding@0.1.13) npm-package-arg: 8.1.1 npm-packlist: 5.1.1 npm-registry-fetch: 14.0.5 @@ -3962,11 +3997,11 @@ snapshots: '@octokit/auth-token@3.0.4': {} - '@octokit/core@4.2.4': + '@octokit/core@4.2.4(encoding@0.1.13)': dependencies: '@octokit/auth-token': 3.0.4 - '@octokit/graphql': 5.0.6 - '@octokit/request': 6.2.8 + '@octokit/graphql': 5.0.6(encoding@0.1.13) + '@octokit/request': 6.2.8(encoding@0.1.13) '@octokit/request-error': 3.0.3 '@octokit/types': 9.3.2 before-after-hook: 2.2.3 @@ -3980,9 +4015,9 @@ snapshots: is-plain-object: 5.0.0 universal-user-agent: 6.0.1 - '@octokit/graphql@5.0.6': + '@octokit/graphql@5.0.6(encoding@0.1.13)': dependencies: - '@octokit/request': 6.2.8 + '@octokit/request': 6.2.8(encoding@0.1.13) '@octokit/types': 9.3.2 universal-user-agent: 6.0.1 transitivePeerDependencies: @@ -3992,19 +4027,19 @@ snapshots: '@octokit/plugin-enterprise-rest@6.0.1': {} - '@octokit/plugin-paginate-rest@6.1.2(@octokit/core@4.2.4)': + '@octokit/plugin-paginate-rest@6.1.2(@octokit/core@4.2.4(encoding@0.1.13))': dependencies: - '@octokit/core': 4.2.4 + '@octokit/core': 4.2.4(encoding@0.1.13) '@octokit/tsconfig': 1.0.2 '@octokit/types': 9.3.2 - '@octokit/plugin-request-log@1.0.4(@octokit/core@4.2.4)': + '@octokit/plugin-request-log@1.0.4(@octokit/core@4.2.4(encoding@0.1.13))': dependencies: - '@octokit/core': 4.2.4 + '@octokit/core': 4.2.4(encoding@0.1.13) - '@octokit/plugin-rest-endpoint-methods@7.2.3(@octokit/core@4.2.4)': + '@octokit/plugin-rest-endpoint-methods@7.2.3(@octokit/core@4.2.4(encoding@0.1.13))': dependencies: - '@octokit/core': 4.2.4 + '@octokit/core': 4.2.4(encoding@0.1.13) '@octokit/types': 10.0.0 '@octokit/request-error@3.0.3': @@ -4013,23 +4048,23 @@ snapshots: deprecation: 2.3.1 once: 1.4.0 - '@octokit/request@6.2.8': + '@octokit/request@6.2.8(encoding@0.1.13)': dependencies: '@octokit/endpoint': 7.0.6 '@octokit/request-error': 3.0.3 '@octokit/types': 9.3.2 is-plain-object: 5.0.0 - node-fetch: 2.6.7 + node-fetch: 2.6.7(encoding@0.1.13) universal-user-agent: 6.0.1 transitivePeerDependencies: - encoding - '@octokit/rest@19.0.11': + '@octokit/rest@19.0.11(encoding@0.1.13)': dependencies: - '@octokit/core': 4.2.4 - '@octokit/plugin-paginate-rest': 6.1.2(@octokit/core@4.2.4) - '@octokit/plugin-request-log': 1.0.4(@octokit/core@4.2.4) - '@octokit/plugin-rest-endpoint-methods': 7.2.3(@octokit/core@4.2.4) + '@octokit/core': 4.2.4(encoding@0.1.13) + '@octokit/plugin-paginate-rest': 6.1.2(@octokit/core@4.2.4(encoding@0.1.13)) + '@octokit/plugin-request-log': 1.0.4(@octokit/core@4.2.4(encoding@0.1.13)) + '@octokit/plugin-rest-endpoint-methods': 7.2.3(@octokit/core@4.2.4(encoding@0.1.13)) transitivePeerDependencies: - encoding @@ -4227,7 +4262,9 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@vitest/coverage-v8@1.5.1(vitest@1.5.1)': + '@types/pako@2.0.3': {} + + '@vitest/coverage-v8@1.5.1(vitest@1.5.1(@types/node@20.12.7)(jsdom@24.0.0))': dependencies: '@ampproject/remapping': 2.2.1 '@bcoe/v8-coverage': 0.2.3 @@ -4392,6 +4429,8 @@ snapshots: base64-js@1.5.1: {} + base64url@3.0.1: {} + before-after-hook@2.2.3: {} binary-extensions@2.2.0: {} @@ -4660,6 +4699,7 @@ snapshots: js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 + optionalDependencies: typescript: 5.4.5 create-require@1.1.1: {} @@ -5402,13 +5442,13 @@ snapshots: kind-of@6.0.3: {} - lerna@8.1.2: + lerna@8.1.2(encoding@0.1.13): dependencies: - '@lerna/create': 8.1.2(typescript@5.4.5) + '@lerna/create': 8.1.2(encoding@0.1.13)(typescript@5.4.5) '@npmcli/run-script': 7.0.2 '@nx/devkit': 18.0.4(nx@18.0.4) '@octokit/plugin-enterprise-rest': 6.0.1 - '@octokit/rest': 19.0.11 + '@octokit/rest': 19.0.11(encoding@0.1.13) byte-size: 8.1.1 chalk: 4.1.0 clone-deep: 4.0.1 @@ -5444,7 +5484,7 @@ snapshots: make-dir: 4.0.0 minimatch: 3.0.5 multimatch: 5.0.0 - node-fetch: 2.6.7 + node-fetch: 2.6.7(encoding@0.1.13) npm-package-arg: 8.1.1 npm-packlist: 5.1.1 npm-registry-fetch: 14.0.5 @@ -5774,9 +5814,11 @@ snapshots: neo-async@2.6.2: {} - node-fetch@2.6.7: + node-fetch@2.6.7(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 node-gyp@10.0.1: dependencies: @@ -6092,6 +6134,8 @@ snapshots: - bluebird - supports-color + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6171,11 +6215,13 @@ snapshots: mlly: 1.5.0 pathe: 1.1.2 - postcss-load-config@4.0.2(ts-node@10.9.2): + postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5)): dependencies: lilconfig: 3.0.0 - ts-node: 10.9.2(@types/node@20.12.7)(typescript@5.4.5) yaml: 2.3.4 + optionalDependencies: + postcss: 8.4.38 + ts-node: 10.9.2(@types/node@20.12.7)(typescript@5.4.5) postcss@8.4.38: dependencies: @@ -6730,7 +6776,7 @@ snapshots: tslib@2.6.2: {} - tsup@8.0.2(ts-node@10.9.2)(typescript@5.4.5): + tsup@8.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5))(typescript@5.4.5): dependencies: bundle-require: 4.0.2(esbuild@0.19.12) cac: 6.7.14 @@ -6740,12 +6786,14 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(ts-node@10.9.2) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.12.7)(typescript@5.4.5)) resolve-from: 5.0.0 rollup: 4.10.0 source-map: 0.8.0-beta.0 sucrase: 3.35.0 tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.4.38 typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -6851,16 +6899,15 @@ snapshots: vite@5.2.10(@types/node@20.12.7): dependencies: - '@types/node': 20.12.7 esbuild: 0.20.2 postcss: 8.4.38 rollup: 4.14.0 optionalDependencies: + '@types/node': 20.12.7 fsevents: 2.3.3 vitest@1.5.1(@types/node@20.12.7)(jsdom@24.0.0): dependencies: - '@types/node': 20.12.7 '@vitest/expect': 1.5.1 '@vitest/runner': 1.5.1 '@vitest/snapshot': 1.5.1 @@ -6870,7 +6917,6 @@ snapshots: chai: 4.4.1 debug: 4.3.4 execa: 8.0.1 - jsdom: 24.0.0 local-pkg: 0.5.0 magic-string: 0.30.7 pathe: 1.1.2 @@ -6882,6 +6928,9 @@ snapshots: vite: 5.2.10(@types/node@20.12.7) vite-node: 1.5.1(@types/node@20.12.7) why-is-node-running: 2.2.2 + optionalDependencies: + '@types/node': 20.12.7 + jsdom: 24.0.0 transitivePeerDependencies: - less - lightningcss