diff --git a/packages/data-store/src/__tests__/statusList.entities.test.ts b/packages/data-store/src/__tests__/statusList.entities.test.ts new file mode 100644 index 000000000..9004e64c4 --- /dev/null +++ b/packages/data-store/src/__tests__/statusList.entities.test.ts @@ -0,0 +1,215 @@ +import { DataSource } from 'typeorm' +import { DataSources } from '@sphereon/ssi-sdk.agent-config' +import { DataStoreStatusListEntities, StatusListEntryEntity } from '../index' +import { DataStoreStatusListMigrations } from '../migrations' +import { OAuthStatusListEntity, StatusList2021Entity } from '../entities/statusList/StatusListEntities' +import { IIssuer, StatusListCredentialIdMode, StatusListDriverType } from '@sphereon/ssi-types' + +describe('Status list entities tests', () => { + let dbConnection: DataSource + + beforeEach(async () => { + DataSources.singleInstance().defaultDbType = 'sqlite' + dbConnection = await new DataSource({ + type: 'sqlite', + database: ':memory:', + migrationsRun: false, + migrations: DataStoreStatusListMigrations, + synchronize: false, + entities: [...DataStoreStatusListEntities], + }).initialize() + await dbConnection.runMigrations() + expect(await dbConnection.showMigrations()).toBeFalsy() + }) + + afterEach(async () => { + await dbConnection.destroy() + }) + + it('should save status list to database', async () => { + const statusList = new StatusList2021Entity() + statusList.id = 'test-list-1' + statusList.correlationId = 'correlation-1' + statusList.driverType = StatusListDriverType.AGENT_TYPEORM + statusList.length = 100000 + statusList.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + statusList.proofFormat = 'jwt' + statusList.statusPurpose = 'revocation' + statusList.indexingDirection = 'rightToLeft' + statusList.issuer = 'did:example:123' + + const fromDb = await dbConnection.getRepository(StatusList2021Entity).save(statusList) + expect(fromDb).toBeDefined() + expect(fromDb.id).toEqual(statusList.id) + expect(fromDb.correlationId).toEqual(statusList.correlationId) + expect(fromDb.length).toEqual(statusList.length) + expect(fromDb.credentialIdMode).toEqual(statusList.credentialIdMode) + expect(fromDb.statusPurpose).toEqual(statusList.statusPurpose) + expect(fromDb.indexingDirection).toEqual(statusList.indexingDirection) + expect(fromDb.issuer).toEqual(statusList.issuer) + }) + + it('should save status list entry to database', async () => { + const statusList = new StatusList2021Entity() + statusList.id = 'test-list-1' + statusList.correlationId = 'correlation-1' + statusList.driverType = StatusListDriverType.AGENT_TYPEORM + statusList.length = 100000 + statusList.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + statusList.proofFormat = 'jwt' + statusList.statusPurpose = 'revocation' + statusList.indexingDirection = 'rightToLeft' + statusList.issuer = 'did:example:123' + + await dbConnection.getRepository(StatusList2021Entity).save(statusList) + + const entry = new StatusListEntryEntity() + entry.statusList = statusList + entry.statusListIndex = 1 + entry.credentialId = 'credential-1' + entry.credentialHash = 'hash-1' + entry.correlationId = 'correlation-1' + entry.value = '1' + + const fromDb = await dbConnection.getRepository(StatusListEntryEntity).save(entry) + expect(fromDb).toBeDefined() + expect(fromDb.statusListIndex).toEqual(entry.statusListIndex) + expect(fromDb.credentialId).toEqual(entry.credentialId) + expect(fromDb.credentialHash).toEqual(entry.credentialHash) + expect(fromDb.correlationId).toEqual(entry.correlationId) + expect(fromDb.value).toEqual(entry.value) + }) + + it('should handle complex issuer object', async () => { + const statusList = new StatusList2021Entity() + statusList.id = 'test-list-1' + statusList.correlationId = 'correlation-1' + statusList.driverType = StatusListDriverType.AGENT_TYPEORM + statusList.length = 100000 + statusList.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + statusList.proofFormat = 'jwt' + statusList.statusPurpose = 'revocation' + statusList.indexingDirection = 'rightToLeft' + statusList.issuer = { id: 'did:example:123', name: 'Test Issuer' } + + const fromDb = await dbConnection.getRepository(StatusList2021Entity).save(statusList) + expect(fromDb).toBeDefined() + expect(fromDb.issuer).toEqual(statusList.issuer) + expect(typeof fromDb.issuer).toEqual('object') + expect((fromDb.issuer as IIssuer).id).toEqual('did:example:123') + expect((fromDb.issuer as IIssuer).name).toEqual('Test Issuer') + }) + + it('should save OAuth status list to database', async () => { + const statusList = new OAuthStatusListEntity() + statusList.id = 'oauth-list-1' + statusList.correlationId = 'correlation-oauth-1' + statusList.driverType = StatusListDriverType.AGENT_TYPEORM + statusList.length = 100000 + statusList.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + statusList.proofFormat = 'jwt' + statusList.bitsPerStatus = 1 + statusList.expiresAt = new Date('2025-01-01T00:00:00Z') + statusList.issuer = 'did:example:123' + + const fromDb = await dbConnection.getRepository(OAuthStatusListEntity).save(statusList) + expect(fromDb).toBeDefined() + expect(fromDb.id).toEqual(statusList.id) + expect(fromDb.correlationId).toEqual(statusList.correlationId) + expect(fromDb.length).toEqual(statusList.length) + expect(fromDb.credentialIdMode).toEqual(statusList.credentialIdMode) + expect(fromDb.bitsPerStatus).toEqual(statusList.bitsPerStatus) + expect(fromDb.expiresAt).toEqual(statusList.expiresAt) + expect(fromDb.issuer).toEqual(statusList.issuer) + }) + + it('should handle both status list types having entries', async () => { + const statusList2021 = new StatusList2021Entity() + statusList2021.id = 'test-list-1' + statusList2021.correlationId = 'correlation-1' + statusList2021.driverType = StatusListDriverType.AGENT_TYPEORM + statusList2021.length = 100000 + statusList2021.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + statusList2021.proofFormat = 'jwt' + statusList2021.statusPurpose = 'revocation' + statusList2021.indexingDirection = 'rightToLeft' + statusList2021.issuer = 'did:example:123' + await dbConnection.getRepository(StatusList2021Entity).save(statusList2021) + + const oauthStatusList = new OAuthStatusListEntity() + oauthStatusList.id = 'oauth-list-1' + oauthStatusList.correlationId = 'correlation-oauth-1' + oauthStatusList.driverType = StatusListDriverType.AGENT_TYPEORM + oauthStatusList.length = 100000 + oauthStatusList.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + oauthStatusList.proofFormat = 'jwt' + oauthStatusList.bitsPerStatus = 1 + oauthStatusList.issuer = 'did:example:456' + await dbConnection.getRepository(OAuthStatusListEntity).save(oauthStatusList) + + const entry2021 = new StatusListEntryEntity() + entry2021.statusList = statusList2021 + entry2021.statusListIndex = 1 + entry2021.credentialId = 'credential-1' + entry2021.credentialHash = 'hash-1' + entry2021.value = '1' + await dbConnection.getRepository(StatusListEntryEntity).save(entry2021) + + const entryOAuth = new StatusListEntryEntity() + entryOAuth.statusList = oauthStatusList + entryOAuth.statusListIndex = 1 + entryOAuth.credentialId = 'credential-2' + entryOAuth.credentialHash = 'hash-2' + entryOAuth.value = '1' + await dbConnection.getRepository(StatusListEntryEntity).save(entryOAuth) + + const found2021Entry = await dbConnection.getRepository(StatusListEntryEntity).findOne({ + where: { statusList: statusList2021.id, statusListIndex: 1 }, + }) + const foundOAuthEntry = await dbConnection.getRepository(StatusListEntryEntity).findOne({ + where: { statusList: oauthStatusList.id, statusListIndex: 1 }, + }) + + expect(found2021Entry).toBeDefined() + expect(found2021Entry?.credentialId).toEqual('credential-1') + expect(foundOAuthEntry).toBeDefined() + expect(foundOAuthEntry?.credentialId).toEqual('credential-2') + }) + + it('should cascade delete entries when status list is deleted', async () => { + const statusList = new StatusList2021Entity() + statusList.id = 'test-list-1' + statusList.correlationId = 'correlation-1' + statusList.driverType = StatusListDriverType.AGENT_TYPEORM + statusList.length = 100000 + statusList.credentialIdMode = StatusListCredentialIdMode.ISSUANCE + statusList.proofFormat = 'jwt' + statusList.statusPurpose = 'revocation' + statusList.indexingDirection = 'rightToLeft' + statusList.issuer = 'did:example:123' + + const savedStatusList = await dbConnection.getRepository(StatusList2021Entity).save(statusList) + + const entry = new StatusListEntryEntity() + entry.statusList = statusList + entry.statusListIndex = 1 + entry.credentialId = 'credential-1' + entry.credentialHash = 'hash-1' + entry.correlationId = 'correlation-1' + entry.value = '1' + + await dbConnection.getRepository(StatusListEntryEntity).save(entry) + + // First delete entry, otherwise constraint fails + await dbConnection.getRepository(StatusListEntryEntity).delete({ statusList: savedStatusList.id }) + await dbConnection.getRepository(StatusList2021Entity).remove(savedStatusList) + + const foundEntry = await dbConnection.getRepository(StatusListEntryEntity).findOne({ + where: { + statusList: statusList.id, + statusListIndex: entry.statusListIndex, + }, + }) + expect(foundEntry).toBeNull() + }) +}) diff --git a/packages/data-store/src/__tests__/statusList.store.test.ts b/packages/data-store/src/__tests__/statusList.store.test.ts new file mode 100644 index 000000000..86bc05415 --- /dev/null +++ b/packages/data-store/src/__tests__/statusList.store.test.ts @@ -0,0 +1,232 @@ +import { DataSource } from 'typeorm' +import { DataSources } from '@sphereon/ssi-sdk.agent-config' +import { DataStoreStatusListEntities } from '../index' +import { DataStoreStatusListMigrations } from '../migrations' +import { StatusListStore } from '../statusList/StatusListStore' +import { IStatusList2021Entity, IStatusListEntryEntity, IOAuthStatusListEntity } from '../types' +import { StatusListCredentialIdMode, StatusListDriverType, StatusListType } from '@sphereon/ssi-types' + +describe('Status list store tests', () => { + let dbConnection: DataSource + let statusListStore: StatusListStore + + beforeEach(async () => { + DataSources.singleInstance().defaultDbType = 'sqlite' + dbConnection = await new DataSource({ + type: 'sqlite', + database: ':memory:', + migrationsRun: false, + migrations: DataStoreStatusListMigrations, + synchronize: false, + entities: DataStoreStatusListEntities, + }).initialize() + await dbConnection.runMigrations() + expect(await dbConnection.showMigrations()).toBeFalsy() + statusListStore = new StatusListStore(dbConnection) + }) + + afterEach(async () => { + await dbConnection.destroy() + }) + + it('should store status list', async () => { + const statusList: IStatusList2021Entity = { + id: 'test-list-1', + correlationId: 'correlation-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + issuer: 'did:example:123', + } + + const result = await statusListStore.addStatusList(statusList) + expect(result).toBeDefined() + expect(result.id).toEqual(statusList.id) + expect(result.correlationId).toEqual(statusList.correlationId) + }) + + it('should store status list entry', async () => { + const statusList: IStatusList2021Entity = { + id: 'test-list-1', + correlationId: 'correlation-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + issuer: 'did:example:123', + } + + await statusListStore.addStatusList(statusList) + + const entry: IStatusListEntryEntity = { + statusList: statusList.id, + statusListIndex: 1, + credentialId: 'credential-1', + credentialHash: 'hash-1', + correlationId: 'correlation-1', + value: '1', + } + + const result = await statusListStore.addStatusListEntry(entry) + expect(result).toBeDefined() + expect(result.statusListIndex).toEqual(entry.statusListIndex) + expect(result.credentialId).toEqual(entry.credentialId) + }) + + it('should store OAuth status list', async () => { + const statusList: IOAuthStatusListEntity = { + id: 'oauth-list-1', + correlationId: 'correlation-oauth-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + bitsPerStatus: 1, + expiresAt: new Date('2025-01-01T00:00:00Z'), + issuer: 'did:example:123', + } + + const result = (await statusListStore.addStatusList(statusList)) as IOAuthStatusListEntity + expect(result).toBeDefined() + expect(result.id).toEqual(statusList.id) + expect(result.correlationId).toEqual(statusList.correlationId) + expect(result.bitsPerStatus).toEqual(statusList.bitsPerStatus) + expect(result.expiresAt).toEqual(statusList.expiresAt) + }) + + it('should store and retrieve both types of status lists', async () => { + const statusList2021: IStatusList2021Entity = { + id: 'test-list-1', + correlationId: 'correlation-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + issuer: 'did:example:123', + } + + const oauthStatusList: IOAuthStatusListEntity = { + id: 'oauth-list-1', + correlationId: 'correlation-oauth-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + bitsPerStatus: 1, + issuer: 'did:example:456', + } + + await statusListStore.addStatusList(statusList2021) + await statusListStore.addStatusList(oauthStatusList) + + const found2021 = (await statusListStore.getStatusList({ id: statusList2021.id })) as IStatusList2021Entity + const foundOAuth = (await statusListStore.getStatusList({ id: oauthStatusList.id })) as IOAuthStatusListEntity + + expect(found2021.type).toEqual(StatusListType.StatusList2021) + expect(found2021.statusPurpose).toEqual('revocation') + expect(foundOAuth.type).toEqual(StatusListType.OAuthStatusList) + expect((foundOAuth as IOAuthStatusListEntity).bitsPerStatus).toEqual(1) + }) + + it('should get status list by id', async () => { + const statusList: IStatusList2021Entity = { + id: 'test-list-1', + correlationId: 'correlation-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + issuer: 'did:example:123', + } + + await statusListStore.addStatusList(statusList) + + const result = await statusListStore.getStatusList({ id: statusList.id }) + expect(result).toBeDefined() + expect(result.id).toEqual(statusList.id) + }) + + it('should get status lists with filter', async () => { + const statusList1: IStatusList2021Entity = { + id: 'test-list-1', + correlationId: 'correlation-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + issuer: 'did:example:123', + } + + const statusList2: IStatusList2021Entity = { + id: 'test-list-2', + correlationId: 'correlation-2', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'suspension', + indexingDirection: 'rightToLeft', + issuer: 'did:example:456', + } + + await statusListStore.addStatusList(statusList1) + await statusListStore.addStatusList(statusList2) + + const result = await statusListStore.getStatusLists({ + filter: [{ statusPurpose: 'revocation' }], + }) + + expect(result.length).toEqual(1) + expect(result[0].id).toEqual(statusList1.id) + }) + + it('should delete status list', async () => { + const statusList: IStatusList2021Entity = { + id: 'test-list-1', + correlationId: 'correlation-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + length: 100000, + credentialIdMode: StatusListCredentialIdMode.ISSUANCE, + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + issuer: 'did:example:123', + } + + await statusListStore.addStatusList(statusList) + const entry: IStatusListEntryEntity = { + statusList: statusList.id, + statusListIndex: 1, + credentialId: 'credential-1', + credentialHash: 'hash-1', + correlationId: 'correlation-1', + value: '1', + } + await statusListStore.addStatusListEntry(entry) + + const result = await statusListStore.removeStatusList({ id: statusList.id }) + expect(result).toEqual(true) + + await expect(statusListStore.getStatusList({ id: statusList.id })).rejects.toThrow(`No status list found for id ${statusList.id}`) + }) +}) diff --git a/packages/data-store/src/entities/statusList2021/StatusList2021EntryEntity.ts b/packages/data-store/src/entities/statusList/StatusList2021EntryEntity.ts similarity index 95% rename from packages/data-store/src/entities/statusList2021/StatusList2021EntryEntity.ts rename to packages/data-store/src/entities/statusList/StatusList2021EntryEntity.ts index 128419ded..50bf29dc6 100644 --- a/packages/data-store/src/entities/statusList2021/StatusList2021EntryEntity.ts +++ b/packages/data-store/src/entities/statusList/StatusList2021EntryEntity.ts @@ -1,7 +1,7 @@ import { Validate } from 'class-validator' import { BaseEntity, Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm' import { IsNonEmptyStringConstraint } from '../validators' -import { StatusListEntity } from './StatusList2021Entity' +import { StatusListEntity } from './StatusListEntities' @Entity('StatusListEntry') // @Unique('uq_credential_statuslist', ['statusList', 'credentialId']) // disabled because one prop can be null diff --git a/packages/data-store/src/entities/statusList2021/StatusList2021Entity.ts b/packages/data-store/src/entities/statusList/StatusListEntities.ts similarity index 64% rename from packages/data-store/src/entities/statusList2021/StatusList2021Entity.ts rename to packages/data-store/src/entities/statusList/StatusListEntities.ts index e3c5c5cde..333b124ca 100644 --- a/packages/data-store/src/entities/statusList2021/StatusList2021Entity.ts +++ b/packages/data-store/src/entities/statusList/StatusListEntities.ts @@ -1,20 +1,20 @@ import { IIssuer, - JwtDecodedVerifiableCredential, + StatusListCredential, StatusListCredentialIdMode, StatusListDriverType, StatusListIndexingDirection, StatusListType, StatusPurpose2021, - W3CVerifiableCredential, + ProofFormat, } from '@sphereon/ssi-types' -import { ProofFormat } from '@veramo/core' -import { BaseEntity, Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm' +import { BaseEntity, ChildEntity, Column, Entity, OneToMany, PrimaryColumn, TableInheritance, Unique } from 'typeorm' import { StatusListEntryEntity } from './StatusList2021EntryEntity' @Entity('StatusList') @Unique('UQ_correlationId', ['correlationId']) -export class StatusListEntity extends BaseEntity { +@TableInheritance({ column: { type: 'simple-enum', name: 'type', enum: StatusListType } }) +export abstract class StatusListEntity extends BaseEntity { @PrimaryColumn({ name: 'id', type: 'varchar' }) id!: string @@ -46,10 +46,12 @@ export class StatusListEntity extends BaseEntity { }) issuer!: string | IIssuer - @Column('simple-enum', { name: 'type', enum: StatusListType, nullable: false, default: StatusListType.StatusList2021 }) - type!: StatusListType - - @Column('simple-enum', { name: 'driverType', enum: StatusListDriverType, nullable: false, default: StatusListDriverType.AGENT_TYPEORM }) + @Column('simple-enum', { + name: 'driverType', + enum: StatusListDriverType, + nullable: false, + default: StatusListDriverType.AGENT_TYPEORM, + }) driverType!: StatusListDriverType @Column('simple-enum', { @@ -63,25 +65,19 @@ export class StatusListEntity extends BaseEntity { @Column({ type: 'varchar', name: 'proofFormat', enum: ['lds', 'jwt'], nullable: false, default: 'lds' }) proofFormat!: ProofFormat - @Column({ type: 'varchar', name: 'indexingDirection', enum: ['rightToLeft'], nullable: false, default: 'rightToLeft' }) - indexingDirection!: StatusListIndexingDirection - - @Column({ type: 'varchar', name: 'statusPurpose', nullable: false, default: 'revocation' }) - statusPurpose!: StatusPurpose2021 - @Column({ name: 'statusListCredential', type: 'text', nullable: true, unique: false, transformer: { - from(value: string): W3CVerifiableCredential | JwtDecodedVerifiableCredential { + from(value: string): StatusListCredential { if (value?.startsWith('ey')) { return value } return JSON.parse(value) }, - to(value: W3CVerifiableCredential | JwtDecodedVerifiableCredential): string { + to(value: StatusListCredential): string { if (typeof value === 'string') { return value } @@ -89,8 +85,31 @@ export class StatusListEntity extends BaseEntity { }, }, }) - statusListCredential?: W3CVerifiableCredential | JwtDecodedVerifiableCredential + statusListCredential?: StatusListCredential @OneToMany((type) => StatusListEntryEntity, (entry) => entry.statusList) statusListEntries!: StatusListEntryEntity[] } + +@ChildEntity(StatusListType.StatusList2021) +export class StatusList2021Entity extends StatusListEntity { + @Column({ + type: 'varchar', + name: 'indexingDirection', + enum: ['rightToLeft'], + nullable: false, + default: 'rightToLeft', + }) + indexingDirection!: StatusListIndexingDirection + + @Column({ type: 'varchar', name: 'statusPurpose', nullable: false, default: 'revocation' }) + statusPurpose!: StatusPurpose2021 +} + +@ChildEntity(StatusListType.OAuthStatusList) +export class OAuthStatusListEntity extends StatusListEntity { + @Column({ type: 'integer', name: 'bitsPerStatus', nullable: false }) + bitsPerStatus!: number + @Column({ type: 'datetime', name: 'expiresAt', nullable: true }) + expiresAt?: Date +} diff --git a/packages/data-store/src/index.ts b/packages/data-store/src/index.ts index 1a5948488..02a7be796 100644 --- a/packages/data-store/src/index.ts +++ b/packages/data-store/src/index.ts @@ -16,8 +16,8 @@ import { ImageDimensionsEntity } from './entities/issuanceBranding/ImageDimensio import { IssuerLocaleBrandingEntity } from './entities/issuanceBranding/IssuerLocaleBrandingEntity' import { IssuerBrandingEntity } from './entities/issuanceBranding/IssuerBrandingEntity' import { TextAttributesEntity } from './entities/issuanceBranding/TextAttributesEntity' -import { StatusListEntity } from './entities/statusList2021/StatusList2021Entity' -import { StatusListEntryEntity } from './entities/statusList2021/StatusList2021EntryEntity' +import { OAuthStatusListEntity, StatusList2021Entity, StatusListEntity } from './entities/statusList/StatusListEntities' +import { StatusListEntryEntity } from './entities/statusList/StatusList2021EntryEntity' import { MachineStateInfoEntity } from './entities/machineState/MachineStateInfoEntity' import { IStatusListEntity, IStatusListEntryEntity } from './types' import { PartyRelationshipEntity } from './entities/contact/PartyRelationshipEntity' @@ -96,7 +96,7 @@ export const DataStoreIssuanceBrandingEntities = [ export const DataStorePresentationDefinitionEntities = [PresentationDefinitionItemEntity] -export const DataStoreStatusListEntities = [StatusListEntity, StatusListEntryEntity] +export const DataStoreStatusListEntities = [StatusListEntity, StatusList2021Entity, OAuthStatusListEntity, StatusListEntryEntity] export const DataStoreEventLoggerEntities = [AuditEventEntity] diff --git a/packages/data-store/src/migrations/generic/4-CreateStatusList.ts b/packages/data-store/src/migrations/generic/4-CreateStatusList.ts index 320823521..f826427a5 100644 --- a/packages/data-store/src/migrations/generic/4-CreateStatusList.ts +++ b/packages/data-store/src/migrations/generic/4-CreateStatusList.ts @@ -2,6 +2,8 @@ import Debug from 'debug' import { MigrationInterface, QueryRunner } from 'typeorm' import { CreateStatusList1693866470001 } from '../postgres/1693866470001-CreateStatusList' import { CreateStatusList1693866470002 } from '../sqlite/1693866470000-CreateStatusList' +import { UpdateStatusList1737110469001 } from '../postgres/1737110469001-UpdateStatusList' +import { UpdateStatusList1737110469000 } from '../sqlite/1737110469000-UpdateStatusList' const debug = Debug('sphereon:ssi-sdk:migrations') @@ -12,15 +14,19 @@ export class CreateStatusList1693866470000 implements MigrationInterface { debug('migration: creating issuance branding tables') const dbType = queryRunner.connection.driver.options.type if (dbType === 'postgres') { - debug('using postgres migration file') - const mig = new CreateStatusList1693866470001() - const up = await mig.up(queryRunner) + debug('using postgres migration files') + const createMig = new CreateStatusList1693866470001() + await createMig.up(queryRunner) + const updateMig = new UpdateStatusList1737110469001() + const up = await updateMig.up(queryRunner) debug('Migration statements executed') return up } else if (dbType === 'sqlite' || dbType === 'react-native' || dbType === 'expo') { - debug('using sqlite/react-native migration file') - const mig = new CreateStatusList1693866470002() - const up = await mig.up(queryRunner) + debug('using sqlite/react-native migration files') + const createMig = new CreateStatusList1693866470002() + await createMig.up(queryRunner) + const updateMig = new UpdateStatusList1737110469000() + const up = await updateMig.up(queryRunner) debug('Migration statements executed') return up } else { @@ -34,15 +40,19 @@ export class CreateStatusList1693866470000 implements MigrationInterface { debug('migration: reverting issuance branding tables') const dbType = queryRunner.connection.driver.options.type if (dbType === 'postgres') { - debug('using postgres migration file') - const mig = new CreateStatusList1693866470002() - const down = await mig.down(queryRunner) + debug('using postgres migration files') + const updateMig = new UpdateStatusList1737110469001() + await updateMig.down(queryRunner) + const createMig = new CreateStatusList1693866470001() + const down = await createMig.down(queryRunner) debug('Migration statements executed') return down } else if (dbType === 'sqlite' || dbType === 'react-native' || dbType === 'expo') { - debug('using sqlite/react-native migration file') - const mig = new CreateStatusList1693866470002() - const down = await mig.down(queryRunner) + debug('using sqlite/react-native migration files') + const updateMig = new UpdateStatusList1737110469000() + await updateMig.down(queryRunner) + const createMig = new CreateStatusList1693866470002() + const down = await createMig.down(queryRunner) debug('Migration statements executed') return down } else { diff --git a/packages/data-store/src/migrations/postgres/1693866470001-CreateStatusList.ts b/packages/data-store/src/migrations/postgres/1693866470001-CreateStatusList.ts index 7561304da..e0e94cbb6 100644 --- a/packages/data-store/src/migrations/postgres/1693866470001-CreateStatusList.ts +++ b/packages/data-store/src/migrations/postgres/1693866470001-CreateStatusList.ts @@ -1,24 +1,60 @@ +// noinspection SqlPostgresDialect SqlNoDataSourceInspection import { MigrationInterface, QueryRunner } from 'typeorm' export class CreateStatusList1693866470001 implements MigrationInterface { name = 'CreateStatusList1693866470001' public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "StatusList_type_enum" AS ENUM('StatusList2021', 'OAuthStatusList')`) + await queryRunner.query(`CREATE TYPE "StatusList_drivertype_enum" AS ENUM('agent_typeorm', 'agent_kv_store', 'github', 'agent_filesystem')`) + await queryRunner.query(`CREATE TYPE "StatusList_credentialidmode_enum" AS ENUM('ISSUANCE', 'PERSISTENCE', 'NEVER')`) + await queryRunner.query( - `CREATE TABLE "StatusListEntry" ("statusListId" character varying NOT NULL, "statusListIndex" integer NOT NULL, "credentialId" character varying, "credentialHash" character varying(128), "correlationId" character varying(255), "value" character varying(50), CONSTRAINT "PK_68704d2d13857360c6b44a3d1d0" PRIMARY KEY ("statusListId", "statusListIndex"))`, - ) - await queryRunner.query(`CREATE TYPE "public"."StatusList_type_enum" AS ENUM('StatusList2021')`) - await queryRunner.query( - `CREATE TYPE "public"."StatusList_drivertype_enum" AS ENUM('agent_typeorm', 'agent_kv_store', 'github', 'agent_filesystem')`, + `CREATE TABLE "StatusListEntry" + ( + "statusListId" character varying NOT NULL, + "statusListIndex" integer NOT NULL, + "credentialId" character varying, + "credentialHash" character varying(128), + "correlationId" character varying(255), + "value" character varying(50), + CONSTRAINT "PK_68704d2d13857360c6b44a3d1d0" PRIMARY KEY ("statusListId", "statusListIndex") + )`, ) - await queryRunner.query(`CREATE TYPE "public"."StatusList_credentialidmode_enum" AS ENUM('ISSUANCE', 'PERSISTENCE', 'NEVER')`) + await queryRunner.query(`CREATE TYPE "StatusList_type_enum" AS ENUM('StatusList2021')`) + await queryRunner.query(`CREATE TYPE "StatusList_drivertype_enum" AS ENUM('agent_typeorm', 'agent_kv_store', 'github', 'agent_filesystem')`) + await queryRunner.query(`CREATE TYPE "StatusList_credentialidmode_enum" AS ENUM('ISSUANCE', 'PERSISTENCE', 'NEVER')`) await queryRunner.query( - `CREATE TABLE "StatusList" ("id" character varying NOT NULL, "correlationId" character varying NOT NULL, "length" integer NOT NULL, "issuer" text NOT NULL, "type" "public"."StatusList_type_enum" NOT NULL DEFAULT 'StatusList2021', "driverType" "public"."StatusList_drivertype_enum" NOT NULL DEFAULT 'agent_typeorm', "credentialIdMode" "public"."StatusList_credentialidmode_enum" NOT NULL DEFAULT 'ISSUANCE', "proofFormat" character varying NOT NULL DEFAULT 'lds', "indexingDirection" character varying NOT NULL DEFAULT 'rightToLeft', "statusPurpose" character varying NOT NULL DEFAULT 'revocation', "statusListCredential" text, CONSTRAINT "UQ_correlationId" UNIQUE ("correlationId"), CONSTRAINT "PK_StatusList_Id" PRIMARY KEY ("id"))`, + `CREATE TABLE "StatusList" + ( + "id" character varying NOT NULL, + "correlationId" character varying NOT NULL, + "length" integer NOT NULL, + "issuer" text NOT NULL, + "type" "StatusList_type_enum" NOT NULL DEFAULT 'StatusList2021', + "driverType" "StatusList_drivertype_enum" NOT NULL DEFAULT 'agent_typeorm', + "credentialIdMode" "StatusList_credentialidmode_enum" NOT NULL DEFAULT 'ISSUANCE', + "proofFormat" character varying NOT NULL DEFAULT 'lds', + "indexingDirection" character varying NOT NULL DEFAULT 'rightToLeft', + "statusPurpose" character varying NOT NULL DEFAULT 'revocation', + "statusListCredential" text, + CONSTRAINT "UQ_correlationId" UNIQUE ("correlationId"), + CONSTRAINT "PK_StatusList_Id" PRIMARY KEY ("id") + )`, ) await queryRunner.query( - `ALTER TABLE "StatusListEntry" ADD CONSTRAINT "FK_statusListEntry_statusListId" FOREIGN KEY ("statusListId") REFERENCES "StatusList"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + `ALTER TABLE "StatusListEntry" + ADD CONSTRAINT "FK_statusListEntry_statusListId" FOREIGN KEY ("statusListId") REFERENCES "StatusList" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, ) } - public async down(queryRunner: QueryRunner): Promise {} + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "StatusListEntry" + DROP CONSTRAINT "FK_statusListEntry_statusListId"`) + await queryRunner.query(`DROP TABLE "StatusListEntry"`) + await queryRunner.query(`DROP TABLE "StatusList"`) + await queryRunner.query(`DROP TYPE "StatusList_credentialidmode_enum"`) + await queryRunner.query(`DROP TYPE "StatusList_drivertype_enum"`) + await queryRunner.query(`DROP TYPE "StatusList_type_enum"`) + } } diff --git a/packages/data-store/src/migrations/postgres/1737110469001-UpdateStatusList.ts b/packages/data-store/src/migrations/postgres/1737110469001-UpdateStatusList.ts new file mode 100644 index 000000000..18ea0ade8 --- /dev/null +++ b/packages/data-store/src/migrations/postgres/1737110469001-UpdateStatusList.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class UpdateStatusList1737110469001 implements MigrationInterface { + name = 'UpdateStatusList1737110469001' + + public async up(queryRunner: QueryRunner): Promise { + // Add new enum value + await queryRunner.query(`ALTER TYPE "StatusList_type_enum" ADD VALUE 'OAuthStatusList'`) + + // Make columns nullable and add new columns + await queryRunner.query(`ALTER TABLE "StatusList" ALTER COLUMN "indexingDirection" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "StatusList" ALTER COLUMN "statusPurpose" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "StatusList" ADD "bitsPerStatus" integer`) + await queryRunner.query(`ALTER TABLE "StatusList" ADD "expiresAt" timestamp with time zone`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "StatusList" DROP COLUMN "expiresAt"`) + await queryRunner.query(`ALTER TABLE "StatusList" DROP COLUMN "bitsPerStatus"`) + await queryRunner.query(`ALTER TABLE "StatusList" ALTER COLUMN "statusPurpose" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "StatusList" ALTER COLUMN "indexingDirection" SET NOT NULL`) + + // Note: Cannot remove enum value in Postgres, would need to recreate the type + } +} diff --git a/packages/data-store/src/migrations/sqlite/1693866470000-CreateStatusList.ts b/packages/data-store/src/migrations/sqlite/1693866470000-CreateStatusList.ts index 4e5d8182b..70c5a207d 100644 --- a/packages/data-store/src/migrations/sqlite/1693866470000-CreateStatusList.ts +++ b/packages/data-store/src/migrations/sqlite/1693866470000-CreateStatusList.ts @@ -5,20 +5,60 @@ export class CreateStatusList1693866470002 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "StatusListEntry" ("statusListId" varchar NOT NULL, "statusListIndex" integer NOT NULL, "credentialId" varchar, "credentialHash" varchar(128), "correlationId" varchar(255), "value" varchar(50), PRIMARY KEY ("statusListId", "statusListIndex"))`, + `CREATE TABLE "StatusListEntry" + ( + "statusListId" varchar NOT NULL, + "statusListIndex" integer NOT NULL, + "credentialId" varchar, + "credentialHash" varchar(128), + "correlationId" varchar(255), + "value" varchar(50), + PRIMARY KEY ("statusListId", "statusListIndex") + )`, ) await queryRunner.query( - `CREATE TABLE "StatusList" ("id" varchar PRIMARY KEY NOT NULL, "correlationId" varchar NOT NULL, "length" integer NOT NULL, "issuer" text NOT NULL, "type" varchar CHECK( "type" IN ('StatusList2021') ) NOT NULL DEFAULT ('StatusList2021'), "driverType" varchar CHECK( "driverType" IN ('agent_typeorm','agent_kv_store','github','agent_filesystem') ) NOT NULL DEFAULT ('agent_typeorm'), "credentialIdMode" varchar CHECK( "credentialIdMode" IN ('ISSUANCE','PERSISTENCE','NEVER') ) NOT NULL DEFAULT ('ISSUANCE'), "proofFormat" varchar CHECK( "proofFormat" IN ('lds','jwt') ) NOT NULL DEFAULT ('lds'), "indexingDirection" varchar CHECK( "indexingDirection" IN ('rightToLeft') ) NOT NULL DEFAULT ('rightToLeft'), "statusPurpose" varchar NOT NULL DEFAULT ('revocation'), "statusListCredential" text, CONSTRAINT "UQ_correlationId" UNIQUE ("correlationId"))`, + `CREATE TABLE "StatusList" + ( + "id" varchar PRIMARY KEY NOT NULL, + "correlationId" varchar NOT NULL, + "length" integer NOT NULL, + "issuer" text NOT NULL, + "type" varchar CHECK ( "type" IN ('StatusList2021') ) NOT NULL DEFAULT ('StatusList2021'), + "driverType" varchar CHECK ( "driverType" IN ('agent_typeorm', 'agent_kv_store', 'github', + 'agent_filesystem') ) NOT NULL DEFAULT ('agent_typeorm'), + "credentialIdMode" varchar CHECK ( "credentialIdMode" IN ('ISSUANCE', 'PERSISTENCE', 'NEVER') ) NOT NULL DEFAULT ('ISSUANCE'), + "proofFormat" varchar CHECK ( "proofFormat" IN ('lds', 'jwt') ) NOT NULL DEFAULT ('lds'), + "indexingDirection" varchar CHECK ( "indexingDirection" IN ('rightToLeft') ) NOT NULL DEFAULT ('rightToLeft'), + "statusPurpose" varchar NOT NULL DEFAULT ('revocation'), + "statusListCredential" text, + CONSTRAINT "UQ_correlationId" UNIQUE ("correlationId") + )`, ) await queryRunner.query( - `CREATE TABLE "temporary_StatusListEntry" ("statusListId" varchar NOT NULL, "statusListIndex" integer NOT NULL, "credentialId" varchar, "credentialHash" varchar(128), "correlationId" varchar(255), "value" varchar(50), CONSTRAINT "FK_statusListEntry_statusListId" FOREIGN KEY ("statusListId") REFERENCES "StatusList" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, PRIMARY KEY ("statusListId", "statusListIndex"))`, + `CREATE TABLE "temporary_StatusListEntry" + ( + "statusListId" varchar NOT NULL, + "statusListIndex" integer NOT NULL, + "credentialId" varchar, + "credentialHash" varchar(128), + "correlationId" varchar(255), + "value" varchar(50), + CONSTRAINT "FK_statusListEntry_statusListId" FOREIGN KEY ("statusListId") REFERENCES "StatusList" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("statusListId", "statusListIndex") + )`, ) await queryRunner.query( - `INSERT INTO "temporary_StatusListEntry"("statusListId", "statusListIndex", "credentialId", "credentialHash", "correlationId", "value") SELECT "statusListId", "statusListIndex", "credentialId", "credentialHash", "correlationId", "value" FROM "StatusListEntry"`, + `INSERT INTO "temporary_StatusListEntry"("statusListId", "statusListIndex", "credentialId", + "credentialHash", "correlationId", "value") + SELECT "statusListId", "statusListIndex", "credentialId", "credentialHash", "correlationId", "value" + FROM "StatusListEntry"`, ) await queryRunner.query(`DROP TABLE "StatusListEntry"`) await queryRunner.query(`ALTER TABLE "temporary_StatusListEntry" RENAME TO "StatusListEntry"`) } - public async down(queryRunner: QueryRunner): Promise {} + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "StatusListEntry"`) + await queryRunner.query(`DROP TABLE "StatusList"`) + } } diff --git a/packages/data-store/src/migrations/sqlite/1737110469000-UpdateStatusList.ts b/packages/data-store/src/migrations/sqlite/1737110469000-UpdateStatusList.ts new file mode 100644 index 000000000..80772f0d4 --- /dev/null +++ b/packages/data-store/src/migrations/sqlite/1737110469000-UpdateStatusList.ts @@ -0,0 +1,84 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class UpdateStatusList1737110469000 implements MigrationInterface { + name = 'UpdateStatusList1737110469000' + + public async up(queryRunner: QueryRunner): Promise { + // Create temporary table with new schema + await queryRunner.query( + `CREATE TABLE "temporary_StatusList" ( + "id" varchar PRIMARY KEY NOT NULL, + "correlationId" varchar NOT NULL, + "length" integer NOT NULL, + "issuer" text NOT NULL, + "type" varchar CHECK( "type" IN ('StatusList2021', 'OAuthStatusList') ) NOT NULL DEFAULT ('StatusList2021'), + "driverType" varchar CHECK( "driverType" IN ('agent_typeorm','agent_kv_store','github','agent_filesystem') ) NOT NULL DEFAULT ('agent_typeorm'), + "credentialIdMode" varchar CHECK( "credentialIdMode" IN ('ISSUANCE','PERSISTENCE','NEVER') ) NOT NULL DEFAULT ('ISSUANCE'), + "proofFormat" varchar CHECK( "proofFormat" IN ('lds','jwt') ) NOT NULL DEFAULT ('lds'), + "indexingDirection" varchar CHECK( "indexingDirection" IN ('rightToLeft') ), + "statusPurpose" varchar, + "statusListCredential" text, + "bitsPerStatus" integer, + "expiresAt" datetime, + CONSTRAINT "UQ_correlationId" UNIQUE ("correlationId") + )`, + ) + + // Copy data from old table to temporary table + await queryRunner.query( + `INSERT INTO "temporary_StatusList"( + "id", "correlationId", "length", "issuer", "type", "driverType", + "credentialIdMode", "proofFormat", "indexingDirection", "statusPurpose", + "statusListCredential" + ) + SELECT + "id", "correlationId", "length", "issuer", "type", "driverType", + "credentialIdMode", "proofFormat", "indexingDirection", "statusPurpose", + "statusListCredential" + FROM "StatusList"`, + ) + + // Drop old table and rename temporary table + await queryRunner.query(`DROP TABLE "StatusList"`) + await queryRunner.query(`ALTER TABLE "temporary_StatusList" RENAME TO "StatusList"`) + } + + public async down(queryRunner: QueryRunner): Promise { + // Create temporary table with old schema + await queryRunner.query( + `CREATE TABLE "temporary_StatusList" ( + "id" varchar PRIMARY KEY NOT NULL, + "correlationId" varchar NOT NULL, + "length" integer NOT NULL, + "issuer" text NOT NULL, + "type" varchar CHECK( "type" IN ('StatusList2021') ) NOT NULL DEFAULT ('StatusList2021'), + "driverType" varchar CHECK( "driverType" IN ('agent_typeorm','agent_kv_store','github','agent_filesystem') ) NOT NULL DEFAULT ('agent_typeorm'), + "credentialIdMode" varchar CHECK( "credentialIdMode" IN ('ISSUANCE','PERSISTENCE','NEVER') ) NOT NULL DEFAULT ('ISSUANCE'), + "proofFormat" varchar CHECK( "proofFormat" IN ('lds','jwt') ) NOT NULL DEFAULT ('lds'), + "indexingDirection" varchar CHECK( "indexingDirection" IN ('rightToLeft') ) NOT NULL DEFAULT ('rightToLeft'), + "statusPurpose" varchar NOT NULL DEFAULT ('revocation'), + "statusListCredential" text, + CONSTRAINT "UQ_correlationId" UNIQUE ("correlationId") + )`, + ) + + // Copy data back, excluding new columns + await queryRunner.query( + `INSERT INTO "temporary_StatusList"( + "id", "correlationId", "length", "issuer", "type", "driverType", + "credentialIdMode", "proofFormat", "indexingDirection", "statusPurpose", + "statusListCredential" + ) + SELECT + "id", "correlationId", "length", "issuer", + CASE WHEN "type" = 'OAuthStatusList' THEN 'StatusList2021' ELSE "type" END, + "driverType", "credentialIdMode", "proofFormat", "indexingDirection", + COALESCE("statusPurpose", 'revocation'), "statusListCredential" + FROM "StatusList"`, + ) + + // Drop new table and rename temporary table back + await queryRunner.query(`DROP TABLE "StatusList"`) + await queryRunner.query(`ALTER TABLE "temporary_StatusList" RENAME TO "StatusList"`) + } +} diff --git a/packages/data-store/src/statusList/IStatusListStore.ts b/packages/data-store/src/statusList/IStatusListStore.ts index 7081ff95e..ac5a06952 100644 --- a/packages/data-store/src/statusList/IStatusListStore.ts +++ b/packages/data-store/src/statusList/IStatusListStore.ts @@ -1,4 +1,4 @@ -import { StatusListEntryEntity } from '../entities/statusList2021/StatusList2021EntryEntity' +import { StatusListEntryEntity } from '../entities/statusList/StatusList2021EntryEntity' import { IAddStatusListArgs, IAddStatusListEntryArgs, @@ -18,7 +18,7 @@ export interface IStatusListStore { getStatusLists(args: IGetStatusListsArgs): Promise> - removeStatusList(args: IRemoveStatusListArgs): Promise + removeStatusList(args: IRemoveStatusListArgs): Promise addStatusList(args: IAddStatusListArgs): Promise diff --git a/packages/data-store/src/statusList/StatusListStore.ts b/packages/data-store/src/statusList/StatusListStore.ts index 6cbb4db99..0e899d40d 100644 --- a/packages/data-store/src/statusList/StatusListStore.ts +++ b/packages/data-store/src/statusList/StatusListStore.ts @@ -1,8 +1,8 @@ -import { OrPromise } from '@sphereon/ssi-types' +import { OrPromise, StatusListType } from '@sphereon/ssi-types' import Debug from 'debug' import { DataSource, In, Repository } from 'typeorm' -import { StatusListEntity } from '../entities/statusList2021/StatusList2021Entity' -import { StatusListEntryEntity } from '../entities/statusList2021/StatusList2021EntryEntity' +import { OAuthStatusListEntity, StatusList2021Entity, StatusListEntity } from '../entities/statusList/StatusListEntities' +import { StatusListEntryEntity } from '../entities/statusList/StatusList2021EntryEntity' import { IAddStatusListArgs, IAddStatusListEntryArgs, @@ -12,12 +12,13 @@ import { IGetStatusListEntryByIndexArgs, IGetStatusListsArgs, IRemoveStatusListArgs, - IStatusListEntryAvailableArgs, - IUpdateStatusListIndexArgs, IStatusListEntity, + IStatusListEntryAvailableArgs, IStatusListEntryEntity, + IUpdateStatusListIndexArgs, } from '../types' import { IStatusListStore } from './IStatusListStore' +import { statusListEntityFrom, statusListFrom } from '../utils/statusList/MappingUtils' const debug = Debug('sphereon:ssi-sdk:data-store:status-list') @@ -73,7 +74,11 @@ export class StatusListStore implements IStatusListStore { { conflictPaths: ['statusList', 'statusListIndex'] }, ) console.log(updateResult) - return (await this.getStatusListEntryByIndex({ ...args, statusListId, errorOnNotFound: true })) as IStatusListEntryEntity + return (await this.getStatusListEntryByIndex({ + ...args, + statusListId, + errorOnNotFound: true, + })) as IStatusListEntryEntity } async getStatusListEntryByIndex(args: IGetStatusListEntryByIndexArgs): Promise { @@ -96,34 +101,15 @@ export class StatusListStore implements IStatusListStore { return result ?? undefined } - async removeStatusListEntryByIndex(args: IGetStatusListEntryByIndexArgs): Promise { - let error = false - try { - await this.getStatusListEntryByIndex(args) // only used to check it exists - } catch (error) { - error = true - } - if (error) { - console.log(`Could not delete statusList ${args.statusListId} entry by index ${args.statusListIndex}`) - } else { - const result = await ( - await this.getStatusListEntryRepo() - ).delete({ - ...(args.statusListId && { statusList: args.statusListId }), - ...(args.correlationId && { correlationId: args.correlationId }), - statusListIndex: args.statusListIndex, - }) - error = !result.affected || result.affected !== 1 - } - return !error - } - async getStatusListEntryByCredentialId(args: IGetStatusListEntryByCredentialIdArgs): Promise { const credentialId = args.credentialId if (!credentialId) { throw Error('Can only get a credential by credentialId when a credentialId is supplied') } - const statusList = await this.getStatusList({ id: args.statusListId, correlationId: args.statusListCorrelationId }) + const statusList = await this.getStatusList({ + id: args.statusListId, + correlationId: args.statusListCorrelationId, + }) const where = { statusList: statusList.id, ...(args.entryCorrelationId && { correlationId: args.entryCorrelationId }), @@ -158,11 +144,37 @@ export class StatusListStore implements IStatusListStore { return !error } + async removeStatusListEntryByIndex(args: IGetStatusListEntryByIndexArgs): Promise { + let error = false + try { + await this.getStatusListEntryByIndex(args) + } catch (error) { + error = true + } + if (error) { + console.log(`Could not delete statusList ${args.statusListId} entry by index ${args.statusListIndex}`) + } else { + const result = await ( + await this.getStatusListEntryRepo() + ).delete({ + ...(args.statusListId && { statusList: args.statusListId }), + ...(args.correlationId && { correlationId: args.correlationId }), + statusListIndex: args.statusListIndex, + }) + error = !result.affected || result.affected !== 1 + } + return !error + } + async getStatusListEntries(args: IGetStatusListEntriesArgs): Promise { return (await this.getStatusListEntryRepo()).find({ where: { ...args?.filter, statusList: args.statusListId } }) } async getStatusList(args: IGetStatusListArgs): Promise { + return statusListFrom(await this.getStatusListEntity(args)) + } + + private async getStatusListEntity(args: IGetStatusListArgs): Promise { if (!args.id && !args.correlationId) { throw Error(`At least and 'id' or 'correlationId' needs to be provided to lookup a status list`) } @@ -189,7 +201,8 @@ export class StatusListStore implements IStatusListStore { if (!result) { return [] } - return result + + return result.map((entity) => statusListFrom(entity)) } async addStatusList(args: IAddStatusListArgs): Promise { @@ -205,29 +218,42 @@ export class StatusListStore implements IStatusListStore { } debug('Adding status list ', id) - const createdResult = await (await this.getStatusListRepo()).save(args) - - return createdResult + const entity = statusListEntityFrom(args) + const createdResult = await (await this.getStatusListRepo(args.type)).save(entity) + return statusListFrom(createdResult) } async updateStatusList(args: IUpdateStatusListIndexArgs): Promise { const result = await this.getStatusList(args) debug('Updating status list', result) - const updatedResult = await (await this.getStatusListRepo()).save(args, { transaction: true }) - return updatedResult + const entity = statusListEntityFrom(args) + const updatedResult = await (await this.getStatusListRepo(args.type)).save(entity, { transaction: true }) + return statusListFrom(updatedResult) } - async removeStatusList(args: IRemoveStatusListArgs): Promise { - const result = await this.getStatusList(args) - await (await this.getStatusListRepo()).delete(result) + async removeStatusList(args: IRemoveStatusListArgs): Promise { + const result = await this.getStatusListEntity(args) + + await (await this.getStatusListEntryRepo()).delete({ statusList: result.id }) + const deletedEntity = await (await this.getStatusListRepo()).remove(result) + + return Boolean(deletedEntity) } private async getDS(): Promise { return this._dbConnection } - async getStatusListRepo(): Promise> { - return (await this.getDS()).getRepository(StatusListEntity) + async getStatusListRepo(type?: StatusListType): Promise> { + const dataSource = await this.getDS() + switch (type) { + case StatusListType.StatusList2021: + return dataSource.getRepository(StatusList2021Entity) + case StatusListType.OAuthStatusList: + return dataSource.getRepository(OAuthStatusListEntity) + default: + return dataSource.getRepository(StatusListEntity) + } } async getStatusListEntryRepo(): Promise> { diff --git a/packages/data-store/src/types/statusList/IAbstractStatusListStore.ts b/packages/data-store/src/types/statusList/IAbstractStatusListStore.ts index 6c48bd34f..b1a00b337 100644 --- a/packages/data-store/src/types/statusList/IAbstractStatusListStore.ts +++ b/packages/data-store/src/types/statusList/IAbstractStatusListStore.ts @@ -1,7 +1,7 @@ import { FindOptionsWhere } from 'typeorm' -import { IStatusListEntity, IStatusListEntryEntity } from './statusList' +import { IOAuthStatusListEntity, IStatusList2021Entity, IStatusListEntity, IStatusListEntryEntity } from './statusList' -export type FindStatusListArgs = FindOptionsWhere[] +export type FindStatusListArgs = FindOptionsWhere[] export type FindStatusListEntryArgs = FindOptionsWhere[] | FindOptionsWhere export interface IStatusListEntryAvailableArgs { diff --git a/packages/data-store/src/types/statusList/statusList.ts b/packages/data-store/src/types/statusList/statusList.ts index 815d7626c..9ca1e9d23 100644 --- a/packages/data-store/src/types/statusList/statusList.ts +++ b/packages/data-store/src/types/statusList/statusList.ts @@ -1,14 +1,14 @@ import { IIssuer, - OriginalVerifiableCredential, + StatusListCredential, StatusListCredentialIdMode, StatusListDriverType, StatusListIndexingDirection, StatusListType, StatusPurpose2021, + ProofFormat, } from '@sphereon/ssi-types' -import { ProofFormat } from '@veramo/core' -import { StatusListEntity } from '../../entities/statusList2021/StatusList2021Entity' +import { StatusListEntity } from '../../entities/statusList/StatusListEntities' export interface IStatusListEntity { id: string @@ -19,9 +19,17 @@ export interface IStatusListEntity { issuer: string | IIssuer type: StatusListType proofFormat: ProofFormat + statusListCredential?: StatusListCredential +} + +export interface IStatusList2021Entity extends IStatusListEntity { indexingDirection: StatusListIndexingDirection statusPurpose: StatusPurpose2021 - statusListCredential?: OriginalVerifiableCredential +} + +export interface IOAuthStatusListEntity extends IStatusListEntity { + bitsPerStatus: number + expiresAt?: Date } export interface IStatusListEntryEntity { diff --git a/packages/data-store/src/utils/statusList/MappingUtils.ts b/packages/data-store/src/utils/statusList/MappingUtils.ts new file mode 100644 index 000000000..c0de95aec --- /dev/null +++ b/packages/data-store/src/utils/statusList/MappingUtils.ts @@ -0,0 +1,82 @@ +import { IOAuthStatusListEntity, IStatusList2021Entity, IStatusListEntity } from '../../types' +import { OAuthStatusListEntity, StatusList2021Entity, StatusListEntity } from '../../entities/statusList/StatusListEntities' +import { StatusListType } from '@sphereon/ssi-types' +import { replaceNullWithUndefined } from '../FormattingUtils' + +export const statusListEntityFrom = (args: IStatusListEntity): StatusListEntity => { + if (args.type === StatusListType.StatusList2021) { + const entity = new StatusList2021Entity() + const sl2021 = args as IStatusList2021Entity + entity.indexingDirection = sl2021.indexingDirection + entity.statusPurpose = sl2021.statusPurpose + setBaseFields(entity, args) + Object.defineProperty(entity, 'type', { + value: StatusListType.StatusList2021, + enumerable: true, + configurable: true, + }) + return entity + } + + if (args.type === StatusListType.OAuthStatusList) { + const entity = new OAuthStatusListEntity() + const oauthSl = args as IOAuthStatusListEntity + entity.bitsPerStatus = oauthSl.bitsPerStatus + entity.expiresAt = oauthSl.expiresAt + setBaseFields(entity, args) + Object.defineProperty(entity, 'type', { + value: StatusListType.OAuthStatusList, + enumerable: true, + configurable: true, + }) + return entity + } + + throw new Error(`Invalid status list type ${args.type}`) +} + +export const statusListFrom = (entity: StatusListEntity): IStatusListEntity => { + if (entity instanceof StatusList2021Entity) { + const result: IStatusList2021Entity = { + ...getBaseFields(entity), + type: StatusListType.StatusList2021, + indexingDirection: entity.indexingDirection, + statusPurpose: entity.statusPurpose, + } + return replaceNullWithUndefined(result) + } + + if (entity instanceof OAuthStatusListEntity) { + const result: IOAuthStatusListEntity = { + ...getBaseFields(entity), + type: StatusListType.OAuthStatusList, + bitsPerStatus: entity.bitsPerStatus, + expiresAt: entity.expiresAt, + } + return replaceNullWithUndefined(result) + } + + throw new Error(`Invalid status list type ${typeof entity}`) +} + +const setBaseFields = (entity: StatusListEntity, args: IStatusListEntity) => { + entity.id = args.id + entity.correlationId = args.correlationId + entity.length = args.length + entity.issuer = args.issuer + entity.driverType = args.driverType + entity.credentialIdMode = args.credentialIdMode + entity.proofFormat = args.proofFormat + entity.statusListCredential = args.statusListCredential +} + +const getBaseFields = (entity: StatusListEntity): Omit => ({ + id: entity.id, + correlationId: entity.correlationId, + length: entity.length, + issuer: entity.issuer, + driverType: entity.driverType, + credentialIdMode: entity.credentialIdMode, + proofFormat: entity.proofFormat, + statusListCredential: entity.statusListCredential, +}) diff --git a/packages/oid4vci-issuer-rest-api/package.json b/packages/oid4vci-issuer-rest-api/package.json index b3cbf2575..ed4f27e47 100644 --- a/packages/oid4vci-issuer-rest-api/package.json +++ b/packages/oid4vci-issuer-rest-api/package.json @@ -16,6 +16,7 @@ "@sphereon/oid4vci-issuer-server": "0.16.1-next.339", "@sphereon/ssi-express-support": "workspace:*", "@sphereon/ssi-sdk-ext.identifier-resolution": "0.27.0", + "@sphereon/ssi-sdk-ext.jwt-service": "0.27.0", "@sphereon/ssi-sdk.kv-store-temp": "workspace:*", "@sphereon/ssi-sdk.oid4vci-issuer": "workspace:*", "@sphereon/ssi-sdk.oid4vci-issuer-store": "workspace:*", diff --git a/packages/oid4vci-issuer-rest-api/src/types.ts b/packages/oid4vci-issuer-rest-api/src/types.ts index c51ada8a4..4f4d5d40f 100644 --- a/packages/oid4vci-issuer-rest-api/src/types.ts +++ b/packages/oid4vci-issuer-rest-api/src/types.ts @@ -2,7 +2,7 @@ import { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resoluti import { IOID4VCIIssuer } from '@sphereon/ssi-sdk.oid4vci-issuer' import { IOID4VCIStore } from '@sphereon/ssi-sdk.oid4vci-issuer-store' import { IAgentContext, ICredentialIssuer, ICredentialVerifier, IDIDManager, IKeyManager, IResolver } from '@veramo/core' -import { IJwtService } from '@sphereon/ssi-sdk-ext.identifier-resolution/src/types/IJwtService' +import { IJwtService } from '@sphereon/ssi-sdk-ext.jwt-service' export type IRequiredContext = IAgentContext diff --git a/packages/oid4vci-issuer/package.json b/packages/oid4vci-issuer/package.json index 41e9ce3de..6242463e9 100644 --- a/packages/oid4vci-issuer/package.json +++ b/packages/oid4vci-issuer/package.json @@ -18,6 +18,7 @@ "@sphereon/oid4vci-issuer": "0.16.1-next.339", "@sphereon/ssi-sdk-ext.did-utils": "0.27.0", "@sphereon/ssi-sdk-ext.identifier-resolution": "0.27.0", + "@sphereon/ssi-sdk-ext.jwt-service": "0.27.0", "@sphereon/ssi-sdk.agent-config": "workspace:*", "@sphereon/ssi-sdk.core": "workspace:*", "@sphereon/ssi-sdk.kv-store-temp": "workspace:*", diff --git a/packages/oid4vci-issuer/src/types/IOID4VCIIssuer.ts b/packages/oid4vci-issuer/src/types/IOID4VCIIssuer.ts index 5866485ca..7531257bf 100644 --- a/packages/oid4vci-issuer/src/types/IOID4VCIIssuer.ts +++ b/packages/oid4vci-issuer/src/types/IOID4VCIIssuer.ts @@ -17,7 +17,7 @@ import { IOID4VCIStore } from '@sphereon/ssi-sdk.oid4vci-issuer-store' import { ICredential } from '@sphereon/ssi-types/dist' import { IAgentContext, ICredentialIssuer, IDIDManager, IKeyManager, IPluginMethodMap, IResolver } from '@veramo/core' import { IssuerInstance } from '../IssuerInstance' -import { IJwtService } from '@sphereon/ssi-sdk-ext.identifier-resolution/src/types/IJwtService' +import { IJwtService } from '@sphereon/ssi-sdk-ext.jwt-service' export type IssuerCredentialDefinition = JsonLdIssuerCredentialDefinition diff --git a/packages/ssi-types/src/types/index.ts b/packages/ssi-types/src/types/index.ts index e66def827..64c2c1497 100644 --- a/packages/ssi-types/src/types/index.ts +++ b/packages/ssi-types/src/types/index.ts @@ -9,4 +9,5 @@ export * from './jose' export * from './cose' export * from './mso_mdoc' export * from './metadata-types' +export * from './status-list' export * from './dcql' diff --git a/packages/ssi-types/src/types/status-list.ts b/packages/ssi-types/src/types/status-list.ts new file mode 100644 index 000000000..eb1e5277b --- /dev/null +++ b/packages/ssi-types/src/types/status-list.ts @@ -0,0 +1,12 @@ +import { W3CVerifiableCredential } from './w3c-vc' +import { ProofFormat as VeramoProofFormat } from '@veramo/core' + +export enum StatusListType { + StatusList2021 = 'StatusList2021', + OAuthStatusList = 'OAuthStatusList', +} +export type CWT = string + +export type StatusListCredential = W3CVerifiableCredential | CWT + +export type ProofFormat = VeramoProofFormat | 'cbor' diff --git a/packages/ssi-types/src/types/w3c-vc.ts b/packages/ssi-types/src/types/w3c-vc.ts index 7214810c9..2464e0eb0 100644 --- a/packages/ssi-types/src/types/w3c-vc.ts +++ b/packages/ssi-types/src/types/w3c-vc.ts @@ -225,7 +225,7 @@ export interface IVerifyResult { verified: boolean error?: IError log: [{ id: string; valid: boolean }] - }, + } ] statusResult?: IVerifyStatusResult @@ -276,9 +276,11 @@ export interface IErrorDetails { cause?: IError } +/* FIXME figurae out how to handle this, we can't have duplicates and we need one in ssi-types for the data store logic export enum StatusListType { StatusList2021 = 'StatusList2021', } +*/ export type StatusPurpose2021 = 'revocation' | 'suspension' | string diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 7d7233795..97cb99d9e 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -17,6 +17,7 @@ { "path": "ms-authenticator" }, { "path": "ms-request-api" }, { "path": "vc-status-list" }, + { "path": "vc-status-list-tests" }, { "path": "vc-status-list-issuer-drivers" }, { "path": "vc-status-list-issuer" }, { "path": "vc-status-list-issuer-rest-api" }, diff --git a/packages/vc-handler-ld-local/src/__tests__/issue-verify-flow-statuslist.test.ts b/packages/vc-handler-ld-local/src/__tests__/issue-verify-flow-statuslist.test.ts index 4d781c271..a1691571e 100644 --- a/packages/vc-handler-ld-local/src/__tests__/issue-verify-flow-statuslist.test.ts +++ b/packages/vc-handler-ld-local/src/__tests__/issue-verify-flow-statuslist.test.ts @@ -71,13 +71,16 @@ describe('credential-LD full flow', () => { it('create a new status list', async () => { const statusList = await createNewStatusList( { - statusPurpose: 'revocation', + type: StatusListType.StatusList2021, proofFormat: 'lds', id: 'http://localhost:9543/list1', issuer: didKeyIdentifier.did, length: 99999, correlationId: '1234', - type: StatusListType.StatusList2021, + statusList2021: { + statusPurpose: 'revocation', + indexingDirection: 'rightToLeft', + }, }, { agent }, ) @@ -86,7 +89,9 @@ describe('credential-LD full flow', () => { expect(statusList.encodedList).toBeDefined() expect(statusList.issuer).toEqual(didKeyIdentifier.did) expect(statusList.length).toEqual(99999) - expect(statusList.indexingDirection).toEqual('rightToLeft') + expect(statusList.statusList2021).toBeTruthy() + expect(statusList.statusList2021!.indexingDirection).toEqual('rightToLeft') + expect(statusList.statusList2021!.statusPurpose).toEqual('revocation') expect(statusList.proofFormat).toEqual('lds') expect(statusList.statusListCredential).toBeDefined() }) diff --git a/packages/vc-handler-ld-local/src/__tests__/statuslist.test.ts b/packages/vc-handler-ld-local/src/__tests__/statuslist.test.ts deleted file mode 100644 index 9b96b265b..000000000 --- a/packages/vc-handler-ld-local/src/__tests__/statuslist.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { IdentifierResolution, IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution' -import { createAgent, ICredentialPlugin, IDIDManager, IIdentifier, IKeyManager, IResolver, TAgent } from '@veramo/core' -import { CredentialPlugin, ICredentialIssuer } from '@veramo/credential-w3c' -import { DIDManager, MemoryDIDStore } from '@veramo/did-manager' -import { getDidKeyResolver, SphereonKeyDidProvider } from '@sphereon/ssi-sdk-ext.did-provider-key' -import { DIDResolverPlugin } from '@veramo/did-resolver' -import { SphereonKeyManager } from '@sphereon/ssi-sdk-ext.key-manager' -import { SphereonKeyManagementSystem } from '@sphereon/ssi-sdk-ext.kms-local' -import { MemoryKeyStore, MemoryPrivateKeyStore } from '@veramo/key-manager' -import { Resolver } from 'did-resolver' -// @ts-ignore -import nock from 'nock' -import { - createNewStatusList, - checkStatusIndexFromStatusListCredential, - updateStatusIndexFromStatusListCredential, -} from '@sphereon/ssi-sdk.vc-status-list' -import { CredentialHandlerLDLocal } from '../agent' -import { LdDefaultContexts } from '../ld-default-contexts' -import { SphereonEcdsaSecp256k1RecoverySignature2020, SphereonEd25519Signature2018, SphereonEd25519Signature2020 } from '../suites' -import { ICredentialHandlerLDLocal, MethodNames } from '../types' - -jest.setTimeout(100000) - -describe('Status list', () => { - let didKeyIdentifier: IIdentifier - let agent: TAgent - - // jest.setTimeout(1000000) - beforeAll(async () => { - agent = createAgent({ - plugins: [ - new SphereonKeyManager({ - store: new MemoryKeyStore(), - kms: { - local: new SphereonKeyManagementSystem(new MemoryPrivateKeyStore()), - }, - }), - new DIDManager({ - providers: { - 'did:key': new SphereonKeyDidProvider({ defaultKms: 'local' }), - }, - store: new MemoryDIDStore(), - defaultProvider: 'did:key', - }), - new IdentifierResolution({ crypto: global.crypto }), - new DIDResolverPlugin({ - resolver: new Resolver({ - ...getDidKeyResolver(), - }), - }), - new CredentialPlugin(), - new CredentialHandlerLDLocal({ - contextMaps: [LdDefaultContexts], - suites: [new SphereonEd25519Signature2018(), new SphereonEd25519Signature2020(), new SphereonEcdsaSecp256k1RecoverySignature2020()], - bindingOverrides: new Map([ - // Bindings to test overrides of credential-ld plugin methods - ['createVerifiableCredentialLD', MethodNames.createVerifiableCredentialLDLocal], - ['createVerifiablePresentationLD', MethodNames.createVerifiablePresentationLDLocal], - // We test the verify methods by using the LDLocal versions directly in the tests - ]), - }), - ], - }) - didKeyIdentifier = await agent.didManagerCreate() - }) - - it('create a new status list', async () => { - const statusList = await createNewStatusList( - { - statusPurpose: 'revocation', - proofFormat: 'lds', - id: 'http://localhost:9543/list1', - issuer: didKeyIdentifier.did, - length: 99999, - correlationId: '' + new Date().toISOString(), - }, - { agent }, - ) - expect(statusList).toBeDefined() - expect(statusList.id).toEqual('http://localhost:9543/list1') - expect(statusList.encodedList).toBeDefined() - expect(statusList.issuer).toEqual(didKeyIdentifier.did) - expect(statusList.length).toEqual(99999) - expect(statusList.indexingDirection).toEqual('rightToLeft') - expect(statusList.proofFormat).toEqual('lds') - expect(statusList.statusListCredential).toBeDefined() - }) - - it('Update a status list', async () => { - const initialList = await createNewStatusList( - { - statusPurpose: 'revocation', - proofFormat: 'lds', - id: 'http://localhost:9543/list2', - issuer: didKeyIdentifier.did, - length: 99999, - correlationId: '' + new Date().toISOString(), - }, - { agent }, - ) - expect(initialList).toBeDefined() - - let statusList = await updateStatusIndexFromStatusListCredential( - { statusListCredential: initialList.statusListCredential, statusListIndex: 2, value: true }, - { agent }, - ) - statusList = await updateStatusIndexFromStatusListCredential( - { statusListCredential: statusList.statusListCredential, statusListIndex: 4, value: true }, - { agent }, - ) - - expect(statusList.id).toEqual('http://localhost:9543/list2') - expect(statusList.encodedList).toBeDefined() - expect(statusList.issuer).toEqual(didKeyIdentifier.did) - expect(statusList.length).toEqual(99999) - expect(statusList.indexingDirection).toEqual('rightToLeft') - expect(statusList.proofFormat).toEqual('lds') - expect(statusList.statusListCredential).toBeDefined() - expect(statusList.statusListCredential).not.toEqual(initialList.statusListCredential) - - const result2 = await checkStatusIndexFromStatusListCredential({ - statusListCredential: statusList.statusListCredential, - statusListIndex: '2', - }) - expect(result2).toEqual(true) - const result3 = await checkStatusIndexFromStatusListCredential({ - statusListCredential: statusList.statusListCredential, - statusListIndex: '3', - }) - expect(result3).toEqual(false) - const result4 = await checkStatusIndexFromStatusListCredential({ - statusListCredential: statusList.statusListCredential, - statusListIndex: '4', - }) - expect(result4).toEqual(true) - - statusList = await updateStatusIndexFromStatusListCredential( - { statusListCredential: statusList.statusListCredential, statusListIndex: 4, value: false }, - { agent }, - ) - const result4Updated = await checkStatusIndexFromStatusListCredential({ - statusListCredential: statusList.statusListCredential, - statusListIndex: '4', - }) - expect(result4Updated).toEqual(false) - }) -}) diff --git a/packages/vc-status-list-issuer-drivers/src/drivers.ts b/packages/vc-status-list-issuer-drivers/src/drivers.ts index b5b30df52..740f0250a 100644 --- a/packages/vc-status-list-issuer-drivers/src/drivers.ts +++ b/packages/vc-status-list-issuer-drivers/src/drivers.ts @@ -5,12 +5,20 @@ import { IGetStatusListEntryByIndexArgs, IStatusListEntity, IStatusListEntryEntity, + StatusListEntity, StatusListStore, } from '@sphereon/ssi-sdk.data-store' -import { StatusList2021EntryCredentialStatus, statusListCredentialToDetails, StatusListDetails } from '@sphereon/ssi-sdk.vc-status-list' -import { OriginalVerifiableCredential, StatusListCredentialIdMode, StatusListDriverType } from '@sphereon/ssi-types' +import { + StatusList2021EntryCredentialStatus, + statusListCredentialToDetails, + StatusListOAuthEntryCredentialStatus, + StatusListResult, +} from '@sphereon/ssi-sdk.vc-status-list' +import { StatusListCredentialIdMode, StatusListDriverType, StatusListType, StatusListCredential } from '@sphereon/ssi-types' import { DataSource } from 'typeorm' import { IStatusListDriver } from './types' +import { statusListResultToEntity } from './status-list-adapters' +import { OAuthStatusListEntity, StatusList2021Entity } from '@sphereon/ssi-sdk.data-store/dist/entities/statusList/StatusListEntities' export interface StatusListManagementOptions { id?: string @@ -121,46 +129,37 @@ export class AgentDataSourceStatusListDriver implements IStatusListDriver { } async createStatusList(args: { - statusListCredential: OriginalVerifiableCredential + statusListCredential: StatusListCredential correlationId?: string credentialIdMode?: StatusListCredentialIdMode - }): Promise { + }): Promise { const correlationId = args.correlationId ?? this.options.correlationId if (!correlationId) { throw Error('Either a correlationId needs to be set as an option, or it needs to be provided when creating a status list. None found') } const credentialIdMode = args.credentialIdMode ?? StatusListCredentialIdMode.ISSUANCE const details = await statusListCredentialToDetails({ ...args, correlationId, driverType: this.getType() }) - const entity = await ( - await this.statusListStore.getStatusListRepo() - ).findOne({ - where: [ - { - id: details.id, - }, - { - correlationId, - }, - ], - }) - if (entity) { - throw Error(`Status list ${details.id}, correlationId ${args.correlationId} already exists`) - } - this._statusListLength = details.length + + // (StatusListStore does the duplicate entity check) await this.statusListStore.addStatusList({ ...details, credentialIdMode, correlationId, driverType: this.getType(), }) + this._statusListLength = details.length return details } - async updateStatusList(args: { statusListCredential: OriginalVerifiableCredential; correlationId: string }): Promise { + async updateStatusList(args: { + statusListCredential: StatusListCredential + correlationId: string + type: StatusListType + }): Promise { const correlationId = args.correlationId ?? this.options.correlationId const details = await statusListCredentialToDetails({ ...args, correlationId, driverType: this.getType() }) const entity = await ( - await this.statusListStore.getStatusListRepo() + await this.statusListStore.getStatusListRepo(args.type) ).findOne({ where: [ { @@ -189,21 +188,48 @@ export class AgentDataSourceStatusListDriver implements IStatusListDriver { return Promise.resolve(true) } + private isStatusList2021Entity(statusList: StatusListEntity): statusList is StatusList2021Entity { + return statusList instanceof StatusList2021Entity + } + + private isOAuthStatusListEntity(statusList: StatusListEntity): statusList is OAuthStatusListEntity { + return statusList instanceof OAuthStatusListEntity + } + async updateStatusListEntry(args: IAddStatusListEntryArgs): Promise<{ - credentialStatus: StatusList2021EntryCredentialStatus + credentialStatus: StatusList2021EntryCredentialStatus | StatusListOAuthEntryCredentialStatus statusListEntry: IStatusListEntryEntity }> { - const statusList = typeof args.statusList === 'string' ? await this.getStatusList() : args.statusList + const statusList: StatusListEntity = typeof args.statusList === 'string' ? statusListResultToEntity(await this.getStatusList()) : args.statusList const statusListEntry = await this.statusListStore.updateStatusListEntry({ ...args, statusList: statusList.id }) - const credentialStatus: StatusList2021EntryCredentialStatus = { - id: `${statusList.id}#${statusListEntry.statusListIndex}`, - type: 'StatusList2021Entry', - statusPurpose: statusList.statusPurpose ?? 'revocation', - statusListIndex: '' + statusListEntry.statusListIndex, - statusListCredential: statusList.id, + + if (this.isStatusList2021Entity(statusList)) { + return { + credentialStatus: { + id: `${statusList.id}#${statusListEntry.statusListIndex}`, + type: 'StatusList2021Entry', + statusPurpose: statusList.statusPurpose ?? 'revocation', + statusListIndex: '' + statusListEntry.statusListIndex, + statusListCredential: statusList.id, + }, + statusListEntry, + } + } else if (this.isOAuthStatusListEntity(statusList)) { + return { + credentialStatus: { + id: `${statusList.id}#${statusListEntry.statusListIndex}`, + type: 'OAuthStatusListEntry', + bitsPerStatus: statusList.bitsPerStatus, + statusListIndex: '' + statusListEntry.statusListIndex, + statusListCredential: statusList.id, + expiresAt: statusList.expiresAt, + }, + statusListEntry, + } } - return { credentialStatus, statusListEntry } + + throw new Error(`Unsupported status list type: ${typeof statusList}`) } async getStatusListEntryByCredentialId(args: IGetStatusListEntryByCredentialIdArgs): Promise { @@ -251,7 +277,7 @@ export class AgentDataSourceStatusListDriver implements IStatusListDriver { return this._statusListLength! } - async getStatusList(args?: { correlationId?: string }): Promise { + async getStatusList(args?: { correlationId?: string }): Promise { const id = this.options.id const correlationId = args?.correlationId ?? this.options.correlationId return await this.statusListStore diff --git a/packages/vc-status-list-issuer-drivers/src/status-list-adapters.ts b/packages/vc-status-list-issuer-drivers/src/status-list-adapters.ts new file mode 100644 index 000000000..fddbeea79 --- /dev/null +++ b/packages/vc-status-list-issuer-drivers/src/status-list-adapters.ts @@ -0,0 +1,38 @@ +import { StatusListType } from '@sphereon/ssi-types' +import { OAuthStatusListEntity, StatusList2021Entity } from '@sphereon/ssi-sdk.data-store/dist/entities/statusList/StatusListEntities' +import { StatusListResult } from '@sphereon/ssi-sdk.vc-status-list' + +export function statusListResultToEntity(result: StatusListResult): StatusList2021Entity | OAuthStatusListEntity { + const baseFields = { + id: result.id, + correlationId: result.correlationId, + driverType: result.driverType, + credentialIdMode: result.credentialIdMode, + length: result.length, + issuer: result.issuer, + type: result.type, + proofFormat: result.proofFormat, + statusListCredential: result.statusListCredential, + } + + if (result.type === StatusListType.StatusList2021) { + if (!result.statusList2021) { + throw new Error('Missing statusList2021 details') + } + return Object.assign(new StatusList2021Entity(), { + ...baseFields, + indexingDirection: result.statusList2021.indexingDirection, + statusPurpose: result.statusList2021.statusPurpose, + }) + } else if (result.type === StatusListType.OAuthStatusList) { + if (!result.oauthStatusList) { + throw new Error('Missing oauthStatusList details') + } + return Object.assign(new OAuthStatusListEntity(), { + ...baseFields, + bitsPerStatus: result.oauthStatusList.bitsPerStatus, + expiresAt: result.oauthStatusList.expiresAt, + }) + } + throw new Error(`Unsupported status list type: ${result.type}`) +} diff --git a/packages/vc-status-list-issuer-drivers/src/types.ts b/packages/vc-status-list-issuer-drivers/src/types.ts index 6142c92be..40cfe1bb5 100644 --- a/packages/vc-status-list-issuer-drivers/src/types.ts +++ b/packages/vc-status-list-issuer-drivers/src/types.ts @@ -6,8 +6,13 @@ import { IStatusListEntryEntity, StatusListStore, } from '@sphereon/ssi-sdk.data-store' -import { IStatusListPlugin, StatusList2021EntryCredentialStatus, StatusListDetails } from '@sphereon/ssi-sdk.vc-status-list' -import { OriginalVerifiableCredential, StatusListDriverType } from '@sphereon/ssi-types' +import { + IStatusListPlugin, + StatusList2021EntryCredentialStatus, + StatusListOAuthEntryCredentialStatus, + StatusListResult, +} from '@sphereon/ssi-sdk.vc-status-list' +import { StatusListCredential, StatusListDriverType } from '@sphereon/ssi-types' import { IAgentContext, ICredentialIssuer, @@ -40,12 +45,12 @@ export interface IStatusListDriver { getStatusListLength(args?: { correlationId?: string }): Promise - createStatusList(args: { statusListCredential: OriginalVerifiableCredential; correlationId?: string }): Promise + createStatusList(args: { statusListCredential: StatusListCredential; correlationId?: string }): Promise - getStatusList(args?: { correlationId?: string }): Promise + getStatusList(args?: { correlationId?: string }): Promise updateStatusListEntry(args: IAddStatusListEntryArgs): Promise<{ - credentialStatus: StatusList2021EntryCredentialStatus + credentialStatus: StatusList2021EntryCredentialStatus | StatusListOAuthEntryCredentialStatus statusListEntry: IStatusListEntryEntity }> @@ -53,7 +58,7 @@ export interface IStatusListDriver { getStatusListEntryByIndex(args: IGetStatusListEntryByIndexArgs): Promise - updateStatusList(args: { statusListCredential: OriginalVerifiableCredential }): Promise + updateStatusList(args: { statusListCredential: StatusListCredential }): Promise deleteStatusList(): Promise diff --git a/packages/vc-status-list-issuer-rest-api/__tests__/agent.ts b/packages/vc-status-list-issuer-rest-api/__tests__/agent.ts index 45b2a5e50..30044c041 100644 --- a/packages/vc-status-list-issuer-rest-api/__tests__/agent.ts +++ b/packages/vc-status-list-issuer-rest-api/__tests__/agent.ts @@ -27,6 +27,7 @@ import { Resolver } from 'did-resolver' import { StatuslistManagementApiServer } from '../src' import { IRequiredPlugins } from '@sphereon/ssi-sdk.vc-status-list-issuer-drivers' import { DB_CONNECTION_NAME_POSTGRES, DB_ENCRYPTION_KEY, postgresConfig } from './database' +import { IJwtService, JwtService } from '@sphereon/ssi-sdk-ext.jwt-service' const debug = Debug('sphereon:status-list-api') @@ -66,7 +67,7 @@ const dbConnection = DataSources.singleInstance() const privateKeyStore: PrivateKeyStore = new PrivateKeyStore(dbConnection, new SecretBox(DB_ENCRYPTION_KEY)) const agent: TAgent = createAgent< - IDIDManager & IKeyManager & IDataStoreORM & IResolver & ICredentialHandlerLDLocal & ICredentialPlugin & IIdentifierResolution + IDIDManager & IKeyManager & IDataStoreORM & IResolver & ICredentialHandlerLDLocal & ICredentialPlugin & IIdentifierResolution & IJwtService >({ plugins: [ new DataStore(dbConnection), @@ -86,6 +87,7 @@ const agent: TAgent = createAgent< resolver, }), new IdentifierResolution({ crypto: global.crypto }), + new JwtService(), new CredentialPlugin(), new CredentialHandlerLDLocal({ contextMaps: [LdDefaultContexts], diff --git a/packages/vc-status-list-issuer-rest-api/package.json b/packages/vc-status-list-issuer-rest-api/package.json index 270d9d62f..c6d43a81f 100644 --- a/packages/vc-status-list-issuer-rest-api/package.json +++ b/packages/vc-status-list-issuer-rest-api/package.json @@ -15,6 +15,7 @@ "@sphereon/ssi-express-support": "workspace:*", "@sphereon/ssi-sdk-ext.did-utils": "0.27.0", "@sphereon/ssi-sdk-ext.identifier-resolution": "0.27.0", + "@sphereon/ssi-sdk-ext.jwt-service": "0.27.0", "@sphereon/ssi-sdk.core": "workspace:*", "@sphereon/ssi-sdk.data-store": "workspace:*", "@sphereon/ssi-sdk.vc-status-list": "workspace:*", diff --git a/packages/vc-status-list-issuer-rest-api/src/api-functions.ts b/packages/vc-status-list-issuer-rest-api/src/api-functions.ts index 9e8c7c862..dd770dfee 100644 --- a/packages/vc-status-list-issuer-rest-api/src/api-functions.ts +++ b/packages/vc-status-list-issuer-rest-api/src/api-functions.ts @@ -8,6 +8,7 @@ import { getDriver } from '@sphereon/ssi-sdk.vc-status-list-issuer-drivers' import Debug from 'debug' import { Request, Response, Router } from 'express' import { ICredentialStatusListEndpointOpts, IRequiredContext, IW3CredentialStatusEndpointOpts, UpdateCredentialStatusRequest } from './types' +import { StatusListType } from '@sphereon/ssi-types' const debug = Debug('sphereon:ssi-sdk:status-list') @@ -100,7 +101,14 @@ export function getStatusListCredentialIndexStatusEndpoint(router: Router, conte correlationId: details.correlationId, errorOnNotFound: false, }) - const status = await checkStatusIndexFromStatusListCredential({ ...details, statusListIndex }) + const type = details.type === StatusListType.StatusList2021 ? 'StatusList2021Entry' : details.type + const status = await checkStatusIndexFromStatusListCredential({ + statusListCredential: details.statusListCredential, + ...(details.type === StatusListType.StatusList2021 ? { statusPurpose: details.statusList2021?.statusPurpose } : {}), + type, + id: details.id, + statusListIndex, + }) if (!entry) { // The fact we have nothing on it means the status is okay entry = { @@ -170,12 +178,15 @@ export function updateW3CStatusEndpoint(router: Router, context: IRequiredContex `Required 'status' value was missing in the credentialStatus array for credentialId ${credentialId}`, ) } - const value = updateItem.status === '0' || updateItem.status.toLowerCase() === 'false' ? false : true + const value = updateItem.status === '0' || updateItem.status.toLowerCase() === 'false' ? 0 : 1 const statusList = statusListId ?? statusListEntry.statusList await driver.updateStatusListEntry({ ...statusListEntry, statusListIndex, statusList, credentialId, value: value ? '1' : '0' }) // todo: optimize. We are now creating a new VC for every item passed in. Probably wise to look at DB as well - details = await updateStatusIndexFromStatusListCredential({ statusListCredential, statusListIndex, value, keyRef: opts.keyRef }, context) + details = await updateStatusIndexFromStatusListCredential( + { statusListCredential: statusListCredential, statusListIndex, value, keyRef: opts.keyRef }, + context, + ) details = await driver.updateStatusList({ statusListCredential: details.statusListCredential }) } diff --git a/packages/vc-status-list-issuer/__tests__/status-list-vc-handling.test.ts b/packages/vc-status-list-issuer/__tests__/status-list-vc-handling.test.ts index 248650417..6dcce4972 100644 --- a/packages/vc-status-list-issuer/__tests__/status-list-vc-handling.test.ts +++ b/packages/vc-status-list-issuer/__tests__/status-list-vc-handling.test.ts @@ -12,19 +12,9 @@ import { SphereonEd25519Signature2020, SphereonJsonWebSignature2020, } from '@sphereon/ssi-sdk.vc-handler-ld-local' -import { IStatusListPlugin } from '@sphereon/ssi-sdk.vc-status-list' -import { StatusListDriverType, StatusListType } from '@sphereon/ssi-types' -import { - createAgent, - CredentialPayload, - ICredentialPlugin, - IDataStoreORM, - IDIDManager, - IIdentifier, - IKeyManager, - IResolver, - TAgent, -} from '@veramo/core' +import { IStatusListPlugin, StatusListResult } from '@sphereon/ssi-sdk.vc-status-list' +import { IVerifiableCredential, StatusListDriverType, StatusListType } from '@sphereon/ssi-types' +import { createAgent, ICredentialPlugin, IDataStoreORM, IDIDManager, IKeyManager, IResolver, TAgent } from '@veramo/core' import { CredentialPlugin } from '@veramo/credential-w3c' import { DataStore, DataStoreORM, DIDStore, KeyStore, PrivateKeyStore } from '@veramo/data-store' import { DIDManager } from '@veramo/did-manager' @@ -34,7 +24,6 @@ import { KeyManager } from '@veramo/key-manager' import { KeyManagementSystem, SecretBox } from '@veramo/kms-local' import Debug from 'debug' import { Resolver } from 'did-resolver' -import { v4 } from 'uuid' import { StatusListPlugin } from '../src/agent/StatusListPlugin' import { DB_CONNECTION_NAME_POSTGRES, DB_ENCRYPTION_KEY, sqliteConfig } from './database' @@ -88,14 +77,19 @@ describe('JWT Verifiable Credential, should be', () => { let agent: TAgent // let agentContext: IAgentContext - let identifier: IIdentifier beforeAll(async () => { agent = createAgent({ plugins: [ new DataStore(dbConnection), new DataStoreORM(dbConnection), new StatusListPlugin({ - instances: [{ id: 'http://localhost/test/1', driverType: StatusListDriverType.AGENT_TYPEORM, dataSource: dbConnection }], + instances: [ + { + id: 'http://localhost/test/1', + driverType: StatusListDriverType.AGENT_TYPEORM, + dataSource: dbConnection, + }, + ], defaultInstanceId: 'http://localhost/test/1', allDataSources: DataSources.singleInstance(), }), @@ -134,7 +128,7 @@ describe('JWT Verifiable Credential, should be', () => { // agentContext = {...agent.context, agent}; await agent.dataStoreORMGetIdentifiers().then((ids) => ids.forEach((id) => console.log(JSON.stringify(id, null, 2)))) - identifier = await agent + await agent .didManagerCreate({ provider: 'did:jwk', alias: 'test', @@ -151,38 +145,133 @@ describe('JWT Verifiable Credential, should be', () => { }) }) - it('should add status list to credential', async () => { - // Just for this test we are creating the status list. Normally this has been pre-created of course - const sl = await agent.slCreateStatusList({ - id: 'http://localhost/test/1', - issuer: identifier.did, - type: StatusListType.StatusList2021, - proofFormat: 'jwt', - statusPurpose: 'revocation', - keyRef: identifier.keys[0].kid, - correlationId: '1', + describe('slCreateStatusList', () => { + it('should reject non-JWT proof formats like LD-Signatures when creating OAuth status list', async () => { + await expect( + agent.slCreateStatusList({ + type: StatusListType.OAuthStatusList, + issuer: 'did:example:123', + id: 'list123', + correlationId: 'test-1-' + Date.now(), + proofFormat: 'lds', + oauthStatusList: { + bitsPerStatus: 2, + }, + }), + ).rejects.toThrow("Invalid proof format 'lds' for OAuthStatusList") + }) + + it('should successfully create OAuth status list using JWT format with proper header and encoding', async () => { + const mockResult = { + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + statusListCredential: 'ey_eyMockJWT', + encodedList: 'AAAA', + id: 'list123', + issuer: 'did:example:123', + length: 250000, + oauthStatusList: { + bitsPerStatus: 2, + }, + } satisfies StatusListResult + jest.spyOn(agent, 'slCreateStatusList').mockResolvedValue(mockResult) + + const result = await agent.slCreateStatusList({ + type: StatusListType.OAuthStatusList, + issuer: 'did:example:123', + id: 'list123', + proofFormat: 'jwt', + oauthStatusList: { + bitsPerStatus: 2, + }, + }) + + expect(result.type).toBe(StatusListType.OAuthStatusList) + expect(result.proofFormat).toBe('jwt') + expect(result.statusListCredential).toBe('ey_eyMockJWT') + }) + }) + + describe('slAddStatusToCredential', () => { + it('should inject a status to a credential', async () => { + const mockCredential: IVerifiableCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: 'did:example:123', + issuanceDate: '2024-01-15T00:00:00Z', + credentialSubject: { + id: 'did:example:456', + }, + proof: { + type: 'Ed25519Signature2018', + created: '2024-01-15T00:00:00Z', + proofPurpose: 'assertionMethod', + verificationMethod: 'did:example:123#key-1', + }, + } + + const mockResultCredential: IVerifiableCredential = { + ...mockCredential, + credentialStatus: { + id: 'list123#0', + type: 'OAuth2StatusList', + statusListIndex: '0', + statusListCredential: 'eyMockJWT', + }, + } + + jest.spyOn(agent, 'slAddStatusToCredential').mockResolvedValue(mockResultCredential) + + const result = await agent.slAddStatusToCredential({ + credential: mockCredential, + statusListId: 'list123', + statusListIndex: 0, + }) + + expect(result.credentialStatus?.type).toBe('OAuth2StatusList') + expect(result.credentialStatus?.id).toBe('list123#0') + expect(result.credentialStatus?.statusListIndex).toBe('0') + expect(result.credentialStatus?.statusListCredential).toBe('eyMockJWT') + expect(result.issuer).toBe('did:example:123') + }) + }) + + describe('slGetStatusList', () => { + it('should retrieve an existing status list', async () => { + const mockResult: StatusListResult = { + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + statusListCredential: 'ey_mockJWT', + encodedList: 'AAAA', + id: 'list123', + issuer: 'did:example:123', + length: 250000, + oauthStatusList: { + bitsPerStatus: 2, + }, + } + + jest.spyOn(agent, 'slGetStatusList').mockResolvedValue(mockResult) + + const result = await agent.slGetStatusList({ + id: 'list123', + }) + + expect(result.id).toBe('list123') + expect(result.type).toBe(StatusListType.OAuthStatusList) + expect(result.encodedList).toBe('AAAA') + expect(result.statusListCredential).toBe('ey_mockJWT') + expect(result.length).toBe(250000) + }) + + it('should throw when status list not found', async () => { + jest.spyOn(agent, 'slGetStatusList').mockRejectedValue(new Error('Status list not found')) + + await expect( + agent.slGetStatusList({ + id: 'nonexistent', + }), + ).rejects.toThrow('Status list not found') }) - console.log(JSON.stringify(sl, null, 2)) - - // @ts-ignore // We do not provide the credentialStatus id as the plugin should handle that - const vcPayload = { - issuer: identifier.did, - id: v4(), - credentialSubject: { - id: identifier.did, - example: 'value', - }, - - // Let's create a credentialStatus object, so that the status list handling code will assign an index automatically - credentialStatus: { - type: 'StatusList2021', - }, - } as CredentialPayload - const vc = await agent.createVerifiableCredentialLDLocal({ credential: vcPayload, keyRef: identifier.keys[0].kid }) - expect(vc).toBeDefined() - expect(vc.credentialStatus).toBeDefined() - expect(vc.credentialStatus?.statusListIndex).toBeDefined() - - console.log(JSON.stringify(vc, null, 2)) }) }) diff --git a/packages/vc-status-list-issuer/src/agent/StatusListPlugin.ts b/packages/vc-status-list-issuer/src/agent/StatusListPlugin.ts index 70ecd4f98..accd7c11d 100644 --- a/packages/vc-status-list-issuer/src/agent/StatusListPlugin.ts +++ b/packages/vc-status-list-issuer/src/agent/StatusListPlugin.ts @@ -8,11 +8,11 @@ import { IRequiredContext, IRequiredPlugins, IStatusListPlugin, - StatusListDetails, + StatusListResult, } from '@sphereon/ssi-sdk.vc-status-list' import { getDriver } from '@sphereon/ssi-sdk.vc-status-list-issuer-drivers' import { Loggers } from '@sphereon/ssi-types' -import { IAgentContext, IAgentPlugin } from '@veramo/core' +import { IAgentContext, IAgentPlugin, IKeyManager } from '@veramo/core' import { createStatusListFromInstance, handleCredentialStatus } from '../functions' import { StatusListInstance } from '../types' @@ -47,7 +47,10 @@ export class StatusListPlugin implements IAgentPlugin { this.autoCreateInstances = opts.autoCreateInstances ?? true } - private async slGetStatusList(args: GetStatusListArgs, context: IAgentContext): Promise { + private async slGetStatusList( + args: GetStatusListArgs, + context: IAgentContext, + ): Promise { const sl = this.instances.find((instance) => instance.id === args.id || instance.correlationId === args.correlationId) const dataSource = (sl?.dataSource ?? args?.dataSource) @@ -73,8 +76,8 @@ export class StatusListPlugin implements IAgentPlugin { private async slCreateStatusList( args: CreateNewStatusListArgs, - context: IAgentContext, - ): Promise { + context: IAgentContext, + ): Promise { const sl = await createNewStatusList(args, context) const dataSource = args?.dataSource ? await args.dataSource @@ -86,7 +89,7 @@ export class StatusListPlugin implements IAgentPlugin { correlationId: sl.correlationId, dataSource, }) - let statusListDetails: StatusListDetails | undefined = undefined + let statusListDetails: StatusListResult | undefined = undefined try { statusListDetails = await this.slGetStatusList(args, context) } catch (e) { @@ -100,10 +103,10 @@ export class StatusListPlugin implements IAgentPlugin { correlationId: sl.correlationId, }) this.instances.push({ - correlationId: statusListDetails.correlationId, - id: statusListDetails.id, + correlationId: statusListDetails!.correlationId, + id: statusListDetails!.id, dataSource, - driverType: statusListDetails.driverType!, + driverType: statusListDetails!.driverType!, driverOptions: driver.getOptions(), }) } diff --git a/packages/vc-status-list-issuer/src/functions.ts b/packages/vc-status-list-issuer/src/functions.ts index e26897e51..9ecd5291b 100644 --- a/packages/vc-status-list-issuer/src/functions.ts +++ b/packages/vc-status-list-issuer/src/functions.ts @@ -4,7 +4,7 @@ import { IIssueCredentialStatusOpts, IRequiredPlugins, IStatusListPlugin, - StatusListDetails, + StatusListResult, } from '@sphereon/ssi-sdk.vc-status-list' import { getDriver, IStatusListDriver } from '@sphereon/ssi-sdk.vc-status-list-issuer-drivers' import { StatusListCredentialIdMode, StatusListType, StatusPurpose2021 } from '@sphereon/ssi-types' @@ -17,7 +17,7 @@ export const createStatusListFromInstance = async ( instance: StatusListInstance & { issuer: string; type?: StatusListType; statusPurpose?: StatusPurpose2021 } }, context: IAgentContext, -): Promise => { +): Promise => { const instance = { ...args.instance, dataSource: args.instance.dataSource ? await args.instance.dataSource : undefined, @@ -25,18 +25,18 @@ export const createStatusListFromInstance = async ( statusPurpose: args.instance.statusPurpose ?? 'revocation', correlationId: args.instance.correlationId ?? args.instance.id, } - let sl: StatusListDetails + let statusList: StatusListResult try { - sl = await context.agent.slGetStatusList(instance) + statusList = await context.agent.slGetStatusList(instance) } catch (e) { const id = instance.id const correlationId = instance.correlationId if (!id || !correlationId) { return Promise.reject(Error(`No correlation id and id provided for status list`)) } - sl = await context.agent.slCreateStatusList({ ...instance, id, correlationId }) + statusList = await context.agent.slCreateStatusList({ ...instance, id, correlationId }) } - return sl + return statusList } export const handleCredentialStatus = async ( @@ -68,6 +68,7 @@ export const handleCredentialStatus = async ( 'No credential.id was provided in the credential, whilst the issuer is configured to persist credentialIds. Please adjust your input credential to contain an id', ) } + let existingEntry: IStatusListEntryEntity | undefined = undefined // Search whether there is an existing status list entry for this credential first if (credentialId) { diff --git a/packages/vc-status-list-tests/README.md b/packages/vc-status-list-tests/README.md new file mode 100644 index 000000000..4cca41dc5 --- /dev/null +++ b/packages/vc-status-list-tests/README.md @@ -0,0 +1,43 @@ +# README.md + +## Overview + +This package, `@sphereon/vc-status-list-tests`, contains a comprehensive suite of tests for verifying the functionality of the `@sphereon/ssi-sdk.vc-status-list` library. The tests ensure correctness and robustness when working with status lists, including `StatusList2021` and `OAuthStatusList`, used for managing verifiable credentials. The tests also cover various scenarios involving JWT and Linked Data Signatures (LD-Signatures) proofs. + +## Test Features + +### StatusList2021 Tests +- **Create and Update Using LD-Signatures**: Validates creation and modification of `StatusList2021` credentials using Linked Data Signatures. +- **Create and Update Using JWT Format**: Ensures correct behavior when using JWT-based proofs. +- **Update Status Using Encoded List**: Verifies updates to `StatusList2021` credentials using pre-encoded status lists. +- **Conversion to Verifiable Credential**: Tests the conversion of a `StatusList2021` to a verifiable credential in both string and object issuer formats, ensuring all required fields are correctly handled. + +### OAuthStatusList Tests +- **Create and Update Using JWT Format**: Confirms proper creation and modification of `OAuthStatusList` credentials using JWT-based proofs. +- **Invalid Proof Format Rejection**: Tests that invalid proof formats (e.g., LD-Signatures) are correctly rejected for `OAuthStatusList`. + +### Utility Tests +- **Updating Status Indices**: Validates the ability to update status indices within status lists for both `StatusList2021` and `OAuthStatusList`. +- **Error Handling**: Ensures missing or invalid fields throw appropriate errors during credential creation or updates. + +## Purpose of a Separate Package + +This package is maintained as a separate testing module to: +1. **Avoid Cyclic Dependencies**: Prevent cyclic dependency issues with `@sphereon/ssi-sdk.vc-handler-ld-local` and other related packages. +2. **More Flexible With Other Dependencies**: Allows imports from additional packages without adding them to `vc-handler-ld-local`, where some of these tests originated. +## Dependencies + +This package leverages several dependencies, including: +- `@veramo/core` and related plugins for DID management and credential handling. +- `@sphereon` SDK extensions for enhanced functionality like key management and identifier resolution. +- `jest` for running the test suite. + +## How to Use + +1. **Install Dependencies**: + Run `pnpm install` to install the necessary dependencies. Ensure all workspace links are correctly resolved. + +2. **Run Tests**: + Execute the test suite using: + ```bash + pnpm test diff --git a/packages/vc-status-list-tests/__tests__/statuslist.test.ts b/packages/vc-status-list-tests/__tests__/statuslist.test.ts new file mode 100644 index 000000000..0ac707d47 --- /dev/null +++ b/packages/vc-status-list-tests/__tests__/statuslist.test.ts @@ -0,0 +1,435 @@ +import { IdentifierResolution, IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution' +import { createAgent, ICredentialPlugin, IDIDManager, IIdentifier, IKeyManager, IResolver, TAgent } from '@veramo/core' +import { CredentialPlugin, ICredentialIssuer } from '@veramo/credential-w3c' +import { DIDManager, MemoryDIDStore } from '@veramo/did-manager' +import { getDidKeyResolver, SphereonKeyDidProvider } from '@sphereon/ssi-sdk-ext.did-provider-key' +import { DIDResolverPlugin } from '@veramo/did-resolver' +import { SphereonKeyManager } from '@sphereon/ssi-sdk-ext.key-manager' +import { SphereonKeyManagementSystem } from '@sphereon/ssi-sdk-ext.kms-local' +import { MemoryKeyStore, MemoryPrivateKeyStore } from '@veramo/key-manager' +import { Resolver } from 'did-resolver' +import { + checkStatusIndexFromStatusListCredential, + createNewStatusList, + Status2021, + statusList2021ToVerifiableCredential, + statusListCredentialToDetails, + StatusOAuth, + updateStatusIndexFromStatusListCredential, + updateStatusListIndexFromEncodedList, +} from '@sphereon/ssi-sdk.vc-status-list' +import { + CredentialHandlerLDLocal, + ICredentialHandlerLDLocal, + LdDefaultContexts, + MethodNames, + SphereonEcdsaSecp256k1RecoverySignature2020, + SphereonEd25519Signature2018, + SphereonEd25519Signature2020, +} from '@sphereon/ssi-sdk.vc-handler-ld-local' +// @ts-ignore +import nock from 'nock' +import { StatusListDriverType, StatusListType } from '@sphereon/ssi-types' +import { JwtService } from '@sphereon/ssi-sdk-ext.jwt-service' + +jest.setTimeout(100000) + +describe('Status list', () => { + let didKeyIdentifier: IIdentifier + let agent: TAgent + + // jest.setTimeout(1000000) + beforeAll(async () => { + agent = createAgent({ + plugins: [ + new SphereonKeyManager({ + store: new MemoryKeyStore(), + kms: { + local: new SphereonKeyManagementSystem(new MemoryPrivateKeyStore()), + }, + }), + new DIDManager({ + providers: { + 'did:key': new SphereonKeyDidProvider({ defaultKms: 'local' }), + }, + store: new MemoryDIDStore(), + defaultProvider: 'did:key', + }), + new IdentifierResolution({ crypto: global.crypto }), + new JwtService(), + new DIDResolverPlugin({ + resolver: new Resolver({ + ...getDidKeyResolver(), + }), + }), + new CredentialPlugin(), + new CredentialHandlerLDLocal({ + contextMaps: [LdDefaultContexts], + suites: [new SphereonEd25519Signature2018(), new SphereonEd25519Signature2020(), new SphereonEcdsaSecp256k1RecoverySignature2020()], + bindingOverrides: new Map([ + // Bindings to test overrides of credential-ld plugin methods + ['createVerifiableCredentialLD', MethodNames.createVerifiableCredentialLDLocal], + ['createVerifiablePresentationLD', MethodNames.createVerifiablePresentationLDLocal], + // We test the verify methods by using the LDLocal versions directly in the tests + ]), + }), + ], + }) + didKeyIdentifier = await agent.didManagerCreate() + }) + + describe('StatusList2021', () => { + it('should create and update using LD-Signatures', async () => { + const statusList = await createNewStatusList( + { + type: StatusListType.StatusList2021, + proofFormat: 'lds', + id: 'http://localhost:9543/list1', + issuer: didKeyIdentifier.did, + length: 99999, + correlationId: 'test-1-' + Date.now(), + statusList2021: { + indexingDirection: 'rightToLeft', + }, + }, + { agent }, + ) + expect(statusList.type).toBe(StatusListType.StatusList2021) + expect(statusList.proofFormat).toBe('lds') + expect(statusList.statusList2021?.indexingDirection).toBe('rightToLeft') + + const updated = await updateStatusIndexFromStatusListCredential( + { statusListCredential: statusList.statusListCredential, statusListIndex: 2, value: Status2021.Invalid }, + { agent }, + ) + const status = await checkStatusIndexFromStatusListCredential({ + statusListCredential: updated.statusListCredential, + statusListIndex: '2', + }) + expect(status).toBe(Status2021.Invalid) + }) + + it('should create and update using JWT format', async () => { + const statusList = await createNewStatusList( + { + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + id: 'http://localhost:9543/list2', + issuer: didKeyIdentifier.did, + length: 99999, + correlationId: 'test-2-' + Date.now(), + statusList2021: { + indexingDirection: 'rightToLeft', + }, + }, + { agent }, + ) + + const updated = await updateStatusIndexFromStatusListCredential( + { statusListCredential: statusList.statusListCredential, statusListIndex: 3, value: Status2021.Invalid }, + { agent }, + ) + const status = await checkStatusIndexFromStatusListCredential({ + statusListCredential: updated.statusListCredential, + statusListIndex: '3', + }) + expect(status).toBe(Status2021.Invalid) + }) + }) + + describe('OAuthStatusList', () => { + it('should create and update using JWT format', async () => { + const statusList = await createNewStatusList( + { + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + id: 'http://localhost:9543/oauth1', + issuer: didKeyIdentifier.did, + length: 99999, + correlationId: 'test-3-' + Date.now(), + oauthStatusList: { + bitsPerStatus: 2, + }, + }, + { agent }, + ) + + const updated = await updateStatusIndexFromStatusListCredential( + { statusListCredential: statusList.statusListCredential, statusListIndex: 4, value: StatusOAuth.Invalid }, + { agent }, + ) + const status = await checkStatusIndexFromStatusListCredential({ + statusListCredential: updated.statusListCredential, + statusListIndex: '4', + }) + expect(status).toBe(StatusOAuth.Invalid) + }) + + it('should create and update using CBOR format', async () => { + const statusList = await createNewStatusList( + { + type: StatusListType.OAuthStatusList, + proofFormat: 'cbor', + id: 'http://localhost:9543/oauth3', + issuer: didKeyIdentifier.did, + length: 99999, + correlationId: 'test-6-' + Date.now(), + oauthStatusList: { + bitsPerStatus: 2, + }, + }, + { agent }, + ) + + const updated = await updateStatusIndexFromStatusListCredential( + { + statusListCredential: statusList.statusListCredential, + statusListIndex: 5, + value: StatusOAuth.Suspended, + }, + { agent }, + ) + const status = await checkStatusIndexFromStatusListCredential({ + statusListCredential: updated.statusListCredential, + statusListIndex: '5', + }) + expect(status).toBe(StatusOAuth.Suspended) + }) + + it('should reject LD-Signatures format', async () => { + await expect( + createNewStatusList( + { + type: StatusListType.OAuthStatusList, + proofFormat: 'lds', + id: 'http://localhost:9543/oauth2', + correlationId: 'test-4-' + Date.now(), + issuer: didKeyIdentifier.did, + length: 99999, + oauthStatusList: { + bitsPerStatus: 2, + }, + }, + { agent }, + ), + ).rejects.toThrow("Invalid proof format 'lds' for OAuthStatusList") + }) + }) + + describe('updateStatusListIndexFromEncodedList', () => { + it('should update StatusList2021 using encoded list', async () => { + // First create a status list to get valid encoded list + const initialList = await createNewStatusList( + { + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + id: 'http://localhost:9543/encoded1', + correlationId: 'test-5-' + Date.now(), + issuer: didKeyIdentifier.did, + length: 1000, + statusList2021: { + indexingDirection: 'rightToLeft', + }, + }, + { agent }, + ) + + const result = await updateStatusListIndexFromEncodedList( + { + type: StatusListType.StatusList2021, + statusListIndex: 1, + value: true, + proofFormat: 'jwt', + issuer: didKeyIdentifier.did, + id: 'http://localhost:9543/encoded1', + encodedList: initialList.encodedList, + statusList2021: { + statusPurpose: 'revocation', + }, + }, + { agent }, + ) + + expect(result.type).toBe(StatusListType.StatusList2021) + expect(result.encodedList).toBeDefined() + expect(result.statusListCredential).toBeDefined() + }) + + it('should update OAuthStatusList using encoded list', async () => { + const initialList = await createNewStatusList( + { + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + id: 'http://localhost:9543/encoded2', + correlationId: 'test-6-' + Date.now(), + issuer: didKeyIdentifier.did, + length: 1000, + oauthStatusList: { + bitsPerStatus: 2, + }, + }, + { agent }, + ) + + const result = await updateStatusListIndexFromEncodedList( + { + type: StatusListType.OAuthStatusList, + statusListIndex: 1, + value: true, + proofFormat: 'jwt', + issuer: didKeyIdentifier.did, + id: 'http://localhost:9543/encoded2', + encodedList: initialList.encodedList, + oauthStatusList: { + bitsPerStatus: 2, + }, + }, + { agent }, + ) + + expect(result.type).toBe(StatusListType.OAuthStatusList) + expect(result.oauthStatusList?.bitsPerStatus).toBe(2) + }) + }) + + describe('statusList2021ToVerifiableCredential', () => { + it('should create VC with string issuer', async () => { + const result = await statusList2021ToVerifiableCredential( + { + issuer: didKeyIdentifier.did, + id: 'http://localhost:9543/sl1', + encodedList: 'H4sIAAAAAAAAA2NgwA8YgYARiEFEMxBzAbEMEEsAsQAQswExIxADAHPnBI8QAAAA', + statusPurpose: 'revocation', + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + }, + { agent }, + ) + + expect(result).toBeDefined() + expect(typeof result === 'string' || 'proof' in result).toBeTruthy() + }) + + it('should create VC with issuer object', async () => { + const result = await statusList2021ToVerifiableCredential( + { + issuer: { id: didKeyIdentifier.did }, + id: 'http://localhost:9543/sl2', + encodedList: 'H4sIAAAAAAAAA2NgwA8YgYARiEFEMxBzAbEMEEsAsQAQswExIxADAHPnBI8QAAAA', + statusPurpose: 'revocation', + type: StatusListType.StatusList2021, + proofFormat: 'lds', + }, + { agent }, + ) + + if (typeof result === 'string') { + expect(result).toMatch(/^ey/) // JWT format starts with 'ey' + } else { + expect(result).toHaveProperty('proof') + } + }) + + it('should throw error for missing required fields', async () => { + await expect( + statusList2021ToVerifiableCredential( + { + issuer: didKeyIdentifier.did, + id: 'test', + encodedList: 'test', + } as any, + { agent }, + ), + ).rejects.toThrow() + }) + }) + + describe('statusListCredentialToDetails', () => { + it('should handle StatusList2021 JWT credential', async () => { + const initialList = await createNewStatusList( + { + type: StatusListType.StatusList2021, + proofFormat: 'jwt', + id: 'http://localhost:9543/details1', + issuer: didKeyIdentifier.did, + length: 1000, + correlationId: 'test-details-1', + statusList2021: { + indexingDirection: 'rightToLeft', + }, + }, + { agent }, + ) + + const details = await statusListCredentialToDetails({ + statusListCredential: initialList.statusListCredential, + correlationId: 'test-details-1', + driverType: StatusListDriverType.AGENT_TYPEORM, + }) + + expect(details.type).toBe(StatusListType.StatusList2021) + expect(details.proofFormat).toBe('jwt') + expect(details.correlationId).toBe('test-details-1') + expect(details.driverType).toBe(StatusListDriverType.AGENT_TYPEORM) + expect(details.statusList2021?.indexingDirection).toBe('rightToLeft') + }) + + it('should handle OAuthStatusList credential', async () => { + const initialList = await createNewStatusList( + { + type: StatusListType.OAuthStatusList, + proofFormat: 'jwt', + id: 'http://localhost:9543/details2', + issuer: didKeyIdentifier.did, + length: 1000, + correlationId: 'test-details-2', + oauthStatusList: { + bitsPerStatus: 2, + expiresAt: new Date('2025-01-01'), + }, + }, + { agent }, + ) + + const details = await statusListCredentialToDetails({ + statusListCredential: initialList.statusListCredential, + correlationId: 'test-details-2', + }) + + expect(details.type).toBe(StatusListType.OAuthStatusList) + expect(details.proofFormat).toBe('jwt') + expect(details.correlationId).toBe('test-details-2') + expect(details.oauthStatusList?.bitsPerStatus).toBe(2) + expect(details.oauthStatusList?.expiresAt).toEqual(new Date('2025-01-01')) + }) + + it('should handle OAuthStatusList with CBOR format', async () => { + const initialList = await createNewStatusList( + { + type: StatusListType.OAuthStatusList, + proofFormat: 'cbor', + id: 'http://localhost:9543/details3', + issuer: didKeyIdentifier.did, + length: 1000, + correlationId: 'test-details-3', + oauthStatusList: { + bitsPerStatus: 2, + expiresAt: new Date('2025-01-01'), + }, + }, + { agent }, + ) + + const details = await statusListCredentialToDetails({ + statusListCredential: initialList.statusListCredential, + correlationId: 'test-details-3', + }) + + expect(details.type).toBe(StatusListType.OAuthStatusList) + expect(details.proofFormat).toBe('cbor') + expect(details.correlationId).toBe('test-details-3') + expect(details.oauthStatusList?.bitsPerStatus).toBe(2) + expect(details.oauthStatusList?.expiresAt).toEqual(new Date('2025-01-01')) + }) + }) +}) diff --git a/packages/vc-status-list-tests/package.json b/packages/vc-status-list-tests/package.json new file mode 100644 index 000000000..caa852384 --- /dev/null +++ b/packages/vc-status-list-tests/package.json @@ -0,0 +1,53 @@ +{ + "name": "@sphereon/vc-status-list-tests", + "version": "0.32.0", + "scripts": { + "build": "tsc", + "build:clean": "tsc --build --clean && tsc --build" + }, + "dependencies": { + }, + "devDependencies": { + "@types/jest": "^27.5.2", + "@types/node": "^20.17.1", + "@sphereon/ssi-sdk.vc-handler-ld-local": "workspace:*", + "@sphereon/ssi-sdk.vc-status-list": "workspace:*", + "@sphereon/ssi-types": "workspace:*", + "@sphereon/ssi-sdk-ext.identifier-resolution": "0.27.0", + "@sphereon/ssi-sdk-ext.jwt-service": "0.27.0", + "@sphereon/ssi-sdk-ext.key-manager": "0.27.0", + "@sphereon/ssi-sdk-ext.kms-local": "0.27.0", + "@sphereon/ssi-sdk-ext.did-provider-key": "0.27.0", + "did-resolver": "^4.1.0", + "@veramo/core": "4.2.0", + "@veramo/credential-w3c": "4.2.0", + "@veramo/did-manager": "4.2.0", + "@veramo/key-manager": "4.2.0", + "@veramo/did-resolver": "4.2.0", + "nock": "^13.5.4", + "ts-node": "^10.9.2", + "typescript": "5.6.3" + }, + "files": [ + ], + "private": true, + "publishConfig": { + "access": "public" + }, + "repository": "git@github.com:Sphereon-Opensource/SSI-SDK.git", + "author": "Sphereon ", + "license": "Apache-2.0", + "keywords": [ + "Sphereon", + "SSI", + "Veramo", + "W3C", + "Verifiable Credentials", + "Verifiable Presentations", + "JsonLd" + ], + "peerDependencies": { + "react-native-securerandom": "^1.0.1" + }, + "nx": {} +} diff --git a/packages/vc-status-list-tests/tsconfig.json b/packages/vc-status-list-tests/tsconfig.json new file mode 100644 index 000000000..81b4960b4 --- /dev/null +++ b/packages/vc-status-list-tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "rootDir": "src", + "outDir": "dist", + "declarationDir": "dist" + }, + "references": [ + { + "path": "../ssi-types", + "path": "../vc-status-list", + "path": "../vc-handler-ld-local" + } + ], + "extends": "../tsconfig-base.json" +} diff --git a/packages/vc-status-list/package.json b/packages/vc-status-list/package.json index b420f1d63..0d2a8a6f9 100644 --- a/packages/vc-status-list/package.json +++ b/packages/vc-status-list/package.json @@ -10,22 +10,30 @@ "build:clean": "tsc --build --clean && tsc --build" }, "dependencies": { + "@sd-jwt/jwt-status-list": "^0.9.1", "@sphereon/ssi-sdk-ext.did-utils": "0.27.0", "@sphereon/ssi-sdk-ext.identifier-resolution": "0.27.0", + "@sphereon/ssi-sdk-ext.jwt-service": "0.27.0", "@sphereon/ssi-types": "workspace:*", "@sphereon/vc-status-list": "7.0.0-next.0", + "@sphereon/kmp-cbor": "0.2.0-SNAPSHOT.25", "@veramo/core": "4.2.0", "@veramo/credential-status": "4.2.0", + "base64url": "^3.0.1", "credential-status": "^2.0.6", + "jwt-decode": "^4.0.0", "debug": "^4.3.5", "typeorm": "^0.3.20", - "uint8arrays": "^3.1.1" + "uint8arrays": "^3.1.1", + "pako": "^2.1.0" }, "devDependencies": { "@babel/cli": "^7.24.8", "@babel/core": "^7.24.9", "@babel/preset-env": "^7.24.8", "@babel/preset-typescript": "^7.24.7", + "@veramo/key-manager": "4.2.0", + "@types/pako": "2.0.3", "typescript": "5.4.2" }, "files": [ diff --git a/packages/vc-status-list/src/functions.ts b/packages/vc-status-list/src/functions.ts index 3a8fd594d..97b4c422b 100644 --- a/packages/vc-status-list/src/functions.ts +++ b/packages/vc-status-list/src/functions.ts @@ -2,40 +2,42 @@ import { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resoluti import { CredentialMapper, DocumentFormat, - IIssuer, - OriginalVerifiableCredential, + ProofFormat, + StatusListCredential, StatusListDriverType, StatusListType, StatusPurpose2021, } from '@sphereon/ssi-types' +import { CredentialStatus, DIDDocument, IAgentContext, ICredentialPlugin, ProofFormat as VeramoProofFormat } from '@veramo/core' -import { checkStatus, StatusList } from '@sphereon/vc-status-list' -import { CredentialStatus, DIDDocument, IAgentContext, ICredentialPlugin, ProofFormat } from '@veramo/core' +import { checkStatus } from '@sphereon/vc-status-list' import { CredentialJwtOrJSON, StatusMethod } from 'credential-status' import { CreateNewStatusListFuncArgs, + Status2021, StatusList2021ToVerifiableCredentialArgs, - StatusListDetails, StatusListResult, + StatusOAuth, UpdateStatusListFromEncodedListArgs, - UpdateStatusListFromStatusListCredentialArgs, + UpdateStatusListIndexArgs, } from './types' +import { assertValidProofType, determineStatusListType, getAssertedValue, getAssertedValues } from './utils' +import { getStatusListImplementation } from './impl/StatusListFactory' -export async function fetchStatusListCredential(args: { statusListCredential: string }): Promise { +export async function fetchStatusListCredential(args: { statusListCredential: string }): Promise { const url = getAssertedValue('statusListCredential', args.statusListCredential) try { const response = await fetch(url) if (!response.ok) { - const error = `Fetching status list ${url} resulted in an error: ${response.status} : ${response.statusText}` - throw Error(error) + throw Error(`Fetching status list ${url} resulted in an error: ${response.status} : ${response.statusText}`) } const responseAsText = await response.text() if (responseAsText.trim().startsWith('{')) { - return JSON.parse(responseAsText) as OriginalVerifiableCredential + return JSON.parse(responseAsText) as StatusListCredential } - return responseAsText as OriginalVerifiableCredential + return responseAsText as StatusListCredential } catch (error) { - console.log(`Fetching status list ${url} resulted in an unexpected error: ${error instanceof Error ? error.message : JSON.stringify(error)}`) + console.error(`Fetching status list ${url} resulted in an unexpected error: ${error instanceof Error ? error.message : JSON.stringify(error)}`) throw error } } @@ -52,7 +54,7 @@ export function statusPluginStatusFunction(args: { const result = await checkStatusForCredential({ ...args, documentLoader: args.documentLoader, - credential: credential as OriginalVerifiableCredential, + credential: credential as StatusListCredential, errorUnknownListType: args.errorUnknownListType, }) @@ -75,7 +77,7 @@ export function vcLibCheckStatusFunction(args: { }) { const { mandatoryCredentialStatus, verifyStatusListCredential, verifyMatchingIssuers, errorUnknownListType } = args return (args: { - credential: OriginalVerifiableCredential + credential: StatusListCredential documentLoader: any suite: any }): Promise<{ @@ -93,7 +95,7 @@ export function vcLibCheckStatusFunction(args: { } export async function checkStatusForCredential(args: { - credential: OriginalVerifiableCredential + credential: StatusListCredential documentLoader: any suite: any mandatoryCredentialStatus?: boolean @@ -132,7 +134,7 @@ export async function simpleCheckStatusFromStatusListUrl(args: { type?: StatusListType | 'StatusList2021Entry' id?: string statusListIndex: string -}): Promise { +}): Promise { return checkStatusIndexFromStatusListCredential({ ...args, statusListCredential: await fetchStatusListCredential(args), @@ -140,163 +142,97 @@ export async function simpleCheckStatusFromStatusListUrl(args: { } export async function checkStatusIndexFromStatusListCredential(args: { - statusListCredential: OriginalVerifiableCredential + statusListCredential: StatusListCredential statusPurpose?: StatusPurpose2021 type?: StatusListType | 'StatusList2021Entry' id?: string statusListIndex: string | number -}): Promise { - const requestedType = getAssertedStatusListType(args.type?.replace('Entry', '') as StatusListType) - const uniform = CredentialMapper.toUniformCredential(args.statusListCredential) - const { issuer, type, credentialSubject, id } = uniform - getAssertedValue('issuer', issuer) // We are only checking the value here - getAssertedValue('credentialSubject', credentialSubject) - if (args.statusPurpose && 'statusPurpose' in credentialSubject) { - if (args.statusPurpose !== credentialSubject.statusPurpose) { - throw Error( - `Status purpose in StatusList credential with id ${id} and value ${credentialSubject.statusPurpose} does not match supplied purpose: ${args.statusPurpose}`, - ) - } - } else if (args.id && args.id !== id) { - throw Error(`Status list id ${id} did not match required supplied id: ${args.id}`) - } - if (!type || !(type.includes(requestedType) || type.includes(requestedType + 'Credential'))) { - throw Error(`Credential type ${JSON.stringify(type)} does not contain requested type ${requestedType}`) - } - // @ts-ignore - const encodedList = getAssertedValue('encodedList', credentialSubject['encodedList']) - - const statusList = await StatusList.decode({ encodedList }) - const status = statusList.getStatus(typeof args.statusListIndex === 'number' ? args.statusListIndex : Number.parseInt(args.statusListIndex)) - return status +}): Promise { + const statusListType: StatusListType = determineStatusListType(args.statusListCredential) + const implementation = getStatusListImplementation(statusListType) + return implementation.checkStatusIndex(args) } export async function createNewStatusList( args: CreateNewStatusListFuncArgs, context: IAgentContext, ): Promise { - const length = args?.length ?? 250000 - const proofFormat = args?.proofFormat ?? 'lds' - const { issuer, type, id } = getAssertedValues(args) - const correlationId = getAssertedValue('correlationId', args.correlationId) - - const list = new StatusList({ length }) - const encodedList = await list.encode() - const statusPurpose = args.statusPurpose ?? 'revocation' - const statusListCredential = await statusList2021ToVerifiableCredential( - { - ...args, - type, - proofFormat, - encodedList, - }, - context, - ) - - return { - encodedList, - statusListCredential, - length, - type, - proofFormat, - id, - correlationId, - issuer, - statusPurpose, - indexingDirection: 'rightToLeft', - } as StatusListResult + const { type } = getAssertedValues(args) + const implementation = getStatusListImplementation(type) + return implementation.createNewStatusList(args, context) } export async function updateStatusIndexFromStatusListCredential( - args: UpdateStatusListFromStatusListCredentialArgs, + args: UpdateStatusListIndexArgs, context: IAgentContext, -): Promise { - return updateStatusListIndexFromEncodedList( - { - ...(await statusListCredentialToDetails(args)), - statusListIndex: args.statusListIndex, - value: args.value, - }, - context, - ) +): Promise { + const credential = getAssertedValue('statusListCredential', args.statusListCredential) + const statusListType: StatusListType = determineStatusListType(credential) + const implementation = getStatusListImplementation(statusListType) + return implementation.updateStatusListIndex(args, context) } +// Keeping helper function for backward compatibility export async function statusListCredentialToDetails(args: { - statusListCredential: OriginalVerifiableCredential + statusListCredential: StatusListCredential correlationId?: string driverType?: StatusListDriverType -}): Promise { +}): Promise { const credential = getAssertedValue('statusListCredential', args.statusListCredential) - const uniform = CredentialMapper.toUniformCredential(credential) - const { issuer, type, credentialSubject } = uniform - if (!type.includes('StatusList2021Credential')) { - throw Error('StatusList2021Credential type should be present in the Verifiable Credential') + + let statusListType: StatusListType | undefined + const documentFormat = CredentialMapper.detectDocumentType(credential) + if (documentFormat === DocumentFormat.JWT) { + const [header] = credential.split('.') + const decodedHeader = JSON.parse(Buffer.from(header, 'base64').toString()) + + if (decodedHeader.typ === 'statuslist+jwt') { + statusListType = StatusListType.OAuthStatusList + } + } else if (documentFormat === DocumentFormat.MSO_MDOC) { + statusListType = StatusListType.OAuthStatusList + // TODO check CBOR content? } - const id = getAssertedValue('id', uniform.id) - // @ts-ignore - const { encodedList, statusPurpose } = credentialSubject - const proofFormat: ProofFormat = CredentialMapper.detectDocumentType(credential) === DocumentFormat.JWT ? 'jwt' : 'lds' - return { - id, - encodedList, - issuer, - type: StatusListType.StatusList2021, - proofFormat, - indexingDirection: 'rightToLeft', - length: (await StatusList.decode({ encodedList })).length, - statusPurpose, - statusListCredential: credential, - ...(args.correlationId && { correlationId: args.correlationId }), - ...(args.driverType && { driverType: args.driverType }), + if (!statusListType) { + const uniform = CredentialMapper.toUniformCredential(credential) + const type = uniform.type.find((t) => t.includes('StatusList2021') || t.includes('OAuth2StatusList')) + if (!type) { + throw new Error('Invalid status list credential type') + } + statusListType = type.replace('Credential', '') as StatusListType } + + const implementation = getStatusListImplementation(statusListType) + return await implementation.toStatusListDetails({ + statusListPayload: credential, + correlationId: args.correlationId, + driverType: args.driverType, + }) } export async function updateStatusListIndexFromEncodedList( args: UpdateStatusListFromEncodedListArgs, context: IAgentContext, -): Promise { - const { issuer, type, id } = getAssertedValues(args) - const proofFormat = args?.proofFormat ?? 'lds' - const origEncodedList = getAssertedValue('encodedList', args.encodedList) - const index = getAssertedValue('index', typeof args.statusListIndex === 'number' ? args.statusListIndex : Number.parseInt(args.statusListIndex)) - const value = getAssertedValue('value', args.value) - const statusPurpose = getAssertedValue('statusPurpose', args.statusPurpose) - - const statusList = await StatusList.decode({ encodedList: origEncodedList }) - statusList.setStatus(index, value) - const encodedList = await statusList.encode() - const statusListCredential = await statusList2021ToVerifiableCredential( - { - ...args, - type, - proofFormat, - encodedList, - }, - context, - ) - return { - encodedList, - statusListCredential, - length: statusList.length - 1, - type, - proofFormat, - id, - issuer, - statusPurpose, - indexingDirection: 'rightToLeft', - } +): Promise { + const { type } = getAssertedValue('type', args) + const implementation = getStatusListImplementation(type!) + return implementation.updateStatusListFromEncodedList(args, context) } export async function statusList2021ToVerifiableCredential( args: StatusList2021ToVerifiableCredentialArgs, context: IAgentContext, -): Promise { +): Promise { const { issuer, id, type } = getAssertedValues(args) const identifier = await context.agent.identifierManagedGet({ identifier: typeof issuer === 'string' ? issuer : issuer.id, vmRelationship: 'assertionMethod', offlineWhenNoDIDRegistered: true, // FIXME Fix identifier resolution for EBSI }) + const proofFormat: ProofFormat = args?.proofFormat ?? 'lds' + assertValidProofType(StatusListType.StatusList2021, proofFormat) + const veramoProofFormat: VeramoProofFormat = proofFormat as VeramoProofFormat + const encodedList = getAssertedValue('encodedList', args.encodedList) const statusPurpose = getAssertedValue('statusPurpose', args.statusPurpose) const credential = { @@ -316,31 +252,9 @@ export async function statusList2021ToVerifiableCredential( const verifiableCredential = await context.agent.createVerifiableCredential({ credential, keyRef: identifier.kmsKeyRef, - proofFormat: args.proofFormat ?? 'lds', + proofFormat: veramoProofFormat, fetchRemoteContexts: true, }) - return CredentialMapper.toWrappedVerifiableCredential(verifiableCredential as OriginalVerifiableCredential).original -} - -function getAssertedStatusListType(type?: StatusListType) { - const assertedType = type ?? StatusListType.StatusList2021 - if (assertedType !== StatusListType.StatusList2021) { - throw Error(`StatusList type ${assertedType} is not supported (yet)`) - } - return assertedType -} - -function getAssertedValue(name: string, value: T): NonNullable { - if (value === undefined || value === null) { - throw Error(`Missing required ${name} value`) - } - return value -} - -function getAssertedValues(args: { issuer: string | IIssuer; id: string; type?: StatusListType }) { - const type = getAssertedStatusListType(args?.type) - const id = getAssertedValue('id', args.id) - const issuer = getAssertedValue('issuer', args.issuer) - return { id, issuer, type } + return CredentialMapper.toWrappedVerifiableCredential(verifiableCredential as StatusListCredential).original as StatusListCredential } diff --git a/packages/vc-status-list/src/impl/IStatusList.ts b/packages/vc-status-list/src/impl/IStatusList.ts new file mode 100644 index 000000000..9bbf0092b --- /dev/null +++ b/packages/vc-status-list/src/impl/IStatusList.ts @@ -0,0 +1,42 @@ +import { IAgentContext, ICredentialPlugin } from '@veramo/core' +import { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution' +import { + CheckStatusIndexArgs, + CreateStatusListArgs, + Status2021, + StatusListResult, + StatusOAuth, + ToStatusListDetailsArgs, + UpdateStatusListFromEncodedListArgs, + UpdateStatusListIndexArgs, +} from '../types' + +export interface IStatusList { + /** + * Creates a new status list of the specific type + */ + createNewStatusList(args: CreateStatusListArgs, context: IAgentContext): Promise + + /** + * Updates a status at the given index in the status list + */ + updateStatusListIndex(args: UpdateStatusListIndexArgs, context: IAgentContext): Promise + + /** + * Updates a status list using a base64 encoded list of statuses + */ + updateStatusListFromEncodedList( + args: UpdateStatusListFromEncodedListArgs, + context: IAgentContext, + ): Promise + + /** + * Checks the status at a given index in the status list + */ + checkStatusIndex(args: CheckStatusIndexArgs): Promise + + /** + * Collects the status list details + */ + toStatusListDetails(args: ToStatusListDetailsArgs): Promise +} diff --git a/packages/vc-status-list/src/impl/OAuthStatusList.ts b/packages/vc-status-list/src/impl/OAuthStatusList.ts new file mode 100644 index 000000000..66615aecd --- /dev/null +++ b/packages/vc-status-list/src/impl/OAuthStatusList.ts @@ -0,0 +1,196 @@ +import { IAgentContext, ICredentialPlugin, IKeyManager } from '@veramo/core' +import { CompactJWT, CWT, ProofFormat, StatusListType } from '@sphereon/ssi-types' +import { + CheckStatusIndexArgs, + CreateStatusListArgs, + SignedStatusListData, + StatusListResult, + StatusOAuth, + ToStatusListDetailsArgs, + UpdateStatusListFromEncodedListArgs, + UpdateStatusListIndexArgs, +} from '../types' +import { determineProofFormat, getAssertedValue, getAssertedValues } from '../utils' +import { IStatusList } from './IStatusList' +import { StatusList } from '@sd-jwt/jwt-status-list' +import { IJwtService } from '@sphereon/ssi-sdk-ext.jwt-service' +import { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution' +import { createSignedJwt, decodeStatusListJWT } from './encoding/jwt' +import { createSignedCbor, decodeStatusListCWT } from './encoding/cbor' + +type IRequiredContext = IAgentContext + +export const DEFAULT_BITS_PER_STATUS = 2 // 2 bits are sufficient for 0x00 - "VALID" 0x01 - "INVALID" & 0x02 - "SUSPENDED" +export const DEFAULT_LIST_LENGTH = 250000 +export const DEFAULT_PROOF_FORMAT = 'jwt' as ProofFormat + +export class OAuthStatusListImplementation implements IStatusList { + async createNewStatusList(args: CreateStatusListArgs, context: IRequiredContext): Promise { + if (!args.oauthStatusList) { + throw new Error('OAuthStatusList options are required for type OAuthStatusList') + } + + const proofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT + const { issuer, id, oauthStatusList, keyRef } = args + const { bitsPerStatus, expiresAt } = oauthStatusList + const length = args.length ?? DEFAULT_LIST_LENGTH + const issuerString = typeof issuer === 'string' ? issuer : issuer.id + const correlationId = getAssertedValue('correlationId', args.correlationId) + + const statusList = new StatusList(new Array(length).fill(0), bitsPerStatus ?? DEFAULT_BITS_PER_STATUS) + const encodedList = statusList.compressStatusList() + const { statusListCredential } = await this.createSignedStatusList(proofFormat, context, statusList, issuerString, id, expiresAt, keyRef) + + return { + encodedList, + statusListCredential, + oauthStatusList: { bitsPerStatus }, + length, + type: StatusListType.OAuthStatusList, + proofFormat, + id, + correlationId, + issuer, + } + } + + async updateStatusListIndex(args: UpdateStatusListIndexArgs, context: IRequiredContext): Promise { + const { statusListCredential, value, expiresAt, keyRef } = args + if (typeof statusListCredential !== 'string') { + return Promise.reject('statusListCredential in neither JWT nor CWT') + } + + const proofFormat = determineProofFormat(statusListCredential) + const decoded = proofFormat === 'jwt' ? decodeStatusListJWT(statusListCredential) : decodeStatusListCWT(statusListCredential) + const { statusList, issuer, id } = decoded + + const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex) + if (index < 0 || index >= statusList.statusList.length) { + throw new Error('Status list index out of bounds') + } + + statusList.setStatus(index, value) + const { statusListCredential: signedCredential, encodedList } = await this.createSignedStatusList( + proofFormat, + context, + statusList, + issuer, + id, + expiresAt, + keyRef, + ) + + return { + statusListCredential: signedCredential, + encodedList, + oauthStatusList: { + bitsPerStatus: statusList.getBitsPerStatus(), + }, + length: statusList.statusList.length, + type: StatusListType.OAuthStatusList, + proofFormat, + id, + issuer, + } + } + + async updateStatusListFromEncodedList(args: UpdateStatusListFromEncodedListArgs, context: IRequiredContext): Promise { + if (!args.oauthStatusList) { + throw new Error('OAuthStatusList options are required for type OAuthStatusList') + } + const { proofFormat, oauthStatusList, keyRef } = args + const { bitsPerStatus, expiresAt } = oauthStatusList + + const { issuer, id } = getAssertedValues(args) + const issuerString = typeof issuer === 'string' ? issuer : issuer.id + + const listToUpdate = StatusList.decompressStatusList(args.encodedList, bitsPerStatus ?? DEFAULT_BITS_PER_STATUS) + const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex) + listToUpdate.setStatus(index, args.value ? 1 : 0) + + const { statusListCredential, encodedList } = await this.createSignedStatusList( + proofFormat ?? DEFAULT_PROOF_FORMAT, + context, + listToUpdate, + issuerString, + id, + expiresAt, + keyRef, + ) + + return { + encodedList, + statusListCredential, + oauthStatusList: { + bitsPerStatus, + expiresAt, + }, + length: listToUpdate.statusList.length, + type: StatusListType.OAuthStatusList, + proofFormat: proofFormat ?? DEFAULT_PROOF_FORMAT, + id, + issuer, + } + } + + async checkStatusIndex(args: CheckStatusIndexArgs): Promise { + const { statusListCredential, statusListIndex } = args + if (typeof statusListCredential !== 'string') { + return Promise.reject('statusListCredential in neither JWT nor CWT') + } + + const proofFormat = determineProofFormat(statusListCredential) + const { statusList } = proofFormat === 'jwt' ? decodeStatusListJWT(statusListCredential) : decodeStatusListCWT(statusListCredential) + + const index = typeof statusListIndex === 'number' ? statusListIndex : parseInt(statusListIndex) + if (index < 0 || index >= statusList.statusList.length) { + throw new Error('Status list index out of bounds') + } + + return statusList.getStatus(index) + } + + async toStatusListDetails(args: ToStatusListDetailsArgs): Promise { + const { statusListPayload } = args as { statusListPayload: CompactJWT | CWT } + const proofFormat = determineProofFormat(statusListPayload) + const decoded = proofFormat === 'jwt' ? decodeStatusListJWT(statusListPayload) : decodeStatusListCWT(statusListPayload) + const { statusList, issuer, id, exp } = decoded + + return { + id, + encodedList: statusList.compressStatusList(), + issuer, + type: StatusListType.OAuthStatusList, + proofFormat, + length: statusList.statusList.length, + statusListCredential: statusListPayload, + oauthStatusList: { + bitsPerStatus: statusList.getBitsPerStatus(), + ...(exp && { expiresAt: new Date(exp * 1000) }), + }, + ...(args.correlationId && { correlationId: args.correlationId }), + ...(args.driverType && { driverType: args.driverType }), + } + } + + private async createSignedStatusList( + proofFormat: 'jwt' | 'lds' | 'EthereumEip712Signature2021' | 'cbor', + context: IAgentContext, + statusList: StatusList, + issuerString: string, + id: string, + expiresAt?: Date, + keyRef?: string, + ): Promise { + switch (proofFormat) { + case 'jwt': { + return await createSignedJwt(context, statusList, issuerString, id, expiresAt, keyRef) + } + case 'cbor': { + return await createSignedCbor(context, statusList, issuerString, id, expiresAt, keyRef) + } + default: + throw new Error(`Invalid proof format '${proofFormat}' for OAuthStatusList`) + } + } +} diff --git a/packages/vc-status-list/src/impl/StatusList2021.ts b/packages/vc-status-list/src/impl/StatusList2021.ts new file mode 100644 index 000000000..3277c3369 --- /dev/null +++ b/packages/vc-status-list/src/impl/StatusList2021.ts @@ -0,0 +1,223 @@ +import { IAgentContext, ICredentialPlugin, ProofFormat as VeramoProofFormat } from '@veramo/core' +import { IIdentifierResolution } from '@sphereon/ssi-sdk-ext.identifier-resolution' +import { CredentialMapper, DocumentFormat, IIssuer, ProofFormat, StatusListCredential, StatusListType } from '@sphereon/ssi-types' + +import { StatusList } from '@sphereon/vc-status-list' +import { IStatusList } from './IStatusList' +import { + CheckStatusIndexArgs, + CreateStatusListArgs, + Status2021, + StatusListResult, + ToStatusListDetailsArgs, + UpdateStatusListFromEncodedListArgs, + UpdateStatusListIndexArgs, +} from '../types' +import { assertValidProofType, getAssertedProperty, getAssertedValue, getAssertedValues } from '../utils' + +export const DEFAULT_LIST_LENGTH = 250000 +export const DEFAULT_PROOF_FORMAT = 'lds' as VeramoProofFormat + +export class StatusList2021Implementation implements IStatusList { + async createNewStatusList( + args: CreateStatusListArgs, + context: IAgentContext, + ): Promise { + const length = args?.length ?? DEFAULT_LIST_LENGTH + const proofFormat: ProofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT + assertValidProofType(StatusListType.StatusList2021, proofFormat) + const veramoProofFormat: VeramoProofFormat = proofFormat as VeramoProofFormat + + const { issuer, id } = args + const correlationId = getAssertedValue('correlationId', args.correlationId) + + const list = new StatusList({ length }) + const encodedList = await list.encode() + const statusPurpose = 'revocation' + + const statusListCredential = await this.createVerifiableCredential( + { + ...args, + encodedList, + proofFormat: veramoProofFormat, + }, + context, + ) + + return { + encodedList, + statusListCredential: statusListCredential, + statusList2021: { + statusPurpose, + indexingDirection: 'rightToLeft', + }, + length, + type: StatusListType.StatusList2021, + proofFormat, + id, + correlationId, + issuer, + } + } + + async updateStatusListIndex( + args: UpdateStatusListIndexArgs, + context: IAgentContext, + ): Promise { + const credential = args.statusListCredential + const uniform = CredentialMapper.toUniformCredential(credential) + const { issuer, credentialSubject } = uniform + const id = getAssertedValue('id', uniform.id) + const origEncodedList = getAssertedProperty('encodedList', credentialSubject) + + const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex) + const statusList = await StatusList.decode({ encodedList: origEncodedList }) + statusList.setStatus(index, args.value != 0) + const encodedList = await statusList.encode() + + const updatedCredential = await this.createVerifiableCredential( + { + ...args, + id, + issuer, + encodedList, + proofFormat: CredentialMapper.detectDocumentType(credential) === DocumentFormat.JWT ? 'jwt' : 'lds', + }, + context, + ) + + return { + statusListCredential: updatedCredential, + encodedList, + statusList2021: { + ...('statusPurpose' in credentialSubject ? { statusPurpose: credentialSubject.statusPurpose } : {}), + indexingDirection: 'rightToLeft', + }, + length: statusList.length - 1, + type: StatusListType.StatusList2021, + proofFormat: CredentialMapper.detectDocumentType(credential) === DocumentFormat.JWT ? 'jwt' : 'lds', + id, + issuer, + } + } + + async updateStatusListFromEncodedList( + args: UpdateStatusListFromEncodedListArgs, + context: IAgentContext, + ): Promise { + if (!args.statusList2021) { + throw new Error('statusList2021 options required for type StatusList2021') + } + const proofFormat: ProofFormat = args?.proofFormat ?? DEFAULT_PROOF_FORMAT + assertValidProofType(StatusListType.StatusList2021, proofFormat) + const veramoProofFormat: VeramoProofFormat = proofFormat as VeramoProofFormat + + const { issuer, id } = getAssertedValues(args) + const statusList = await StatusList.decode({ encodedList: args.encodedList }) + const index = typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex) + statusList.setStatus(index, args.value) + + const newEncodedList = await statusList.encode() + const credential = await this.createVerifiableCredential( + { + id, + issuer, + encodedList: newEncodedList, + proofFormat: veramoProofFormat, + keyRef: args.keyRef, + }, + context, + ) + + return { + type: StatusListType.StatusList2021, + statusListCredential: credential, + encodedList: newEncodedList, + statusList2021: { + statusPurpose: args.statusList2021.statusPurpose, + indexingDirection: 'rightToLeft', + }, + length: statusList.length, + proofFormat: args.proofFormat ?? 'lds', + id: id, + issuer: issuer, + } + } + + async checkStatusIndex(args: CheckStatusIndexArgs): Promise { + const uniform = CredentialMapper.toUniformCredential(args.statusListCredential) + const { credentialSubject } = uniform + const encodedList = getAssertedProperty('encodedList', credentialSubject) + + const statusList = await StatusList.decode({ encodedList }) + const status = statusList.getStatus(typeof args.statusListIndex === 'number' ? args.statusListIndex : parseInt(args.statusListIndex)) + return status ? Status2021.Invalid : Status2021.Valid + } + + async toStatusListDetails(args: ToStatusListDetailsArgs): Promise { + const { statusListPayload } = args + const uniform = CredentialMapper.toUniformCredential(statusListPayload) + const { issuer, credentialSubject } = uniform + const id = getAssertedValue('id', uniform.id) + const encodedList = getAssertedProperty('encodedList', credentialSubject) + const proofFormat: ProofFormat = CredentialMapper.detectDocumentType(statusListPayload) === DocumentFormat.JWT ? 'jwt' : 'lds' + + const statusPurpose = getAssertedProperty('statusPurpose', credentialSubject) + const list = await StatusList.decode({ encodedList }) + + return { + id, + encodedList, + issuer, + type: StatusListType.StatusList2021, + proofFormat, + length: list.length, + statusListCredential: statusListPayload, + statusList2021: { + indexingDirection: 'rightToLeft', + statusPurpose, + }, + ...(args.correlationId && { correlationId: args.correlationId }), + ...(args.driverType && { driverType: args.driverType }), + } + } + + private async createVerifiableCredential( + args: { + id: string + issuer: string | IIssuer + encodedList: string + proofFormat: VeramoProofFormat + keyRef?: string + }, + context: IAgentContext, + ): Promise { + const identifier = await context.agent.identifierManagedGet({ + identifier: typeof args.issuer === 'string' ? args.issuer : args.issuer.id, + vmRelationship: 'assertionMethod', + offlineWhenNoDIDRegistered: true, + }) + + const credential = { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://w3id.org/vc/status-list/2021/v1'], + id: args.id, + issuer: args.issuer, + type: ['VerifiableCredential', 'StatusList2021Credential'], + credentialSubject: { + id: args.id, + type: 'StatusList2021', + statusPurpose: 'revocation', + encodedList: args.encodedList, + }, + } + + const verifiableCredential = await context.agent.createVerifiableCredential({ + credential, + keyRef: args.keyRef ?? identifier.kmsKeyRef, + proofFormat: args.proofFormat, + fetchRemoteContexts: true, + }) + + return CredentialMapper.toWrappedVerifiableCredential(verifiableCredential as StatusListCredential).original as StatusListCredential + } +} diff --git a/packages/vc-status-list/src/impl/StatusListFactory.ts b/packages/vc-status-list/src/impl/StatusListFactory.ts new file mode 100644 index 000000000..b644e69a9 --- /dev/null +++ b/packages/vc-status-list/src/impl/StatusListFactory.ts @@ -0,0 +1,34 @@ +import { IStatusList } from './IStatusList' +import { StatusList2021Implementation } from './StatusList2021' +import { OAuthStatusListImplementation } from './OAuthStatusList' +import { StatusListType } from '@sphereon/ssi-types' + +export class StatusListFactory { + private static instance: StatusListFactory + private implementations: Map + + private constructor() { + this.implementations = new Map() + this.implementations.set(StatusListType.StatusList2021, new StatusList2021Implementation()) + this.implementations.set(StatusListType.OAuthStatusList, new OAuthStatusListImplementation()) + } + + public static getInstance(): StatusListFactory { + if (!StatusListFactory.instance) { + StatusListFactory.instance = new StatusListFactory() + } + return StatusListFactory.instance + } + + public getByType(type: StatusListType): IStatusList { + const statusList = this.implementations.get(type) + if (!statusList) { + throw new Error(`No implementation found for status list type: ${type}`) + } + return statusList + } +} + +export function getStatusListImplementation(type: StatusListType): IStatusList { + return StatusListFactory.getInstance().getByType(type) +} diff --git a/packages/vc-status-list/src/impl/encoding/cbor.ts b/packages/vc-status-list/src/impl/encoding/cbor.ts new file mode 100644 index 000000000..33358ed4c --- /dev/null +++ b/packages/vc-status-list/src/impl/encoding/cbor.ts @@ -0,0 +1,171 @@ +import { StatusList } from '@sd-jwt/jwt-status-list' +import { deflate, inflate } from 'pako' +import { com, kotlin } from '@sphereon/kmp-cbor' +import base64url from 'base64url' +import { IRequiredContext, SignedStatusListData } from '../../types' +import { DecodedStatusListPayload, resolveIdentifier } from './common' +import { BitsPerStatus } from '@sd-jwt/jwt-status-list/dist' + +const cbor = com.sphereon.cbor +const kmp = com.sphereon.kmp +const decompressRawStatusList = (StatusList as any).decodeStatusList.bind(StatusList) + +const CWT_CLAIMS = { + SUBJECT: 2, + ISSUER: 1, + ISSUED_AT: 6, + EXPIRATION: 4, + TIME_TO_LIVE: 65534, + STATUS_LIST: 65533, +} as const + +export const createSignedCbor = async ( + context: IRequiredContext, + statusList: StatusList, + issuerString: string, + id: string, + expiresAt?: Date, + keyRef?: string, +): Promise => { + const identifier = await resolveIdentifier(context, issuerString, keyRef) + + const encodeStatusList = statusList.encodeStatusList() + const compressedList = deflate(encodeStatusList, { level: 9 }) + const compressedBytes = new Int8Array(compressedList) + + const statusListMap = new cbor.CborMap( + kotlin.collections.KtMutableMap.fromJsMap( + new Map>([ + [new cbor.CborString('bits'), new cbor.CborUInt(kmp.LongKMP.fromNumber(statusList.getBitsPerStatus()))], + [new cbor.CborString('lst'), new cbor.CborByteString(compressedBytes)], + ]), + ), + ) + + const protectedHeader = new cbor.CborMap( + kotlin.collections.KtMutableMap.fromJsMap( + new Map([[new cbor.CborUInt(kmp.LongKMP.fromNumber(16)), new cbor.CborString('statuslist+cwt')]]), // "type" + ), + ) + const protectedHeaderEncoded = cbor.Cbor.encode(protectedHeader) + const claimsMap = buildClaimsMap(id, issuerString, statusListMap, expiresAt) + const claimsEncoded: Int8Array = cbor.Cbor.encode(claimsMap) + + const signedCWT: string = await context.agent.keyManagerSign({ + keyRef: identifier.kmsKeyRef, + data: base64url.encode(Buffer.from(claimsEncoded)), // TODO test on RN + encoding: undefined, + }) + + const protectedHeaderEncodedInt8 = new Int8Array(protectedHeaderEncoded) + const claimsEncodedInt8 = new Int8Array(claimsEncoded) + const signatureBytes = base64url.decode(signedCWT) + const signatureInt8 = new Int8Array(Buffer.from(signatureBytes)) + + const cwtArrayElements: Array> = [ + new cbor.CborByteString(protectedHeaderEncodedInt8), + new cbor.CborByteString(claimsEncodedInt8), + new cbor.CborByteString(signatureInt8), + ] + const cwtArray = new cbor.CborArray(kotlin.collections.KtMutableList.fromJsArray(cwtArrayElements)) + const cwtEncoded = cbor.Cbor.encode(cwtArray) + const cwtBuffer = Buffer.from(cwtEncoded) + return { + statusListCredential: base64url.encode(cwtBuffer), + encodedList: base64url.encode(compressedList as Buffer), // JS in @sd-jwt/jwt-status-list drops it in like this, so keep the same method + } +} + +function buildClaimsMap( + id: string, + issuerString: string, + statusListMap: com.sphereon.cbor.CborMap>, + expiresAt?: Date, +) { + const ttl = 65535 // FIXME figure out what value should be / come from and what the difference is with exp + const claimsEntries: Array<[com.sphereon.cbor.CborUInt, com.sphereon.cbor.CborItem]> = [ + [new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.SUBJECT)), new cbor.CborString(id)], // "sub" + [new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.ISSUER)), new cbor.CborString(issuerString)], // "iss" + [ + new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.ISSUED_AT)), + new cbor.CborUInt(kmp.LongKMP.fromNumber(Math.floor(Date.now() / 1000))), // "iat" + ], + ] + + if (expiresAt) { + claimsEntries.push([ + new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.EXPIRATION)), + new cbor.CborUInt(kmp.LongKMP.fromNumber(Math.floor(expiresAt.getTime() / 1000))), // "exp" + ]) + } + + if (ttl) { + claimsEntries.push([ + new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.TIME_TO_LIVE)), + new cbor.CborUInt(kmp.LongKMP.fromNumber(ttl)), // "time to live" + ]) + } + + claimsEntries.push([new cbor.CborUInt(kmp.LongKMP.fromNumber(CWT_CLAIMS.STATUS_LIST)), statusListMap]) + + const claimsMap = new cbor.CborMap(kotlin.collections.KtMutableMap.fromJsMap(new Map(claimsEntries))) + return claimsMap +} + +const getCborValueFromMap = (map: Map, com.sphereon.cbor.CborItem>, key: number): T => { + const value = getCborOptionalValueFromMap(map, key) + if (value === undefined) { + throw new Error(`Required claim ${key} not found`) + } + return value +} + +const getCborOptionalValueFromMap = ( + map: Map, com.sphereon.cbor.CborItem>, + key: number, +): T | undefined | never => { + const value = map.get(new com.sphereon.cbor.CborUInt(kmp.LongKMP.fromNumber(key))) + if (!value) { + return undefined + } + return value.value as T +} + +export const decodeStatusListCWT = (cwt: string): DecodedStatusListPayload => { + const encodedCbor = base64url.toBuffer(cwt) + const encodedCborArray = new Int8Array(encodedCbor) + const decodedCbor = com.sphereon.cbor.Cbor.decode(encodedCborArray) + + if (!(decodedCbor instanceof com.sphereon.cbor.CborArray)) { + throw new Error('Invalid CWT format: Expected a CBOR array') + } + + const [, payload] = decodedCbor.value.asJsArrayView() + if (!(payload instanceof com.sphereon.cbor.CborByteString)) { + throw new Error('Invalid payload format: Expected a CBOR ByteString') + } + + const claims = com.sphereon.cbor.Cbor.decode(payload.value) + if (!(claims instanceof com.sphereon.cbor.CborMap)) { + throw new Error('Invalid claims format: Expected a CBOR map') + } + + const claimsMap = claims.value.asJsMapView() + + const statusListMap = claimsMap.get(new com.sphereon.cbor.CborUInt(kmp.LongKMP.fromNumber(65533))).value.asJsMapView() + + const bits = Number(statusListMap.get(new com.sphereon.cbor.CborString('bits')).value) as BitsPerStatus + const decoded = new Uint8Array(statusListMap.get(new com.sphereon.cbor.CborString('lst')).value) + const uint8Array = inflate(decoded) + const rawStatusList = decompressRawStatusList(uint8Array, bits) + const statusList = new StatusList(rawStatusList, bits) + + return { + issuer: getCborValueFromMap(claimsMap, CWT_CLAIMS.ISSUER), + id: getCborValueFromMap(claimsMap, CWT_CLAIMS.SUBJECT), + statusList, + iat: Number(getCborValueFromMap(claimsMap, CWT_CLAIMS.ISSUED_AT)), + exp: getCborOptionalValueFromMap(claimsMap, CWT_CLAIMS.EXPIRATION), + ttl: getCborOptionalValueFromMap(claimsMap, CWT_CLAIMS.TIME_TO_LIVE), + } +} diff --git a/packages/vc-status-list/src/impl/encoding/common.ts b/packages/vc-status-list/src/impl/encoding/common.ts new file mode 100644 index 000000000..1d3cddc7f --- /dev/null +++ b/packages/vc-status-list/src/impl/encoding/common.ts @@ -0,0 +1,25 @@ +import { IRequiredContext } from '../../types' +import { StatusList } from '@sd-jwt/jwt-status-list' + +export interface DecodedStatusListPayload { + issuer: string + id: string + statusList: StatusList + exp?: number + ttl?: number + iat: number +} + +export const resolveIdentifier = async (context: IRequiredContext, issuer: string, keyRef?: string) => { + if (keyRef) { + return await context.agent.identifierManagedGetByKid({ + identifier: keyRef, + }) + } + + return await context.agent.identifierManagedGet({ + identifier: issuer, + vmRelationship: 'assertionMethod', + offlineWhenNoDIDRegistered: true, + }) +} diff --git a/packages/vc-status-list/src/impl/encoding/jwt.ts b/packages/vc-status-list/src/impl/encoding/jwt.ts new file mode 100644 index 000000000..bf28e516c --- /dev/null +++ b/packages/vc-status-list/src/impl/encoding/jwt.ts @@ -0,0 +1,80 @@ +import { CompactJWT, JoseSignatureAlgorithm } from '@sphereon/ssi-types' +import { createHeaderAndPayload, StatusList, StatusListJWTHeaderParameters, StatusListJWTPayload } from '@sd-jwt/jwt-status-list' +import base64url from 'base64url' +import { JWTPayload } from 'did-jwt' +import { IRequiredContext, SignedStatusListData } from '../../types' +import { DecodedStatusListPayload, resolveIdentifier } from './common' +import { TKeyType } from '@veramo/core' +import { ensureManagedIdentifierResult } from '@sphereon/ssi-sdk-ext.identifier-resolution' + +const STATUS_LIST_JWT_TYP = 'statuslist+jwt' + +export const createSignedJwt = async ( + context: IRequiredContext, + statusList: StatusList, + issuerString: string, + id: string, + expiresAt?: Date, + keyRef?: string, +): Promise => { + const identifier = await resolveIdentifier(context, issuerString, keyRef) + const resolution = await ensureManagedIdentifierResult(identifier, context) + + const payload: JWTPayload = { + iss: issuerString, + sub: id, + iat: Math.floor(Date.now() / 1000), + ...(expiresAt && { exp: Math.floor(expiresAt.getTime() / 1000) }), + } + + const header: StatusListJWTHeaderParameters = { + alg: getSigningAlgo(resolution.key.type), + typ: STATUS_LIST_JWT_TYP, + } + const values = createHeaderAndPayload(statusList, payload, header) + const signedJwt = await context.agent.jwtCreateJwsCompactSignature({ + issuer: { ...identifier, noIssPayloadUpdate: false }, + protectedHeader: values.header, + payload: values.payload, + }) + + return { + statusListCredential: signedJwt.jwt, + encodedList: (values.payload as StatusListJWTPayload).status_list.lst, + } +} + +export const decodeStatusListJWT = (jwt: CompactJWT): DecodedStatusListPayload => { + const [, payloadBase64] = jwt.split('.') + const payload = JSON.parse(base64url.decode(payloadBase64)) + + if (!payload.iss || !payload.sub || !payload.status_list) { + throw new Error('Missing required fields in JWT payload') + } + + const statusList = StatusList.decompressStatusList(payload.status_list.lst, payload.status_list.bits) + + return { + issuer: payload.iss, + id: payload.sub, + statusList, + exp: payload.exp, + ttl: payload.ttl, + iat: payload.iat, + } +} + +export const getSigningAlgo = (type: TKeyType): JoseSignatureAlgorithm => { + switch (type) { + case 'Ed25519': + return JoseSignatureAlgorithm.EdDSA + case 'Secp256k1': + return JoseSignatureAlgorithm.ES256K + case 'Secp256r1': + return JoseSignatureAlgorithm.ES256 + case 'RSA': + return JoseSignatureAlgorithm.RS256 + default: + throw Error('Key type not yet supported') + } +} diff --git a/packages/vc-status-list/src/types/index.ts b/packages/vc-status-list/src/types/index.ts index 6b3906d1a..e90690d11 100644 --- a/packages/vc-status-list/src/types/index.ts +++ b/packages/vc-status-list/src/types/index.ts @@ -4,12 +4,13 @@ import { ICredentialStatus, IIssuer, IVerifiableCredential, - OriginalVerifiableCredential, OrPromise, + ProofFormat, StatusListCredentialIdMode, StatusListDriverType, StatusListIndexingDirection, StatusListType, + StatusListCredential, StatusPurpose2021, } from '@sphereon/ssi-types' import { @@ -18,58 +19,101 @@ import { ICredentialIssuer, ICredentialPlugin, ICredentialVerifier, + IKeyManager, IPluginMethodMap, - ProofFormat, } from '@veramo/core' import { DataSource } from 'typeorm' +import { BitsPerStatus } from '@sd-jwt/jwt-status-list/dist' -export interface CreateNewStatusListFuncArgs extends Omit { - correlationId: string - length?: number +export enum StatusOAuth { + Valid = 0, + Invalid = 1, + Suspended = 2, } -export interface UpdateStatusListFromEncodedListArgs extends StatusList2021ToVerifiableCredentialArgs { - statusListIndex: number | string - value: boolean +export enum Status2021 { + Valid = 0, + Invalid = 1, } -export interface UpdateStatusListFromStatusListCredentialArgs { - statusListCredential: OriginalVerifiableCredential - keyRef?: string - statusListIndex: number | string - value: boolean +export type StatusList2021Args = { + indexingDirection: StatusListIndexingDirection + statusPurpose?: StatusPurpose2021 + // todo: validFrom and validUntil } -export interface StatusList2021ToVerifiableCredentialArgs { - issuer: string | IIssuer +export type OAuthStatusListArgs = { + bitsPerStatus?: BitsPerStatus + expiresAt?: Date +} + +export type BaseCreateNewStatusListArgs = { + type: StatusListType id: string - type?: StatusListType + issuer: string | IIssuer + correlationId?: string + length?: number + proofFormat?: ProofFormat + keyRef?: string + statusList2021?: StatusList2021Args + oauthStatusList?: OAuthStatusListArgs +} + +export type UpdateStatusList2021Args = { statusPurpose: StatusPurpose2021 - encodedList: string +} + +export type UpdateOAuthStatusListArgs = { + bitsPerStatus: BitsPerStatus + expiresAt?: Date +} + +export interface UpdateStatusListFromEncodedListArgs { + type?: StatusListType + statusListIndex: number | string + value: boolean proofFormat?: ProofFormat keyRef?: string + correlationId?: string + encodedList: string + issuer: string | IIssuer + id: string + statusList2021?: UpdateStatusList2021Args + oauthStatusList?: UpdateOAuthStatusListArgs +} - // todo: validFrom and validUntil +export interface UpdateStatusListFromStatusListCredentialArgs { + statusListCredential: StatusListCredential // | CompactJWT + keyRef?: string + statusListIndex: number | string + value: number | Status2021 | StatusOAuth } -export interface StatusListDetails { +export interface StatusListResult { encodedList: string + statusListCredential: StatusListCredential // | CompactJWT length: number type: StatusListType proofFormat: ProofFormat - statusPurpose: StatusPurpose2021 id: string issuer: string | IIssuer - indexingDirection: StatusListIndexingDirection - statusListCredential: OriginalVerifiableCredential + statusList2021?: StatusList2021Details + oauthStatusList?: OAuthStatusDetails + // These cannot be deduced from the VC, so they are present when callers pass in these values as params correlationId?: string driverType?: StatusListDriverType credentialIdMode?: StatusListCredentialIdMode } -export interface StatusListResult extends StatusListDetails { - statusListCredential: OriginalVerifiableCredential +interface StatusList2021Details { + indexingDirection: StatusListIndexingDirection + statusPurpose?: StatusPurpose2021 +} + +interface OAuthStatusDetails { + bitsPerStatus?: BitsPerStatus + expiresAt?: Date } export interface StatusList2021EntryCredentialStatus extends ICredentialStatus { @@ -79,6 +123,54 @@ export interface StatusList2021EntryCredentialStatus extends ICredentialStatus { statusListCredential: string } +export interface StatusListOAuthEntryCredentialStatus extends ICredentialStatus { + type: 'OAuthStatusListEntry' + bitsPerStatus: number + statusListIndex: string + statusListCredential: string + expiresAt?: Date +} + +export interface StatusList2021ToVerifiableCredentialArgs { + issuer: string | IIssuer + id: string + type?: StatusListType + proofFormat?: ProofFormat + keyRef?: string + encodedList: string + statusPurpose: StatusPurpose2021 +} + +export interface CreateStatusListArgs { + issuer: string | IIssuer + id: string + proofFormat?: ProofFormat + keyRef?: string + correlationId?: string + length?: number + statusList2021?: StatusList2021Args + oauthStatusList?: OAuthStatusListArgs +} + +export interface UpdateStatusListIndexArgs { + statusListCredential: StatusListCredential // | CompactJWT + statusListIndex: number | string + value: number | Status2021 | StatusOAuth + keyRef?: string + expiresAt?: Date +} + +export interface CheckStatusIndexArgs { + statusListCredential: StatusListCredential // | CompactJWT + statusListIndex: string | number +} + +export interface ToStatusListDetailsArgs { + statusListPayload: StatusListCredential + correlationId?: string + driverType?: StatusListDriverType +} + /** * The interface definition for a plugin that can add statuslist info to a credential * @@ -95,7 +187,7 @@ export interface IStatusListPlugin extends IPluginMethodMap { * * @returns - The details of the newly created status list */ - slCreateStatusList(args: CreateNewStatusListArgs, context: IRequiredContext): Promise + slCreateStatusList(args: CreateNewStatusListArgs, context: IRequiredContext): Promise /** * Ensures status list info like index and list id is added to a credential @@ -114,7 +206,15 @@ export interface IStatusListPlugin extends IPluginMethodMap { * @param args * @param context */ - slGetStatusList(args: GetStatusListArgs, context: IRequiredContext): Promise + slGetStatusList(args: GetStatusListArgs, context: IRequiredContext): Promise +} + +export type CreateNewStatusListFuncArgs = BaseCreateNewStatusListArgs + +export type CreateNewStatusListArgs = BaseCreateNewStatusListArgs & { + dataSource?: OrPromise + dbName?: string + isDefault?: boolean } export type IAddStatusToCredentialArgs = Omit & { @@ -123,7 +223,6 @@ export type IAddStatusToCredentialArgs = Omit - dbName?: string - isDefault?: boolean -} - export type CredentialWithStatusSupport = ICredential | CredentialPayload | IVerifiableCredential +export type SignedStatusListData = { + statusListCredential: StatusListCredential + encodedList: string +} + export type IRequiredPlugins = ICredentialPlugin & IIdentifierResolution -export type IRequiredContext = IAgentContext +export type IRequiredContext = IAgentContext diff --git a/packages/vc-status-list/src/utils.ts b/packages/vc-status-list/src/utils.ts new file mode 100644 index 000000000..1b6d70157 --- /dev/null +++ b/packages/vc-status-list/src/utils.ts @@ -0,0 +1,95 @@ +import { + CredentialMapper, + IIssuer, + ProofFormat, + StatusListType, + StatusListType as StatusListTypeW3C, + StatusListCredential, + DocumentFormat, +} from '@sphereon/ssi-types' +import { jwtDecode } from 'jwt-decode' + +export function getAssertedStatusListType(type?: StatusListType) { + const assertedType = type ?? StatusListType.StatusList2021 + if (![StatusListType.StatusList2021, StatusListType.OAuthStatusList].includes(assertedType)) { + throw Error(`StatusList type ${assertedType} is not supported (yet)`) + } + return assertedType +} + +export function getAssertedValue(name: string, value: T): NonNullable { + if (value === undefined || value === null) { + throw Error(`Missing required ${name} value`) + } + return value +} + +export function getAssertedValues(args: { issuer: string | IIssuer; id: string; type?: StatusListTypeW3C | StatusListType }) { + const type = getAssertedStatusListType(args?.type) + const id = getAssertedValue('id', args.id) + const issuer = getAssertedValue('issuer', args.issuer) + return { id, issuer, type } +} + +export function getAssertedProperty(propertyName: string, obj: T): NonNullable { + if (!(propertyName in obj)) { + throw Error(`The input object does not contain required property: ${propertyName}`) + } + return getAssertedValue(propertyName, (obj as any)[propertyName]) +} + +const ValidProofTypeMap = new Map([ + [StatusListType.StatusList2021, ['jwt', 'lds', 'EthereumEip712Signature2021']], + [StatusListType.OAuthStatusList, ['jwt', 'cbor']], +]) + +export function assertValidProofType(type: StatusListType, proofFormat: ProofFormat) { + const validProofTypes = ValidProofTypeMap.get(type) + if (!validProofTypes?.includes(proofFormat)) { + throw Error(`Invalid proof format '${proofFormat}' for status list type ${type}`) + } +} + +export function determineStatusListType(credential: StatusListCredential): StatusListType { + const proofFormat = determineProofFormat(credential) + switch (proofFormat) { + case 'jwt': + const payload: StatusListCredential = jwtDecode(credential as string) + const keys = Object.keys(payload) + if (keys.includes('status_list')) { + return StatusListType.OAuthStatusList + } else if (keys.includes('vc')) { + return StatusListType.StatusList2021 + } + break + case 'lds': + const uniform = CredentialMapper.toUniformCredential(credential) + const type = uniform.type.find((t) => { + return Object.values(StatusListType).some((statusType) => t.includes(statusType)) + }) + if (!type) { + throw new Error('Invalid status list credential type') + } + return type.replace('Credential', '') as StatusListType + + case 'cbor': + return StatusListType.OAuthStatusList + } + + throw new Error('Cannot determine status list type from credential payload') +} + +export function determineProofFormat(credential: StatusListCredential): ProofFormat { + const type: DocumentFormat = CredentialMapper.detectDocumentType(credential) + switch (type) { + case DocumentFormat.JWT: + return 'jwt' + case DocumentFormat.MSO_MDOC: + // Not really mdoc, just assume Cbor for now, I'd need to decode at least the header to what type of Cbor we have + return 'cbor' + case DocumentFormat.JSONLD: + return 'lds' + default: + throw Error('Cannot determine credential payload type') + } +} diff --git a/packages/w3c-vc-api/src/api-functions.ts b/packages/w3c-vc-api/src/api-functions.ts index 04222a38c..1f6f59f0c 100644 --- a/packages/w3c-vc-api/src/api-functions.ts +++ b/packages/w3c-vc-api/src/api-functions.ts @@ -2,7 +2,7 @@ import { checkAuth, ISingleEndpointOpts, sendErrorResponse } from '@sphereon/ssi import { contextHasPlugin } from '@sphereon/ssi-sdk.agent-config' import { CredentialPayload } from '@veramo/core' import { ProofFormat } from '@veramo/core' -import { W3CVerifiableCredential } from '@veramo/core/src/types/vc-data-model' +import { W3CVerifiableCredential } from '@veramo/core' import { Request, Response, Router } from 'express' import { v4 } from 'uuid' import { IIssueCredentialEndpointOpts, IRequiredContext, IVCAPIIssueOpts, IVerifyCredentialEndpointOpts } from './types' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 898c5bd57..7c6b4811e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1284,6 +1284,9 @@ importers: '@sphereon/ssi-sdk-ext.identifier-resolution': specifier: 0.27.0 version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk-ext.jwt-service': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) '@sphereon/ssi-sdk.agent-config': specifier: workspace:* version: link:../agent-config @@ -1369,6 +1372,9 @@ importers: '@sphereon/ssi-sdk-ext.identifier-resolution': specifier: 0.27.0 version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk-ext.jwt-service': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) '@sphereon/ssi-sdk.kv-store-temp': specifier: workspace:* version: link:../kv-store @@ -3249,12 +3255,21 @@ importers: packages/vc-status-list: dependencies: + '@sd-jwt/jwt-status-list': + specifier: ^0.9.1 + version: 0.9.1 + '@sphereon/kmp-cbor': + specifier: 0.2.0-SNAPSHOT.25 + version: 0.2.0-SNAPSHOT.25 '@sphereon/ssi-sdk-ext.did-utils': specifier: 0.27.0 version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) '@sphereon/ssi-sdk-ext.identifier-resolution': specifier: 0.27.0 version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk-ext.jwt-service': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) '@sphereon/ssi-types': specifier: workspace:* version: link:../ssi-types @@ -3267,12 +3282,21 @@ importers: '@veramo/credential-status': specifier: 4.2.0 version: 4.2.0(encoding@0.1.13) + base64url: + specifier: ^3.0.1 + version: 3.0.1 credential-status: specifier: ^2.0.6 version: 2.0.6 debug: specifier: ^4.3.5 version: 4.3.7 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 + pako: + specifier: ^2.1.0 + version: 2.1.0 typeorm: specifier: ^0.3.20 version: 0.3.20(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) @@ -3292,6 +3316,12 @@ importers: '@babel/preset-typescript': specifier: ^7.24.7 version: 7.26.0(@babel/core@7.26.0) + '@types/pako': + specifier: 2.0.3 + version: 2.0.3 + '@veramo/key-manager': + specifier: 4.2.0 + version: 4.2.0 typescript: specifier: 5.6.3 version: 5.6.3 @@ -3492,6 +3522,9 @@ importers: '@sphereon/ssi-sdk-ext.identifier-resolution': specifier: 0.27.0 version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk-ext.jwt-service': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) '@sphereon/ssi-sdk.core': specifier: workspace:* version: link:../ssi-sdk-core @@ -3617,6 +3650,70 @@ importers: specifier: 5.6.3 version: 5.6.3 + packages/vc-status-list-tests: + dependencies: + react-native-securerandom: + specifier: ^1.0.1 + version: 1.0.1(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)) + devDependencies: + '@sphereon/ssi-sdk-ext.did-provider-key': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(expo@52.0.11(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(encoding@0.1.13)(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(web-streams-polyfill@3.3.3) + '@sphereon/ssi-sdk-ext.identifier-resolution': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk-ext.jwt-service': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk-ext.key-manager': + specifier: 0.27.0 + version: 0.27.0 + '@sphereon/ssi-sdk-ext.kms-local': + specifier: 0.27.0 + version: 0.27.0(encoding@0.1.13)(pg@8.13.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.17.9)(typescript@5.6.3)) + '@sphereon/ssi-sdk.vc-handler-ld-local': + specifier: workspace:* + version: link:../vc-handler-ld-local + '@sphereon/ssi-sdk.vc-status-list': + specifier: workspace:* + version: link:../vc-status-list + '@sphereon/ssi-types': + specifier: workspace:* + version: link:../ssi-types + '@types/jest': + specifier: ^27.5.2 + version: 27.5.2 + '@types/node': + specifier: ^20.17.1 + version: 20.17.9 + '@veramo/core': + specifier: 4.2.0 + version: 4.2.0(patch_hash=c5oempznsz4br5w3tcuk2i2mau) + '@veramo/credential-w3c': + specifier: 4.2.0 + version: 4.2.0(patch_hash=wuhizuafnrz3uzah2wlqaevbmi)(encoding@0.1.13)(expo@52.0.11(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(encoding@0.1.13)(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(web-streams-polyfill@3.3.3) + '@veramo/did-manager': + specifier: 4.2.0 + version: 4.2.0 + '@veramo/did-resolver': + specifier: 4.2.0 + version: 4.2.0(encoding@0.1.13) + '@veramo/key-manager': + specifier: 4.2.0 + version: 4.2.0 + did-resolver: + specifier: ^4.1.0 + version: 4.1.0 + nock: + specifier: ^13.5.4 + version: 13.5.6 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.17.9)(typescript@5.6.3) + typescript: + specifier: 5.6.3 + version: 5.6.3 + packages/w3c-vc-api: dependencies: '@sphereon/did-auth-siop': @@ -5935,6 +6032,10 @@ packages: resolution: {integrity: sha512-o/Mg/Zg21poFsPXuxtPD9sdXq2b/0L+rb9gxU2k1rp1aT+DWmqD0k8v0Ttr2tlMc8l1xXQNA8FLXbL1AdLRmbQ==} engines: {node: '>=18'} + '@sd-jwt/jwt-status-list@0.9.1': + resolution: {integrity: sha512-vdk9LKmhZWNbR3K4MFPwJ/ms04UKpDpxNt+CgTuvP++xgcdgmMj8WPiqxZznNssaPPe5XI+THgFn0w1O7gXTSg==} + engines: {node: '>=18'} + '@sd-jwt/present@0.7.2': resolution: {integrity: sha512-mQV85u2+mLLy2VZ9Wx2zpaB6yTDnbhCfWkP7eeCrzJQHBKAAHko8GrylEFmLKewFIcajS/r4lT/zHOsCkp5pZw==} engines: {node: '>=18'} @@ -5947,6 +6048,10 @@ packages: resolution: {integrity: sha512-1NRKowiW0ZiB9SGLApLPBH4Xk8gDQJ+nA9NdZ+uy6MmJKLEwjuJxO7yTvRIv/jX/0/Ebh339S7Kq4RD2AiFuRg==} engines: {node: '>=18'} + '@sd-jwt/types@0.9.1': + resolution: {integrity: sha512-/Ylzbpre9HQdBrRSRS3bivFdYtwkLM99Ye6gEZEMsJCb231Z1LKdzvmN35H/335ZV4cFYsT5IVM9PurEl6GoLQ==} + engines: {node: '>=18'} + '@sd-jwt/utils@0.7.2': resolution: {integrity: sha512-aMPY7uHRMgyI5PlDvEiIc+eBFGC1EM8OCQRiEjJ8HGN0pajWMYj0qwSw7pS90A49/DsYU1a5Zpvb7nyjgGH0Yg==} engines: {node: '>=18'} @@ -6053,6 +6158,10 @@ packages: resolution: {integrity: sha512-PQABG/rZpK1ypZqfHRV3HuxVDxclRJnD41A8fnr8EQB5JFKElSQ/SWEIWi7DD1HeWqzZnRiLWt1boPuWjgphOQ==} engines: {node: '>=18'} + '@sphereon/kmp-cbor@0.2.0-SNAPSHOT.25': + resolution: {integrity: sha512-EFuAtA4zaONe1/BGOntx9m1Oov6iVSMhbjhht6ST/6hFx+VMCs5iDTnl8qay3dleIOoDEluK8AnSiufi56+IJA==} + bundledDependencies: [] + '@sphereon/kmp-mdoc-core@0.2.0-SNAPSHOT.26': resolution: {integrity: sha512-QXJ6R8ENiZV2rPMbn06cw5JKwqUYN1kzVRbYfONqE1PEXx1noQ4md7uxr2zSczi0ubKkNcbyYDNtIMTZIhGzmQ==} bundledDependencies: [] @@ -15895,6 +16004,10 @@ snapshots: '@js-joda/core@5.6.3': {} + '@js-joda/timezone@2.3.0(@js-joda/core@3.2.0)': + dependencies: + '@js-joda/core': 3.2.0 + '@js-joda/timezone@2.3.0(@js-joda/core@5.6.3)': dependencies: '@js-joda/core': 5.6.3 @@ -16834,6 +16947,12 @@ snapshots: base64url: 3.0.1 pako: 2.1.0 + '@sd-jwt/jwt-status-list@0.9.1': + dependencies: + '@sd-jwt/types': 0.9.1 + base64url: 3.0.1 + pako: 2.1.0 + '@sd-jwt/present@0.7.2': dependencies: '@sd-jwt/decode': 0.7.2 @@ -16848,6 +16967,8 @@ snapshots: '@sd-jwt/types@0.7.2': {} + '@sd-jwt/types@0.9.1': {} + '@sd-jwt/utils@0.7.2': dependencies: '@sd-jwt/types': 0.7.2 @@ -17067,6 +17188,12 @@ snapshots: transitivePeerDependencies: - typescript + '@sphereon/kmp-cbor@0.2.0-SNAPSHOT.25': + dependencies: + '@js-joda/core': 3.2.0 + '@js-joda/timezone': 2.3.0(@js-joda/core@3.2.0) + format-util: 1.0.5 + '@sphereon/kmp-mdoc-core@0.2.0-SNAPSHOT.26': dependencies: '@js-joda/core': 5.6.3 @@ -18625,7 +18752,7 @@ snapshots: '@types/testing-library__jest-dom@5.14.9': dependencies: - '@types/jest': 27.5.2 + '@types/jest': 29.5.14 '@types/through@0.0.33': dependencies: