diff --git a/packages/core/src/crypto/jose/jwk/Jwk.ts b/packages/core/src/crypto/jose/jwk/Jwk.ts index 595a1dc9a3..a38b384ba9 100644 --- a/packages/core/src/crypto/jose/jwk/Jwk.ts +++ b/packages/core/src/crypto/jose/jwk/Jwk.ts @@ -28,10 +28,7 @@ export abstract class Jwk { public use?: string public toJson(): JwkJson { - return { - kty: this.kty, - use: this.use, - } + return { use: this.use, kty: this.kty } } public get key() { diff --git a/packages/core/src/crypto/jose/jwk/index.ts b/packages/core/src/crypto/jose/jwk/index.ts index a11c9ea840..7579a74778 100644 --- a/packages/core/src/crypto/jose/jwk/index.ts +++ b/packages/core/src/crypto/jose/jwk/index.ts @@ -1,12 +1,7 @@ -export { - getJwkFromJson, - getJwkFromKey, - getJwkClassFromJwaSignatureAlgorithm, - getJwkClassFromKeyType, -} from './transform' +export * from './transform' export { Ed25519Jwk } from './Ed25519Jwk' export { X25519Jwk } from './X25519Jwk' export { P256Jwk } from './P256Jwk' export { P384Jwk } from './P384Jwk' export { P521Jwk } from './P521Jwk' -export { Jwk } from './Jwk' +export { Jwk, JwkJson } from './Jwk' diff --git a/packages/core/src/crypto/jose/jwk/transform.ts b/packages/core/src/crypto/jose/jwk/transform.ts index c1f4283ae0..ed9e2b968a 100644 --- a/packages/core/src/crypto/jose/jwk/transform.ts +++ b/packages/core/src/crypto/jose/jwk/transform.ts @@ -2,6 +2,7 @@ import type { JwkJson, Jwk } from './Jwk' import type { Key } from '../../Key' import type { JwaSignatureAlgorithm } from '../jwa' +import { AriesFrameworkError } from '../../../error' import { KeyType } from '../../KeyType' import { JwaCurve, JwaKeyType } from '../jwa' @@ -37,7 +38,7 @@ export function getJwkFromKey(key: Key) { if (key.keyType === KeyType.P384) return P384Jwk.fromPublicKey(key.publicKey) if (key.keyType === KeyType.P521) return P521Jwk.fromPublicKey(key.publicKey) - throw new Error(`Cannot create JWK from key. Unsupported key with type '${key.keyType}'.`) + throw new AriesFrameworkError(`Cannot create JWK from key. Unsupported key with type '${key.keyType}'.`) } export function getJwkClassFromJwaSignatureAlgorithm(alg: JwaSignatureAlgorithm | string) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d99d06980..46b4dbddc1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,18 +61,26 @@ export * from './modules/oob' export * from './modules/dids' export * from './modules/vc' export * from './modules/cache' -export { JsonEncoder, JsonTransformer, isJsonObject, isValidJweStructure, TypedArrayEncoder, Buffer } from './utils' +export { + JsonEncoder, + JsonTransformer, + isJsonObject, + isValidJweStructure, + TypedArrayEncoder, + Buffer, + deepEquality, +} from './utils' export * from './logger' export * from './error' export * from './wallet/error' export { parseMessageType, IsValidMessageType, replaceLegacyDidSovPrefix } from './utils/messageType' -export type { Constructor } from './utils/mixins' +export type { Constructor, Constructable } from './utils/mixins' export * from './agent/Events' export * from './crypto/' // TODO: clean up util exports export { encodeAttachment, isLinkedAttachment } from './utils/attachment' -export { Hasher } from './utils/Hasher' +export { Hasher, HashName } from './utils/Hasher' export { MessageValidator } from './utils/MessageValidator' export { LinkedAttachment, LinkedAttachmentOptions } from './utils/LinkedAttachment' import { parseInvitationUrl } from './utils/parseInvitation' diff --git a/packages/core/src/modules/generic-records/repository/GenericRecord.ts b/packages/core/src/modules/generic-records/repository/GenericRecord.ts index b96cb5ebc8..9c23266c9a 100644 --- a/packages/core/src/modules/generic-records/repository/GenericRecord.ts +++ b/packages/core/src/modules/generic-records/repository/GenericRecord.ts @@ -1,12 +1,10 @@ -import type { RecordTags, TagsBase } from '../../../storage/BaseRecord' +import type { TagsBase } from '../../../storage/BaseRecord' import { BaseRecord } from '../../../storage/BaseRecord' import { uuid } from '../../../utils/uuid' export type GenericRecordTags = TagsBase -export type BasicMessageTags = RecordTags - export interface GenericRecordStorageProps { id?: string createdAt?: Date diff --git a/packages/openid4vc-client/src/OpenId4VcClientModule.ts b/packages/openid4vc-client/src/OpenId4VcClientModule.ts index ad6381da52..0c452e8201 100644 --- a/packages/openid4vc-client/src/OpenId4VcClientModule.ts +++ b/packages/openid4vc-client/src/OpenId4VcClientModule.ts @@ -12,7 +12,7 @@ export class OpenId4VcClientModule implements Module { public readonly api = OpenId4VcClientApi /** - * Registers the dependencies of the question answer module on the dependency manager. + * Registers the dependencies of the openid4vc-client module on the dependency manager. */ public register(dependencyManager: DependencyManager) { // Warn about experimental module diff --git a/packages/sd-jwt-vc/README.md b/packages/sd-jwt-vc/README.md new file mode 100644 index 0000000000..aaaac48824 --- /dev/null +++ b/packages/sd-jwt-vc/README.md @@ -0,0 +1,57 @@ +

+
+ Hyperledger Aries logo +

+

Aries Framework JavaScript Selective Disclosure JWT VC Module

+

+ License + typescript + @aries-framework/sd-jwt-vc version +

+
+ +### Installation + +Add the `sd-jwt-vc` module to your project. + +```sh +yarn add @aries-framework/sd-jwt-vc +``` + +### Quick start + +After the installation you can follow the [guide to setup your agent](https://aries.js.org/guides/0.4/getting-started/set-up) and add the following to your agent modules. + +```ts +import { SdJwtVcModule } from '@aries-framework/sd-jwt-vc' + +const agent = new Agent({ + config: { + /* config */ + }, + dependencies: agentDependencies, + modules: { + sdJwtVc: new SdJwtVcModule(), + /* other custom modules */ + }, +}) + +await agent.initialize() +``` diff --git a/packages/sd-jwt-vc/jest.config.ts b/packages/sd-jwt-vc/jest.config.ts new file mode 100644 index 0000000000..93c0197296 --- /dev/null +++ b/packages/sd-jwt-vc/jest.config.ts @@ -0,0 +1,13 @@ +import type { Config } from '@jest/types' + +import base from '../../jest.config.base' + +import packageJson from './package.json' + +const config: Config.InitialOptions = { + ...base, + displayName: packageJson.name, + setupFilesAfterEnv: ['./tests/setup.ts'], +} + +export default config diff --git a/packages/sd-jwt-vc/package.json b/packages/sd-jwt-vc/package.json new file mode 100644 index 0000000000..df62927318 --- /dev/null +++ b/packages/sd-jwt-vc/package.json @@ -0,0 +1,39 @@ +{ + "name": "@aries-framework/sd-jwt-vc", + "main": "build/index", + "types": "build/index", + "version": "0.4.2", + "files": [ + "build" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/hyperledger/aries-framework-javascript/tree/main/packages/sd-jwt-vc", + "repository": { + "type": "git", + "url": "https://github.com/hyperledger/aries-framework-javascript", + "directory": "packages/sd-jwt-vc" + }, + "scripts": { + "build": "yarn run clean && yarn run compile", + "clean": "rimraf ./build", + "compile": "tsc -p tsconfig.build.json", + "prepublishOnly": "yarn run build", + "test": "jest" + }, + "dependencies": { + "@aries-framework/askar": "^0.4.2", + "@aries-framework/core": "^0.4.2", + "class-transformer": "0.5.1", + "class-validator": "0.14.0", + "jwt-sd": "^0.1.2" + }, + "devDependencies": { + "@hyperledger/aries-askar-nodejs": "^0.2.0-dev.1", + "reflect-metadata": "^0.1.13", + "rimraf": "^4.4.0", + "typescript": "~4.9.5" + } +} diff --git a/packages/sd-jwt-vc/src/SdJwtVcApi.ts b/packages/sd-jwt-vc/src/SdJwtVcApi.ts new file mode 100644 index 0000000000..8091d54c69 --- /dev/null +++ b/packages/sd-jwt-vc/src/SdJwtVcApi.ts @@ -0,0 +1,93 @@ +import type { + SdJwtVcCreateOptions, + SdJwtVcPresentOptions, + SdJwtVcReceiveOptions, + SdJwtVcVerifyOptions, +} from './SdJwtVcOptions' +import type { SdJwtVcVerificationResult } from './SdJwtVcService' +import type { SdJwtVcRecord } from './repository' +import type { Query } from '@aries-framework/core' + +import { AgentContext, injectable } from '@aries-framework/core' + +import { SdJwtVcService } from './SdJwtVcService' + +/** + * @public + */ +@injectable() +export class SdJwtVcApi { + private agentContext: AgentContext + private sdJwtVcService: SdJwtVcService + + public constructor(agentContext: AgentContext, sdJwtVcService: SdJwtVcService) { + this.agentContext = agentContext + this.sdJwtVcService = sdJwtVcService + } + + public async create = Record>( + payload: Payload, + options: SdJwtVcCreateOptions + ): Promise<{ sdJwtVcRecord: SdJwtVcRecord; compact: string }> { + return await this.sdJwtVcService.create(this.agentContext, payload, options) + } + + /** + * + * Validates and stores an sd-jwt-vc from the perspective of an holder + * + */ + public async storeCredential(sdJwtVcCompact: string, options: SdJwtVcReceiveOptions): Promise { + return await this.sdJwtVcService.storeCredential(this.agentContext, sdJwtVcCompact, options) + } + + /** + * + * Create a compact presentation of the sd-jwt. + * This presentation can be send in- or out-of-band to the verifier. + * + * Within the `options` field, you can supply the indicies of the disclosures you would like to share with the verifier. + * Also, whether to include the holder key binding. + * + */ + public async present(sdJwtVcRecord: SdJwtVcRecord, options: SdJwtVcPresentOptions): Promise { + return await this.sdJwtVcService.present(this.agentContext, sdJwtVcRecord, options) + } + + /** + * + * Verify an incoming sd-jwt. It will check whether everything is valid, but also returns parts of the validation. + * + * For example, you might still want to continue with a flow if not all the claims are included, but the signature is valid. + * + */ + public async verify< + Header extends Record = Record, + Payload extends Record = Record + >( + sdJwtVcCompact: string, + options: SdJwtVcVerifyOptions + ): Promise<{ sdJwtVcRecord: SdJwtVcRecord; validation: SdJwtVcVerificationResult }> { + return await this.sdJwtVcService.verify(this.agentContext, sdJwtVcCompact, options) + } + + public async getById(id: string): Promise { + return await this.sdJwtVcService.getCredentialRecordById(this.agentContext, id) + } + + public async getAll(): Promise> { + return await this.sdJwtVcService.getAllCredentialRecords(this.agentContext) + } + + public async findAllByQuery(query: Query): Promise> { + return await this.sdJwtVcService.findCredentialRecordsByQuery(this.agentContext, query) + } + + public async remove(id: string) { + return await this.sdJwtVcService.removeCredentialRecord(this.agentContext, id) + } + + public async update(sdJwtVcRecord: SdJwtVcRecord) { + return await this.sdJwtVcService.updateCredentialRecord(this.agentContext, sdJwtVcRecord) + } +} diff --git a/packages/sd-jwt-vc/src/SdJwtVcError.ts b/packages/sd-jwt-vc/src/SdJwtVcError.ts new file mode 100644 index 0000000000..cacc4c7511 --- /dev/null +++ b/packages/sd-jwt-vc/src/SdJwtVcError.ts @@ -0,0 +1,3 @@ +import { AriesFrameworkError } from '@aries-framework/core' + +export class SdJwtVcError extends AriesFrameworkError {} diff --git a/packages/sd-jwt-vc/src/SdJwtVcModule.ts b/packages/sd-jwt-vc/src/SdJwtVcModule.ts new file mode 100644 index 0000000000..eea361477f --- /dev/null +++ b/packages/sd-jwt-vc/src/SdJwtVcModule.ts @@ -0,0 +1,35 @@ +import type { DependencyManager, Module } from '@aries-framework/core' + +import { AgentConfig } from '@aries-framework/core' + +import { SdJwtVcApi } from './SdJwtVcApi' +import { SdJwtVcService } from './SdJwtVcService' +import { SdJwtVcRepository } from './repository' + +/** + * @public + */ +export class SdJwtVcModule implements Module { + public readonly api = SdJwtVcApi + + /** + * Registers the dependencies of the sd-jwt-vc module on the dependency manager. + */ + public register(dependencyManager: DependencyManager) { + // Warn about experimental module + dependencyManager + .resolve(AgentConfig) + .logger.warn( + "The '@aries-framework/sd-jwt-vc' module is experimental and could have unexpected breaking changes. When using this module, make sure to use strict versions for all @aries-framework packages." + ) + + // Api + dependencyManager.registerContextScoped(this.api) + + // Services + dependencyManager.registerSingleton(SdJwtVcService) + + // Repositories + dependencyManager.registerSingleton(SdJwtVcRepository) + } +} diff --git a/packages/sd-jwt-vc/src/SdJwtVcOptions.ts b/packages/sd-jwt-vc/src/SdJwtVcOptions.ts new file mode 100644 index 0000000000..d7e2ea7ece --- /dev/null +++ b/packages/sd-jwt-vc/src/SdJwtVcOptions.ts @@ -0,0 +1,44 @@ +import type { HashName, JwaSignatureAlgorithm } from '@aries-framework/core' +import type { DisclosureFrame } from 'jwt-sd' + +export type SdJwtVcCreateOptions = Record> = { + holderDidUrl: string + issuerDidUrl: string + jsonWebAlgorithm?: JwaSignatureAlgorithm + disclosureFrame?: DisclosureFrame + hashingAlgorithm?: HashName +} + +export type SdJwtVcReceiveOptions = { + issuerDidUrl: string + holderDidUrl: string +} + +/** + * `includedDisclosureIndices` is not the best API, but it is the best alternative until something like `PEX` is supported + */ +export type SdJwtVcPresentOptions = { + jsonWebAlgorithm?: JwaSignatureAlgorithm + includedDisclosureIndices?: Array + + /** + * This information is received out-of-band from the verifier. + * The claims will be used to create a normal JWT, used for key binding. + */ + verifierMetadata: { + verifierDid: string + nonce: string + issuedAt: number + } +} + +/** + * `requiredClaimKeys` is not the best API, but it is the best alternative until something like `PEX` is supported + */ +export type SdJwtVcVerifyOptions = { + holderDidUrl: string + challenge: { + verifierDid: string + } + requiredClaimKeys?: Array +} diff --git a/packages/sd-jwt-vc/src/SdJwtVcService.ts b/packages/sd-jwt-vc/src/SdJwtVcService.ts new file mode 100644 index 0000000000..e8af00af0d --- /dev/null +++ b/packages/sd-jwt-vc/src/SdJwtVcService.ts @@ -0,0 +1,341 @@ +import type { + SdJwtVcCreateOptions, + SdJwtVcPresentOptions, + SdJwtVcReceiveOptions, + SdJwtVcVerifyOptions, +} from './SdJwtVcOptions' +import type { AgentContext, JwkJson, Query } from '@aries-framework/core' +import type { Signer, SdJwtVcVerificationResult, Verifier, HasherAndAlgorithm } from 'jwt-sd' + +import { + parseDid, + DidResolverService, + getKeyFromVerificationMethod, + getJwkFromJson, + Key, + getJwkFromKey, + Hasher, + inject, + injectable, + InjectionSymbols, + Logger, + TypedArrayEncoder, + Buffer, +} from '@aries-framework/core' +import { KeyBinding, SdJwtVc, HasherAlgorithm, Disclosure } from 'jwt-sd' + +import { SdJwtVcError } from './SdJwtVcError' +import { SdJwtVcRepository, SdJwtVcRecord } from './repository' + +export { SdJwtVcVerificationResult } + +/** + * @internal + */ +@injectable() +export class SdJwtVcService { + private logger: Logger + private sdJwtVcRepository: SdJwtVcRepository + + public constructor(sdJwtVcRepository: SdJwtVcRepository, @inject(InjectionSymbols.Logger) logger: Logger) { + this.sdJwtVcRepository = sdJwtVcRepository + this.logger = logger + } + + private async resolveDidUrl(agentContext: AgentContext, didUrl: string) { + const didResolver = agentContext.dependencyManager.resolve(DidResolverService) + const didDocument = await didResolver.resolveDidDocument(agentContext, didUrl) + + return { verificationMethod: didDocument.dereferenceKey(didUrl), didDocument } + } + + private get hasher(): HasherAndAlgorithm { + return { + algorithm: HasherAlgorithm.Sha256, + hasher: (input: string) => { + const serializedInput = TypedArrayEncoder.fromString(input) + return Hasher.hash(serializedInput, 'sha2-256') + }, + } + } + + /** + * @todo validate the JWT header (alg) + */ + private signer
= Record>( + agentContext: AgentContext, + key: Key + ): Signer
{ + return async (input: string) => agentContext.wallet.sign({ key, data: TypedArrayEncoder.fromString(input) }) + } + + /** + * @todo validate the JWT header (alg) + */ + private verifier
= Record>( + agentContext: AgentContext, + signerKey: Key + ): Verifier
{ + return async ({ message, signature, publicKeyJwk }) => { + let key = signerKey + + if (publicKeyJwk) { + if (!('kty' in publicKeyJwk)) { + throw new SdJwtVcError( + 'Key type (kty) claim could not be found in the JWK of the confirmation (cnf) claim. Only JWK is supported right now' + ) + } + + const jwk = getJwkFromJson(publicKeyJwk as JwkJson) + key = Key.fromPublicKey(jwk.publicKey, jwk.keyType) + } + + return await agentContext.wallet.verify({ + signature: Buffer.from(signature), + key: key, + data: TypedArrayEncoder.fromString(message), + }) + } + } + + public async create = Record>( + agentContext: AgentContext, + payload: Payload, + { + issuerDidUrl, + holderDidUrl, + disclosureFrame, + hashingAlgorithm = 'sha2-256', + jsonWebAlgorithm, + }: SdJwtVcCreateOptions + ): Promise<{ sdJwtVcRecord: SdJwtVcRecord; compact: string }> { + if (hashingAlgorithm !== 'sha2-256') { + throw new SdJwtVcError(`Unsupported hashing algorithm used: ${hashingAlgorithm}`) + } + + const parsedDid = parseDid(issuerDidUrl) + if (!parsedDid.fragment) { + throw new SdJwtVcError( + `issuer did url '${issuerDidUrl}' does not contain a '#'. Unable to derive key from did document` + ) + } + + const { verificationMethod: issuerVerificationMethod, didDocument: issuerDidDocument } = await this.resolveDidUrl( + agentContext, + issuerDidUrl + ) + const issuerKey = getKeyFromVerificationMethod(issuerVerificationMethod) + const alg = jsonWebAlgorithm ?? getJwkFromKey(issuerKey).supportedSignatureAlgorithms[0] + + const { verificationMethod: holderVerificationMethod } = await this.resolveDidUrl(agentContext, holderDidUrl) + const holderKey = getKeyFromVerificationMethod(holderVerificationMethod) + const holderKeyJwk = getJwkFromKey(holderKey).toJson() + + const header = { + alg: alg.toString(), + typ: 'vc+sd-jwt', + kid: parsedDid.fragment, + } + + const sdJwtVc = new SdJwtVc({}, { disclosureFrame }) + .withHasher(this.hasher) + .withSigner(this.signer(agentContext, issuerKey)) + .withSaltGenerator(agentContext.wallet.generateNonce) + .withHeader(header) + .withPayload({ ...payload }) + + // Add the `cnf` claim for the holder key binding + sdJwtVc.addPayloadClaim('cnf', { jwk: holderKeyJwk }) + + // Add the issuer DID as the `iss` claim + sdJwtVc.addPayloadClaim('iss', issuerDidDocument.id) + + // Add the issued at (iat) claim + sdJwtVc.addPayloadClaim('iat', Math.floor(new Date().getTime() / 1000)) + + const compact = await sdJwtVc.toCompact() + + if (!sdJwtVc.signature) { + throw new SdJwtVcError('Invalid sd-jwt-vc state. Signature should have been set when calling `toCompact`.') + } + + const sdJwtVcRecord = new SdJwtVcRecord({ + sdJwtVc: { + header: sdJwtVc.header, + payload: sdJwtVc.payload, + signature: sdJwtVc.signature, + disclosures: sdJwtVc.disclosures?.map((d) => d.decoded), + holderDidUrl, + }, + }) + + await this.sdJwtVcRepository.save(agentContext, sdJwtVcRecord) + + return { + sdJwtVcRecord, + compact, + } + } + + public async storeCredential< + Header extends Record = Record, + Payload extends Record = Record + >( + agentContext: AgentContext, + sdJwtVcCompact: string, + { issuerDidUrl, holderDidUrl }: SdJwtVcReceiveOptions + ): Promise { + const sdJwtVc = SdJwtVc.fromCompact(sdJwtVcCompact) + + if (!sdJwtVc.signature) { + throw new SdJwtVcError('A signature must be included for an sd-jwt-vc') + } + + const { verificationMethod: issuerVerificationMethod } = await this.resolveDidUrl(agentContext, issuerDidUrl) + const issuerKey = getKeyFromVerificationMethod(issuerVerificationMethod) + + const { isSignatureValid } = await sdJwtVc.verify(this.verifier(agentContext, issuerKey)) + + if (!isSignatureValid) { + throw new SdJwtVcError('sd-jwt-vc has an invalid signature from the issuer') + } + + const { verificationMethod: holderVerificiationMethod } = await this.resolveDidUrl(agentContext, holderDidUrl) + const holderKey = getKeyFromVerificationMethod(holderVerificiationMethod) + const holderKeyJwk = getJwkFromKey(holderKey).toJson() + + sdJwtVc.assertClaimInPayload('cnf', { jwk: holderKeyJwk }) + + const sdJwtVcRecord = new SdJwtVcRecord({ + sdJwtVc: { + header: sdJwtVc.header, + payload: sdJwtVc.payload, + signature: sdJwtVc.signature, + disclosures: sdJwtVc.disclosures?.map((d) => d.decoded), + holderDidUrl, + }, + }) + + await this.sdJwtVcRepository.save(agentContext, sdJwtVcRecord) + + return sdJwtVcRecord + } + + public async present( + agentContext: AgentContext, + sdJwtVcRecord: SdJwtVcRecord, + { includedDisclosureIndices, verifierMetadata, jsonWebAlgorithm }: SdJwtVcPresentOptions + ): Promise { + const { verificationMethod: holderVerificationMethod } = await this.resolveDidUrl( + agentContext, + sdJwtVcRecord.sdJwtVc.holderDidUrl + ) + const holderKey = getKeyFromVerificationMethod(holderVerificationMethod) + const alg = jsonWebAlgorithm ?? getJwkFromKey(holderKey).supportedSignatureAlgorithms[0] + + const header = { + alg: alg.toString(), + typ: 'kb+jwt', + } as const + + const payload = { + iat: verifierMetadata.issuedAt, + nonce: verifierMetadata.nonce, + aud: verifierMetadata.verifierDid, + } + + const keyBinding = new KeyBinding, Record>({ header, payload }).withSigner( + this.signer(agentContext, holderKey) + ) + + const sdJwtVc = new SdJwtVc({ + header: sdJwtVcRecord.sdJwtVc.header, + payload: sdJwtVcRecord.sdJwtVc.payload, + signature: sdJwtVcRecord.sdJwtVc.signature, + disclosures: sdJwtVcRecord.sdJwtVc.disclosures?.map(Disclosure.fromArray), + }).withKeyBinding(keyBinding) + + return await sdJwtVc.present(includedDisclosureIndices) + } + + public async verify< + Header extends Record = Record, + Payload extends Record = Record + >( + agentContext: AgentContext, + sdJwtVcCompact: string, + { challenge: { verifierDid }, requiredClaimKeys, holderDidUrl }: SdJwtVcVerifyOptions + ): Promise<{ sdJwtVcRecord: SdJwtVcRecord; validation: SdJwtVcVerificationResult }> { + const sdJwtVc = SdJwtVc.fromCompact(sdJwtVcCompact) + + if (!sdJwtVc.signature) { + throw new SdJwtVcError('A signature is required for verification of the sd-jwt-vc') + } + + if (!sdJwtVc.keyBinding || !sdJwtVc.keyBinding.payload) { + throw new SdJwtVcError('Keybinding is required for verification of the sd-jwt-vc') + } + + sdJwtVc.keyBinding.assertClaimInPayload('aud', verifierDid) + + const { verificationMethod: holderVerificationMethod } = await this.resolveDidUrl(agentContext, holderDidUrl) + const holderKey = getKeyFromVerificationMethod(holderVerificationMethod) + const holderKeyJwk = getJwkFromKey(holderKey).toJson() + + sdJwtVc.assertClaimInPayload('cnf', { jwk: holderKeyJwk }) + + sdJwtVc.assertClaimInHeader('kid') + sdJwtVc.assertClaimInPayload('iss') + + const issuerKid = sdJwtVc.getClaimInHeader('kid') + const issuerDid = sdJwtVc.getClaimInPayload('iss') + + // TODO: is there a more AFJ way of doing this? + const issuerDidUrl = `${issuerDid}#${issuerKid}` + + const { verificationMethod: issuerVerificationMethod } = await this.resolveDidUrl(agentContext, issuerDidUrl) + const issuerKey = getKeyFromVerificationMethod(issuerVerificationMethod) + + const verificationResult = await sdJwtVc.verify(this.verifier(agentContext, issuerKey), requiredClaimKeys) + + const sdJwtVcRecord = new SdJwtVcRecord({ + sdJwtVc: { + signature: sdJwtVc.signature, + payload: sdJwtVc.payload, + disclosures: sdJwtVc.disclosures?.map((d) => d.decoded), + header: sdJwtVc.header, + holderDidUrl, + }, + }) + + await this.sdJwtVcRepository.save(agentContext, sdJwtVcRecord) + + return { + sdJwtVcRecord, + validation: verificationResult, + } + } + + public async getCredentialRecordById(agentContext: AgentContext, id: string): Promise { + return await this.sdJwtVcRepository.getById(agentContext, id) + } + + public async getAllCredentialRecords(agentContext: AgentContext): Promise> { + return await this.sdJwtVcRepository.getAll(agentContext) + } + + public async findCredentialRecordsByQuery( + agentContext: AgentContext, + query: Query + ): Promise> { + return await this.sdJwtVcRepository.findByQuery(agentContext, query) + } + + public async removeCredentialRecord(agentContext: AgentContext, id: string) { + await this.sdJwtVcRepository.deleteById(agentContext, id) + } + + public async updateCredentialRecord(agentContext: AgentContext, sdJwtVcRecord: SdJwtVcRecord) { + await this.sdJwtVcRepository.update(agentContext, sdJwtVcRecord) + } +} diff --git a/packages/sd-jwt-vc/src/__tests__/SdJwtVcModule.test.ts b/packages/sd-jwt-vc/src/__tests__/SdJwtVcModule.test.ts new file mode 100644 index 0000000000..0adc239614 --- /dev/null +++ b/packages/sd-jwt-vc/src/__tests__/SdJwtVcModule.test.ts @@ -0,0 +1,27 @@ +import type { DependencyManager } from '@aries-framework/core' + +import { SdJwtVcApi } from '../SdJwtVcApi' +import { SdJwtVcModule } from '../SdJwtVcModule' +import { SdJwtVcService } from '../SdJwtVcService' +import { SdJwtVcRepository } from '../repository' + +const dependencyManager = { + registerInstance: jest.fn(), + registerSingleton: jest.fn(), + registerContextScoped: jest.fn(), + resolve: jest.fn().mockReturnValue({ logger: { warn: jest.fn() } }), +} as unknown as DependencyManager + +describe('SdJwtVcModule', () => { + test('registers dependencies on the dependency manager', () => { + const sdJwtVcModule = new SdJwtVcModule() + sdJwtVcModule.register(dependencyManager) + + expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(SdJwtVcApi) + + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(SdJwtVcService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(SdJwtVcRepository) + }) +}) diff --git a/packages/sd-jwt-vc/src/__tests__/SdJwtVcService.test.ts b/packages/sd-jwt-vc/src/__tests__/SdJwtVcService.test.ts new file mode 100644 index 0000000000..a0347e3c46 --- /dev/null +++ b/packages/sd-jwt-vc/src/__tests__/SdJwtVcService.test.ts @@ -0,0 +1,526 @@ +import type { Key, Logger } from '@aries-framework/core' + +import { AskarModule } from '@aries-framework/askar' +import { + getJwkFromKey, + DidKey, + DidsModule, + KeyDidRegistrar, + KeyDidResolver, + utils, + KeyType, + Agent, + TypedArrayEncoder, +} from '@aries-framework/core' +import { ariesAskar } from '@hyperledger/aries-askar-nodejs' + +import { agentDependencies } from '../../../core/tests' +import { SdJwtVcService } from '../SdJwtVcService' +import { SdJwtVcRepository } from '../repository' + +import { + complexSdJwtVc, + complexSdJwtVcPresentation, + sdJwtVcWithSingleDisclosure, + sdJwtVcWithSingleDisclosurePresentation, + simpleJwtVc, + simpleJwtVcPresentation, +} from './sdjwtvc.fixtures' + +const agent = new Agent({ + config: { label: 'sdjwtvcserviceagent', walletConfig: { id: utils.uuid(), key: utils.uuid() } }, + modules: { + askar: new AskarModule({ ariesAskar }), + dids: new DidsModule({ + resolvers: [new KeyDidResolver()], + registrars: [new KeyDidRegistrar()], + }), + }, + dependencies: agentDependencies, +}) + +const logger = jest.fn() as unknown as Logger +agent.context.wallet.generateNonce = jest.fn(() => Promise.resolve('salt')) +Date.prototype.getTime = jest.fn(() => 1698151532000) + +jest.mock('../repository/SdJwtVcRepository') +const SdJwtVcRepositoryMock = SdJwtVcRepository as jest.Mock + +describe('SdJwtVcService', () => { + const verifierDid = 'did:key:zUC74VEqqhEHQcgv4zagSPkqFJxuNWuoBPKjJuHETEUeHLoSqWt92viSsmaWjy82y' + let issuerDidUrl: string + let holderDidUrl: string + let issuerKey: Key + let holderKey: Key + let sdJwtVcService: SdJwtVcService + + beforeAll(async () => { + await agent.initialize() + + issuerKey = await agent.context.wallet.createKey({ + keyType: KeyType.Ed25519, + seed: TypedArrayEncoder.fromString('00000000000000000000000000000000'), + }) + + const issuerDidKey = new DidKey(issuerKey) + const issuerDidDocument = issuerDidKey.didDocument + issuerDidUrl = (issuerDidDocument.verificationMethod ?? [])[0].id + await agent.dids.import({ didDocument: issuerDidDocument, did: issuerDidDocument.id }) + + holderKey = await agent.context.wallet.createKey({ + keyType: KeyType.Ed25519, + seed: TypedArrayEncoder.fromString('00000000000000000000000000000001'), + }) + + const holderDidKey = new DidKey(holderKey) + const holderDidDocument = holderDidKey.didDocument + holderDidUrl = (holderDidDocument.verificationMethod ?? [])[0].id + await agent.dids.import({ didDocument: holderDidDocument, did: holderDidDocument.id }) + + const sdJwtVcRepositoryMock = new SdJwtVcRepositoryMock() + sdJwtVcService = new SdJwtVcService(sdJwtVcRepositoryMock, logger) + }) + + describe('SdJwtVcService.create', () => { + test('Create sd-jwt-vc from a basic payload without disclosures', async () => { + const { compact, sdJwtVcRecord } = await sdJwtVcService.create( + agent.context, + { + claim: 'some-claim', + type: 'IdentityCredential', + }, + { + issuerDidUrl, + holderDidUrl, + } + ) + + expect(compact).toStrictEqual(simpleJwtVc) + + expect(sdJwtVcRecord.sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: 'z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).toEqual({ + claim: 'some-claim', + type: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + iss: issuerDidUrl.split('#')[0], + cnf: { + jwk: getJwkFromKey(holderKey).toJson(), + }, + }) + }) + + test('Create sd-jwt-vc from a basic payload with a disclosure', async () => { + const { compact, sdJwtVcRecord } = await sdJwtVcService.create( + agent.context, + { claim: 'some-claim', type: 'IdentityCredential' }, + { + issuerDidUrl, + holderDidUrl, + disclosureFrame: { claim: true }, + } + ) + + expect(compact).toStrictEqual(sdJwtVcWithSingleDisclosure) + + expect(sdJwtVcRecord.sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: 'z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).toEqual({ + type: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + iss: issuerDidUrl.split('#')[0], + _sd: ['vcvFU4DsFKTqQ1vl4nelJWXTb_-0dNoBks6iqNFptyg'], + _sd_alg: 'sha-256', + cnf: { + jwk: getJwkFromKey(holderKey).toJson(), + }, + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).not.toContain({ + claim: 'some-claim', + }) + + expect(sdJwtVcRecord.sdJwtVc.disclosures).toEqual(expect.arrayContaining([['salt', 'claim', 'some-claim']])) + }) + + test('Create sd-jwt-vc from a basic payload with multiple (nested) disclosure', async () => { + const { compact, sdJwtVcRecord } = await sdJwtVcService.create( + agent.context, + { + type: 'IdentityCredential', + given_name: 'John', + family_name: 'Doe', + email: 'johndoe@example.com', + phone_number: '+1-202-555-0101', + address: { + street_address: '123 Main St', + locality: 'Anytown', + region: 'Anystate', + country: 'US', + }, + birthdate: '1940-01-01', + is_over_18: true, + is_over_21: true, + is_over_65: true, + }, + { + issuerDidUrl: issuerDidUrl, + holderDidUrl: holderDidUrl, + disclosureFrame: { + is_over_65: true, + is_over_21: true, + is_over_18: true, + birthdate: true, + email: true, + address: { region: true, country: true }, + given_name: true, + }, + } + ) + + expect(compact).toStrictEqual(complexSdJwtVc) + + expect(sdJwtVcRecord.sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: 'z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).toEqual({ + type: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + address: { + _sd: ['NJnmct0BqBME1JfBlC6jRQVRuevpEONiYw7A7MHuJyQ', 'om5ZztZHB-Gd00LG21CV_xM4FaENSoiaOXnTAJNczB4'], + locality: 'Anytown', + street_address: '123 Main St', + }, + phone_number: '+1-202-555-0101', + family_name: 'Doe', + iss: issuerDidUrl.split('#')[0], + _sd: [ + '1Cur2k2A2oIB5CshSIf_A_Kg-l26u_qKuWQ79P0Vdas', + 'R1zTUvOYHgcepj0jHypGHz9EHttVKft0yswbc9ETPbU', + 'eDqQpdTXJXbWhf-EsI7zw5X6OvYmFN-UZQQMesXwKPw', + 'pdDk2_XAKHo7gOAfwF1b7OdCUVTit2kJHaxSECQ9xfc', + 'psauKUNWEi09nu3Cl89xKXgmpWENZl5uy1N1nyn_jMk', + 'sN_ge0pHXF6qmsYnX1A9SdwJ8ch8aENkxbODsT74YwI', + ], + _sd_alg: 'sha-256', + cnf: { + jwk: getJwkFromKey(holderKey).toJson(), + }, + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).not.toContain({ + family_name: 'Doe', + phone_number: '+1-202-555-0101', + address: { + region: 'Anystate', + country: 'US', + }, + birthdate: '1940-01-01', + is_over_18: true, + is_over_21: true, + is_over_65: true, + }) + + expect(sdJwtVcRecord.sdJwtVc.disclosures).toEqual( + expect.arrayContaining([ + ['salt', 'is_over_65', true], + ['salt', 'is_over_21', true], + ['salt', 'is_over_18', true], + ['salt', 'birthdate', '1940-01-01'], + ['salt', 'email', 'johndoe@example.com'], + ['salt', 'region', 'Anystate'], + ['salt', 'country', 'US'], + ['salt', 'given_name', 'John'], + ]) + ) + }) + }) + + describe('SdJwtVcService.receive', () => { + test('Receive sd-jwt-vc from a basic payload without disclosures', async () => { + const sdJwtVc = simpleJwtVc + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { + issuerDidUrl, + holderDidUrl, + }) + + expect(sdJwtVcRecord.sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: 'z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).toEqual({ + claim: 'some-claim', + type: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + iss: issuerDidUrl.split('#')[0], + cnf: { + jwk: getJwkFromKey(holderKey).toJson(), + }, + }) + }) + + test('Receive sd-jwt-vc from a basic payload with a disclosure', async () => { + const sdJwtVc = sdJwtVcWithSingleDisclosure + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { + issuerDidUrl, + holderDidUrl, + }) + + expect(sdJwtVcRecord.sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: 'z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).toEqual({ + type: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + iss: issuerDidUrl.split('#')[0], + _sd: ['vcvFU4DsFKTqQ1vl4nelJWXTb_-0dNoBks6iqNFptyg'], + _sd_alg: 'sha-256', + cnf: { + jwk: getJwkFromKey(holderKey).toJson(), + }, + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).not.toContain({ + claim: 'some-claim', + }) + + expect(sdJwtVcRecord.sdJwtVc.disclosures).toEqual(expect.arrayContaining([['salt', 'claim', 'some-claim']])) + }) + + test('Receive sd-jwt-vc from a basic payload with multiple (nested) disclosure', async () => { + const sdJwtVc = complexSdJwtVc + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { holderDidUrl, issuerDidUrl }) + + expect(sdJwtVcRecord.sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: 'z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).toEqual({ + type: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + family_name: 'Doe', + iss: issuerDidUrl.split('#')[0], + address: { + _sd: ['NJnmct0BqBME1JfBlC6jRQVRuevpEONiYw7A7MHuJyQ', 'om5ZztZHB-Gd00LG21CV_xM4FaENSoiaOXnTAJNczB4'], + locality: 'Anytown', + street_address: '123 Main St', + }, + phone_number: '+1-202-555-0101', + _sd: [ + '1Cur2k2A2oIB5CshSIf_A_Kg-l26u_qKuWQ79P0Vdas', + 'R1zTUvOYHgcepj0jHypGHz9EHttVKft0yswbc9ETPbU', + 'eDqQpdTXJXbWhf-EsI7zw5X6OvYmFN-UZQQMesXwKPw', + 'pdDk2_XAKHo7gOAfwF1b7OdCUVTit2kJHaxSECQ9xfc', + 'psauKUNWEi09nu3Cl89xKXgmpWENZl5uy1N1nyn_jMk', + 'sN_ge0pHXF6qmsYnX1A9SdwJ8ch8aENkxbODsT74YwI', + ], + _sd_alg: 'sha-256', + cnf: { + jwk: getJwkFromKey(holderKey).toJson(), + }, + }) + + expect(sdJwtVcRecord.sdJwtVc.payload).not.toContain({ + family_name: 'Doe', + phone_number: '+1-202-555-0101', + address: { + region: 'Anystate', + country: 'US', + }, + birthdate: '1940-01-01', + is_over_18: true, + is_over_21: true, + is_over_65: true, + }) + + expect(sdJwtVcRecord.sdJwtVc.disclosures).toEqual( + expect.arrayContaining([ + ['salt', 'is_over_65', true], + ['salt', 'is_over_21', true], + ['salt', 'is_over_18', true], + ['salt', 'birthdate', '1940-01-01'], + ['salt', 'email', 'johndoe@example.com'], + ['salt', 'region', 'Anystate'], + ['salt', 'country', 'US'], + ['salt', 'given_name', 'John'], + ]) + ) + }) + }) + + describe('SdJwtVcService.present', () => { + test('Present sd-jwt-vc from a basic payload without disclosures', async () => { + const sdJwtVc = simpleJwtVc + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { issuerDidUrl, holderDidUrl }) + + const presentation = await sdJwtVcService.present(agent.context, sdJwtVcRecord, { + verifierMetadata: { + issuedAt: new Date().getTime() / 1000, + verifierDid, + nonce: await agent.context.wallet.generateNonce(), + }, + }) + + expect(presentation).toStrictEqual(simpleJwtVcPresentation) + }) + + test('Present sd-jwt-vc from a basic payload with a disclosure', async () => { + const sdJwtVc = sdJwtVcWithSingleDisclosure + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { holderDidUrl, issuerDidUrl }) + + const presentation = await sdJwtVcService.present(agent.context, sdJwtVcRecord, { + verifierMetadata: { + issuedAt: new Date().getTime() / 1000, + verifierDid, + nonce: await agent.context.wallet.generateNonce(), + }, + includedDisclosureIndices: [0], + }) + + expect(presentation).toStrictEqual(sdJwtVcWithSingleDisclosurePresentation) + }) + + test('Present sd-jwt-vc from a basic payload with multiple (nested) disclosure', async () => { + const sdJwtVc = complexSdJwtVc + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { holderDidUrl, issuerDidUrl }) + + const presentation = await sdJwtVcService.present(agent.context, sdJwtVcRecord, { + verifierMetadata: { + issuedAt: new Date().getTime() / 1000, + verifierDid, + nonce: await agent.context.wallet.generateNonce(), + }, + includedDisclosureIndices: [0, 1, 4, 6, 7], + }) + + expect(presentation).toStrictEqual(complexSdJwtVcPresentation) + }) + }) + + describe('SdJwtVcService.verify', () => { + test('Verify sd-jwt-vc without disclosures', async () => { + const sdJwtVc = simpleJwtVc + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { holderDidUrl, issuerDidUrl }) + + const presentation = await sdJwtVcService.present(agent.context, sdJwtVcRecord, { + verifierMetadata: { + issuedAt: new Date().getTime() / 1000, + verifierDid, + nonce: await agent.context.wallet.generateNonce(), + }, + }) + + const { validation } = await sdJwtVcService.verify(agent.context, presentation, { + challenge: { verifierDid }, + holderDidUrl, + requiredClaimKeys: ['claim'], + }) + + expect(validation).toEqual({ + isSignatureValid: true, + containsRequiredVcProperties: true, + areRequiredClaimsIncluded: true, + isValid: true, + isKeyBindingValid: true, + }) + }) + + test('Verify sd-jwt-vc with a disclosure', async () => { + const sdJwtVc = sdJwtVcWithSingleDisclosure + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { holderDidUrl, issuerDidUrl }) + + const presentation = await sdJwtVcService.present(agent.context, sdJwtVcRecord, { + verifierMetadata: { + issuedAt: new Date().getTime() / 1000, + verifierDid, + nonce: await agent.context.wallet.generateNonce(), + }, + includedDisclosureIndices: [0], + }) + + const { validation } = await sdJwtVcService.verify(agent.context, presentation, { + challenge: { verifierDid }, + holderDidUrl, + requiredClaimKeys: ['type', 'cnf', 'claim', 'iat'], + }) + + expect(validation).toEqual({ + isSignatureValid: true, + containsRequiredVcProperties: true, + areRequiredClaimsIncluded: true, + isValid: true, + isKeyBindingValid: true, + }) + }) + + test('Verify sd-jwt-vc with multiple (nested) disclosure', async () => { + const sdJwtVc = complexSdJwtVc + + const sdJwtVcRecord = await sdJwtVcService.storeCredential(agent.context, sdJwtVc, { holderDidUrl, issuerDidUrl }) + + const presentation = await sdJwtVcService.present(agent.context, sdJwtVcRecord, { + verifierMetadata: { + issuedAt: new Date().getTime() / 1000, + verifierDid, + nonce: await agent.context.wallet.generateNonce(), + }, + includedDisclosureIndices: [0, 1, 4, 6, 7], + }) + + const { validation } = await sdJwtVcService.verify(agent.context, presentation, { + challenge: { verifierDid }, + holderDidUrl, + requiredClaimKeys: [ + 'type', + 'family_name', + 'phone_number', + 'address', + 'cnf', + 'iss', + 'iat', + 'is_over_65', + 'is_over_21', + 'email', + 'given_name', + 'street_address', + 'locality', + 'country', + ], + }) + + expect(validation).toEqual({ + isSignatureValid: true, + areRequiredClaimsIncluded: true, + containsRequiredVcProperties: true, + isValid: true, + isKeyBindingValid: true, + }) + }) + }) +}) diff --git a/packages/sd-jwt-vc/src/__tests__/sdjwtvc.fixtures.ts b/packages/sd-jwt-vc/src/__tests__/sdjwtvc.fixtures.ts new file mode 100644 index 0000000000..e345cd8c3e --- /dev/null +++ b/packages/sd-jwt-vc/src/__tests__/sdjwtvc.fixtures.ts @@ -0,0 +1,17 @@ +export const simpleJwtVc = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Ino2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyJ9.eyJjbGFpbSI6InNvbWUtY2xhaW0iLCJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiY25mIjp7Imp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6Im9FTlZzeE9VaUg1NFg4d0pMYVZraWNDUmswMHdCSVE0c1JnYms1NE44TW8ifX0sImlzcyI6ImRpZDprZXk6ejZNa3RxdFhORzhDRFVZOVBycnRvU3RGemVDbmhwTW1neFlMMWdpa2NXM0J6dk5XIiwiaWF0IjoxNjk4MTUxNTMyfQ.5oT776RbzRyRTINojXJExV1Ul6aP7sXKssU5bR0uWmQzVJ046y7gNhD5shJ3arYbtdakeVKBTicPM8LAzOvzAw' + +export const simpleJwtVcPresentation = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Ino2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyJ9.eyJjbGFpbSI6InNvbWUtY2xhaW0iLCJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiY25mIjp7Imp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6Im9FTlZzeE9VaUg1NFg4d0pMYVZraWNDUmswMHdCSVE0c1JnYms1NE44TW8ifX0sImlzcyI6ImRpZDprZXk6ejZNa3RxdFhORzhDRFVZOVBycnRvU3RGemVDbmhwTW1neFlMMWdpa2NXM0J6dk5XIiwiaWF0IjoxNjk4MTUxNTMyfQ.5oT776RbzRyRTINojXJExV1Ul6aP7sXKssU5bR0uWmQzVJ046y7gNhD5shJ3arYbtdakeVKBTicPM8LAzOvzAw~eyJhbGciOiJFZERTQSIsInR5cCI6ImtiK2p3dCJ9.eyJpYXQiOjE2OTgxNTE1MzIsIm5vbmNlIjoic2FsdCIsImF1ZCI6ImRpZDprZXk6elVDNzRWRXFxaEVIUWNndjR6YWdTUGtxRkp4dU5XdW9CUEtqSnVIRVRFVWVITG9TcVd0OTJ2aVNzbWFXank4MnkifQ.VdZSnQJ5sklqMPnIzaOaGxP2qPiEPniTaUFHy4VMcW9h9pV1c17fcuTySJtmV2tcpKhei4ss04q_rFyN1EVRDg' + +export const sdJwtVcWithSingleDisclosure = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Ino2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyJ9.eyJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiY25mIjp7Imp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6Im9FTlZzeE9VaUg1NFg4d0pMYVZraWNDUmswMHdCSVE0c1JnYms1NE44TW8ifX0sImlzcyI6ImRpZDprZXk6ejZNa3RxdFhORzhDRFVZOVBycnRvU3RGemVDbmhwTW1neFlMMWdpa2NXM0J6dk5XIiwiaWF0IjoxNjk4MTUxNTMyLCJfc2RfYWxnIjoic2hhLTI1NiIsIl9zZCI6WyJ2Y3ZGVTREc0ZLVHFRMXZsNG5lbEpXWFRiXy0wZE5vQmtzNmlxTkZwdHlnIl19.G5jb2P0z-9H-AsEGBbJmGk9VUTPJJ_bkVE95oKDu4YmilmQuvCritpOoK5nt9n4Bg_3v23ywagHHOnGTBCtQCQ~WyJzYWx0IiwiY2xhaW0iLCJzb21lLWNsYWltIl0~' + +export const sdJwtVcWithSingleDisclosurePresentation = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Ino2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyJ9.eyJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiY25mIjp7Imp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6Im9FTlZzeE9VaUg1NFg4d0pMYVZraWNDUmswMHdCSVE0c1JnYms1NE44TW8ifX0sImlzcyI6ImRpZDprZXk6ejZNa3RxdFhORzhDRFVZOVBycnRvU3RGemVDbmhwTW1neFlMMWdpa2NXM0J6dk5XIiwiaWF0IjoxNjk4MTUxNTMyLCJfc2RfYWxnIjoic2hhLTI1NiIsIl9zZCI6WyJ2Y3ZGVTREc0ZLVHFRMXZsNG5lbEpXWFRiXy0wZE5vQmtzNmlxTkZwdHlnIl19.G5jb2P0z-9H-AsEGBbJmGk9VUTPJJ_bkVE95oKDu4YmilmQuvCritpOoK5nt9n4Bg_3v23ywagHHOnGTBCtQCQ~WyJzYWx0IiwiY2xhaW0iLCJzb21lLWNsYWltIl0~eyJhbGciOiJFZERTQSIsInR5cCI6ImtiK2p3dCJ9.eyJpYXQiOjE2OTgxNTE1MzIsIm5vbmNlIjoic2FsdCIsImF1ZCI6ImRpZDprZXk6elVDNzRWRXFxaEVIUWNndjR6YWdTUGtxRkp4dU5XdW9CUEtqSnVIRVRFVWVITG9TcVd0OTJ2aVNzbWFXank4MnkifQ.VdZSnQJ5sklqMPnIzaOaGxP2qPiEPniTaUFHy4VMcW9h9pV1c17fcuTySJtmV2tcpKhei4ss04q_rFyN1EVRDg' + +export const complexSdJwtVc = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Ino2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyJ9.eyJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiZmFtaWx5X25hbWUiOiJEb2UiLCJwaG9uZV9udW1iZXIiOiIrMS0yMDItNTU1LTAxMDEiLCJhZGRyZXNzIjp7InN0cmVldF9hZGRyZXNzIjoiMTIzIE1haW4gU3QiLCJsb2NhbGl0eSI6IkFueXRvd24iLCJfc2QiOlsiTkpubWN0MEJxQk1FMUpmQmxDNmpSUVZSdWV2cEVPTmlZdzdBN01IdUp5USIsIm9tNVp6dFpIQi1HZDAwTEcyMUNWX3hNNEZhRU5Tb2lhT1huVEFKTmN6QjQiXX0sImNuZiI6eyJqd2siOnsia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJvRU5Wc3hPVWlINTRYOHdKTGFWa2ljQ1JrMDB3QklRNHNSZ2JrNTROOE1vIn19LCJpc3MiOiJkaWQ6a2V5Ono2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyIsImlhdCI6MTY5ODE1MTUzMiwiX3NkX2FsZyI6InNoYS0yNTYiLCJfc2QiOlsiMUN1cjJrMkEyb0lCNUNzaFNJZl9BX0tnLWwyNnVfcUt1V1E3OVAwVmRhcyIsIlIxelRVdk9ZSGdjZXBqMGpIeXBHSHo5RUh0dFZLZnQweXN3YmM5RVRQYlUiLCJlRHFRcGRUWEpYYldoZi1Fc0k3enc1WDZPdlltRk4tVVpRUU1lc1h3S1B3IiwicGREazJfWEFLSG83Z09BZndGMWI3T2RDVVZUaXQya0pIYXhTRUNROXhmYyIsInBzYXVLVU5XRWkwOW51M0NsODl4S1hnbXBXRU5abDV1eTFOMW55bl9qTWsiLCJzTl9nZTBwSFhGNnFtc1luWDFBOVNkd0o4Y2g4YUVOa3hiT0RzVDc0WXdJIl19.LcCXQx4IEnA_JWK_fLD08xXL0RWO796UuiN8YL9CU4zy_MT-LTvWJa1WNoBBeoHLcKI6NlLbXHExGU7sbG1oDw~WyJzYWx0IiwiaXNfb3Zlcl82NSIsdHJ1ZV0~WyJzYWx0IiwiaXNfb3Zlcl8yMSIsdHJ1ZV0~WyJzYWx0IiwiaXNfb3Zlcl8xOCIsdHJ1ZV0~WyJzYWx0IiwiYmlydGhkYXRlIiwiMTk0MC0wMS0wMSJd~WyJzYWx0IiwiZW1haWwiLCJqb2huZG9lQGV4YW1wbGUuY29tIl0~WyJzYWx0IiwicmVnaW9uIiwiQW55c3RhdGUiXQ~WyJzYWx0IiwiY291bnRyeSIsIlVTIl0~WyJzYWx0IiwiZ2l2ZW5fbmFtZSIsIkpvaG4iXQ~' + +export const complexSdJwtVcPresentation = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Ino2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyJ9.eyJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiZmFtaWx5X25hbWUiOiJEb2UiLCJwaG9uZV9udW1iZXIiOiIrMS0yMDItNTU1LTAxMDEiLCJhZGRyZXNzIjp7InN0cmVldF9hZGRyZXNzIjoiMTIzIE1haW4gU3QiLCJsb2NhbGl0eSI6IkFueXRvd24iLCJfc2QiOlsiTkpubWN0MEJxQk1FMUpmQmxDNmpSUVZSdWV2cEVPTmlZdzdBN01IdUp5USIsIm9tNVp6dFpIQi1HZDAwTEcyMUNWX3hNNEZhRU5Tb2lhT1huVEFKTmN6QjQiXX0sImNuZiI6eyJqd2siOnsia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJvRU5Wc3hPVWlINTRYOHdKTGFWa2ljQ1JrMDB3QklRNHNSZ2JrNTROOE1vIn19LCJpc3MiOiJkaWQ6a2V5Ono2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyIsImlhdCI6MTY5ODE1MTUzMiwiX3NkX2FsZyI6InNoYS0yNTYiLCJfc2QiOlsiMUN1cjJrMkEyb0lCNUNzaFNJZl9BX0tnLWwyNnVfcUt1V1E3OVAwVmRhcyIsIlIxelRVdk9ZSGdjZXBqMGpIeXBHSHo5RUh0dFZLZnQweXN3YmM5RVRQYlUiLCJlRHFRcGRUWEpYYldoZi1Fc0k3enc1WDZPdlltRk4tVVpRUU1lc1h3S1B3IiwicGREazJfWEFLSG83Z09BZndGMWI3T2RDVVZUaXQya0pIYXhTRUNROXhmYyIsInBzYXVLVU5XRWkwOW51M0NsODl4S1hnbXBXRU5abDV1eTFOMW55bl9qTWsiLCJzTl9nZTBwSFhGNnFtc1luWDFBOVNkd0o4Y2g4YUVOa3hiT0RzVDc0WXdJIl19.LcCXQx4IEnA_JWK_fLD08xXL0RWO796UuiN8YL9CU4zy_MT-LTvWJa1WNoBBeoHLcKI6NlLbXHExGU7sbG1oDw~WyJzYWx0IiwiaXNfb3Zlcl82NSIsdHJ1ZV0~WyJzYWx0IiwiaXNfb3Zlcl8yMSIsdHJ1ZV0~WyJzYWx0IiwiZW1haWwiLCJqb2huZG9lQGV4YW1wbGUuY29tIl0~WyJzYWx0IiwiY291bnRyeSIsIlVTIl0~WyJzYWx0IiwiZ2l2ZW5fbmFtZSIsIkpvaG4iXQ~eyJhbGciOiJFZERTQSIsInR5cCI6ImtiK2p3dCJ9.eyJpYXQiOjE2OTgxNTE1MzIsIm5vbmNlIjoic2FsdCIsImF1ZCI6ImRpZDprZXk6elVDNzRWRXFxaEVIUWNndjR6YWdTUGtxRkp4dU5XdW9CUEtqSnVIRVRFVWVITG9TcVd0OTJ2aVNzbWFXank4MnkifQ.VdZSnQJ5sklqMPnIzaOaGxP2qPiEPniTaUFHy4VMcW9h9pV1c17fcuTySJtmV2tcpKhei4ss04q_rFyN1EVRDg' diff --git a/packages/sd-jwt-vc/src/index.ts b/packages/sd-jwt-vc/src/index.ts new file mode 100644 index 0000000000..18d611ca76 --- /dev/null +++ b/packages/sd-jwt-vc/src/index.ts @@ -0,0 +1,5 @@ +export * from './SdJwtVcApi' +export * from './SdJwtVcModule' +export * from './SdJwtVcService' +export * from './SdJwtVcError' +export * from './repository' diff --git a/packages/sd-jwt-vc/src/repository/SdJwtVcRecord.ts b/packages/sd-jwt-vc/src/repository/SdJwtVcRecord.ts new file mode 100644 index 0000000000..0075850113 --- /dev/null +++ b/packages/sd-jwt-vc/src/repository/SdJwtVcRecord.ts @@ -0,0 +1,97 @@ +import type { TagsBase, Constructable } from '@aries-framework/core' +import type { DisclosureItem, HasherAndAlgorithm } from 'jwt-sd' + +import { JsonTransformer, Hasher, TypedArrayEncoder, BaseRecord, utils } from '@aries-framework/core' +import { Disclosure, HasherAlgorithm, SdJwtVc } from 'jwt-sd' + +export type SdJwtVcRecordTags = TagsBase & { + disclosureKeys?: Array +} + +export type SdJwt< + Header extends Record = Record, + Payload extends Record = Record +> = { + disclosures?: Array + header: Header + payload: Payload + signature: Uint8Array + + holderDidUrl: string +} + +export type SdJwtVcRecordStorageProps< + Header extends Record = Record, + Payload extends Record = Record +> = { + id?: string + createdAt?: Date + tags?: SdJwtVcRecordTags + sdJwtVc: SdJwt +} + +export class SdJwtVcRecord< + Header extends Record = Record, + Payload extends Record = Record +> extends BaseRecord { + public static readonly type = 'SdJwtVcRecord' + public readonly type = SdJwtVcRecord.type + + public sdJwtVc!: SdJwt + + public constructor(props: SdJwtVcRecordStorageProps) { + super() + + if (props) { + this.id = props.id ?? utils.uuid() + this.createdAt = props.createdAt ?? new Date() + this.sdJwtVc = props.sdJwtVc + this._tags = props.tags ?? {} + } + } + + private get hasher(): HasherAndAlgorithm { + return { + algorithm: HasherAlgorithm.Sha256, + hasher: (input: string) => { + const serializedInput = TypedArrayEncoder.fromString(input) + return Hasher.hash(serializedInput, 'sha2-256') + }, + } + } + + /** + * This function gets the claims from the payload and combines them with the claims in the disclosures. + * + * This can be used to display all claims included in the `sd-jwt-vc` to the holder or verifier. + */ + public async getPrettyClaims | Payload = Payload>(): Promise { + const sdJwtVc = new SdJwtVc({ + header: this.sdJwtVc.header, + payload: this.sdJwtVc.payload, + disclosures: this.sdJwtVc.disclosures?.map(Disclosure.fromArray), + }).withHasher(this.hasher) + + // Assert that we only support `sha-256` as a hashing algorithm + if ('_sd_alg' in this.sdJwtVc.payload) { + sdJwtVc.assertClaimInPayload('_sd_alg', HasherAlgorithm.Sha256.toString()) + } + + return await sdJwtVc.getPrettyClaims() + } + + public getTags() { + const disclosureKeys = this.sdJwtVc.disclosures + ?.filter((d): d is [string, string, unknown] => d.length === 3) + .map((d) => d[1]) + + return { + ...this._tags, + disclosureKeys, + } + } + + public clone(): this { + return JsonTransformer.fromJSON(JsonTransformer.toJSON(this), this.constructor as Constructable) + } +} diff --git a/packages/sd-jwt-vc/src/repository/SdJwtVcRepository.ts b/packages/sd-jwt-vc/src/repository/SdJwtVcRepository.ts new file mode 100644 index 0000000000..7ad7c2ecb8 --- /dev/null +++ b/packages/sd-jwt-vc/src/repository/SdJwtVcRepository.ts @@ -0,0 +1,13 @@ +import { EventEmitter, InjectionSymbols, inject, injectable, Repository, StorageService } from '@aries-framework/core' + +import { SdJwtVcRecord } from './SdJwtVcRecord' + +@injectable() +export class SdJwtVcRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(SdJwtVcRecord, storageService, eventEmitter) + } +} diff --git a/packages/sd-jwt-vc/src/repository/__tests__/SdJwtVcRecord.test.ts b/packages/sd-jwt-vc/src/repository/__tests__/SdJwtVcRecord.test.ts new file mode 100644 index 0000000000..5033a32974 --- /dev/null +++ b/packages/sd-jwt-vc/src/repository/__tests__/SdJwtVcRecord.test.ts @@ -0,0 +1,121 @@ +import { JsonTransformer } from '@aries-framework/core' +import { SdJwtVc, SignatureAndEncryptionAlgorithm } from 'jwt-sd' + +import { SdJwtVcRecord } from '../SdJwtVcRecord' + +describe('SdJwtVcRecord', () => { + const holderDidUrl = 'did:key:zUC74VEqqhEHQcgv4zagSPkqFJxuNWuoBPKjJuHETEUeHLoSqWt92viSsmaWjy82y' + + test('sets the values passed in the constructor on the record', () => { + const createdAt = new Date() + const sdJwtVcRecord = new SdJwtVcRecord({ + id: 'sdjwt-id', + createdAt, + tags: { + some: 'tag', + }, + sdJwtVc: { + header: { alg: SignatureAndEncryptionAlgorithm.EdDSA }, + payload: { iss: 'did:key:123' }, + signature: new Uint8Array(32).fill(42), + holderDidUrl, + }, + }) + + expect(sdJwtVcRecord.type).toBe('SdJwtVcRecord') + expect(sdJwtVcRecord.id).toBe('sdjwt-id') + expect(sdJwtVcRecord.createdAt).toBe(createdAt) + expect(sdJwtVcRecord.getTags()).toEqual({ + some: 'tag', + }) + expect(sdJwtVcRecord.sdJwtVc).toEqual({ + header: { alg: SignatureAndEncryptionAlgorithm.EdDSA }, + payload: { iss: 'did:key:123' }, + signature: new Uint8Array(32).fill(42), + holderDidUrl, + }) + }) + + test('serializes and deserializes', () => { + const createdAt = new Date('2022-02-02') + const sdJwtVcRecord = new SdJwtVcRecord({ + id: 'sdjwt-id', + createdAt, + tags: { + some: 'tag', + }, + sdJwtVc: { + header: { alg: SignatureAndEncryptionAlgorithm.EdDSA }, + payload: { iss: 'did:key:123' }, + signature: new Uint8Array(32).fill(42), + holderDidUrl, + }, + }) + + const json = sdJwtVcRecord.toJSON() + expect(json).toMatchObject({ + id: 'sdjwt-id', + createdAt: '2022-02-02T00:00:00.000Z', + metadata: {}, + _tags: { + some: 'tag', + }, + sdJwtVc: { + header: { alg: SignatureAndEncryptionAlgorithm.EdDSA }, + payload: { iss: 'did:key:123' }, + signature: new Uint8Array(32).fill(42), + }, + }) + + const instance = JsonTransformer.fromJSON(json, SdJwtVcRecord) + + expect(instance.type).toBe('SdJwtVcRecord') + expect(instance.id).toBe('sdjwt-id') + expect(instance.createdAt.getTime()).toBe(createdAt.getTime()) + expect(instance.getTags()).toEqual({ + some: 'tag', + }) + expect(instance.sdJwtVc).toMatchObject({ + header: { alg: SignatureAndEncryptionAlgorithm.EdDSA }, + payload: { iss: 'did:key:123' }, + signature: new Uint8Array(32).fill(42), + }) + }) + + test('Get the pretty claims', async () => { + const compactSdJwtVc = + 'eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCJ9.eyJ0eXBlIjoiSWRlbnRpdHlDcmVkZW50aWFsIiwiY25mIjp7Imp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6IlVXM3ZWRWp3UmYwSWt0Sm9jdktSbUdIekhmV0FMdF9YMkswd3ZsdVpJU3MifX0sImlzcyI6ImRpZDprZXk6MTIzIiwiaWF0IjoxNjk4MTUxNTMyLCJfc2RfYWxnIjoic2hhLTI1NiIsIl9zZCI6WyJ2Y3ZGVTREc0ZLVHFRMXZsNG5lbEpXWFRiXy0wZE5vQmtzNmlxTkZwdHlnIl19.IW6PaMTtxMNvqwrRac5nh7L9_ie4r-PUDL6Gqoey2O3axTm6RBrUv0ETLbdgALK6tU_HoIDuNE66DVrISQXaCw~WyJzYWx0IiwiY2xhaW0iLCJzb21lLWNsYWltIl0~' + + const sdJwtVc = SdJwtVc.fromCompact(compactSdJwtVc) + + const sdJwtVcRecord = new SdJwtVcRecord({ + tags: { + some: 'tag', + }, + sdJwtVc: { + header: sdJwtVc.header, + payload: sdJwtVc.payload, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + signature: sdJwtVc.signature!, + disclosures: sdJwtVc.disclosures?.map((d) => d.decoded), + holderDidUrl, + }, + }) + + const prettyClaims = await sdJwtVcRecord.getPrettyClaims() + + expect(prettyClaims).toEqual({ + type: 'IdentityCredential', + cnf: { + jwk: { + kty: 'OKP', + crv: 'Ed25519', + x: 'UW3vVEjwRf0IktJocvKRmGHzHfWALt_X2K0wvluZISs', + }, + }, + iss: 'did:key:123', + iat: 1698151532, + claim: 'some-claim', + }) + }) +}) diff --git a/packages/sd-jwt-vc/src/repository/index.ts b/packages/sd-jwt-vc/src/repository/index.ts new file mode 100644 index 0000000000..ce10b08020 --- /dev/null +++ b/packages/sd-jwt-vc/src/repository/index.ts @@ -0,0 +1,2 @@ +export * from './SdJwtVcRecord' +export * from './SdJwtVcRepository' diff --git a/packages/sd-jwt-vc/tests/sdJwtVc.e2e.test.ts b/packages/sd-jwt-vc/tests/sdJwtVc.e2e.test.ts new file mode 100644 index 0000000000..9db27d97fe --- /dev/null +++ b/packages/sd-jwt-vc/tests/sdJwtVc.e2e.test.ts @@ -0,0 +1,144 @@ +import type { Key } from '@aries-framework/core' + +import { AskarModule } from '@aries-framework/askar' +import { + Agent, + DidKey, + DidsModule, + KeyDidRegistrar, + KeyDidResolver, + KeyType, + TypedArrayEncoder, + utils, +} from '@aries-framework/core' +import { ariesAskar } from '@hyperledger/aries-askar-nodejs' + +import { agentDependencies } from '../../core/tests' +import { SdJwtVcModule } from '../src' + +const getAgent = (label: string) => + new Agent({ + config: { label, walletConfig: { id: utils.uuid(), key: utils.uuid() } }, + modules: { + sdJwt: new SdJwtVcModule(), + askar: new AskarModule({ ariesAskar }), + dids: new DidsModule({ + resolvers: [new KeyDidResolver()], + registrars: [new KeyDidRegistrar()], + }), + }, + dependencies: agentDependencies, + }) + +describe('sd-jwt-vc end to end test', () => { + const issuer = getAgent('sdjwtvcissueragent') + let issuerKey: Key + let issuerDidUrl: string + + const holder = getAgent('sdjwtvcholderagent') + let holderKey: Key + let holderDidUrl: string + + const verifier = getAgent('sdjwtvcverifieragent') + const verifierDid = 'did:key:zUC74VEqqhEHQcgv4zagSPkqFJxuNWuoBPKjJuHETEUeHLoSqWt92viSsmaWjy82y' + + beforeAll(async () => { + await issuer.initialize() + issuerKey = await issuer.context.wallet.createKey({ + keyType: KeyType.Ed25519, + seed: TypedArrayEncoder.fromString('00000000000000000000000000000000'), + }) + + const issuerDidKey = new DidKey(issuerKey) + const issuerDidDocument = issuerDidKey.didDocument + issuerDidUrl = (issuerDidDocument.verificationMethod ?? [])[0].id + await issuer.dids.import({ didDocument: issuerDidDocument, did: issuerDidDocument.id }) + + await holder.initialize() + holderKey = await holder.context.wallet.createKey({ + keyType: KeyType.Ed25519, + seed: TypedArrayEncoder.fromString('00000000000000000000000000000001'), + }) + + const holderDidKey = new DidKey(holderKey) + const holderDidDocument = holderDidKey.didDocument + holderDidUrl = (holderDidDocument.verificationMethod ?? [])[0].id + await holder.dids.import({ didDocument: holderDidDocument, did: holderDidDocument.id }) + + await verifier.initialize() + }) + + test('end to end flow', async () => { + const credential = { + type: 'IdentityCredential', + given_name: 'John', + family_name: 'Doe', + email: 'johndoe@example.com', + phone_number: '+1-202-555-0101', + address: { + street_address: '123 Main St', + locality: 'Anytown', + region: 'Anystate', + country: 'US', + }, + birthdate: '1940-01-01', + is_over_18: true, + is_over_21: true, + is_over_65: true, + } + + const { compact } = await issuer.modules.sdJwt.create(credential, { + holderDidUrl, + issuerDidUrl, + disclosureFrame: { + is_over_65: true, + is_over_21: true, + is_over_18: true, + birthdate: true, + email: true, + address: { country: true, region: true, locality: true, __decoyCount: 2, street_address: true }, + __decoyCount: 2, + given_name: true, + family_name: true, + phone_number: true, + }, + }) + + const sdJwtVcRecord = await holder.modules.sdJwt.storeCredential(compact, { issuerDidUrl, holderDidUrl }) + + // Metadata created by the verifier and send out of band by the verifier to the holder + const verifierMetadata = { + verifierDid, + issuedAt: new Date().getTime() / 1000, + nonce: await verifier.wallet.generateNonce(), + } + + const presentation = await holder.modules.sdJwt.present(sdJwtVcRecord, { + verifierMetadata, + includedDisclosureIndices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + }) + + const { + validation: { isValid }, + } = await verifier.modules.sdJwt.verify(presentation, { + holderDidUrl, + challenge: { verifierDid }, + requiredClaimKeys: [ + 'is_over_65', + 'is_over_21', + 'is_over_18', + 'birthdate', + 'email', + 'country', + 'region', + 'locality', + 'street_address', + 'given_name', + 'family_name', + 'phone_number', + ], + }) + + expect(isValid).toBeTruthy() + }) +}) diff --git a/packages/sd-jwt-vc/tests/setup.ts b/packages/sd-jwt-vc/tests/setup.ts new file mode 100644 index 0000000000..78143033f2 --- /dev/null +++ b/packages/sd-jwt-vc/tests/setup.ts @@ -0,0 +1,3 @@ +import 'reflect-metadata' + +jest.setTimeout(120000) diff --git a/packages/sd-jwt-vc/tsconfig.build.json b/packages/sd-jwt-vc/tsconfig.build.json new file mode 100644 index 0000000000..2b75d0adab --- /dev/null +++ b/packages/sd-jwt-vc/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["src/**/*"] +} diff --git a/packages/sd-jwt-vc/tsconfig.json b/packages/sd-jwt-vc/tsconfig.json new file mode 100644 index 0000000000..46efe6f721 --- /dev/null +++ b/packages/sd-jwt-vc/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + } +} diff --git a/yarn.lock b/yarn.lock index 0bc9b029c1..2d0e538f1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7911,6 +7911,13 @@ jwt-decode@^3.1.2: resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== +jwt-sd@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/jwt-sd/-/jwt-sd-0.1.2.tgz#e03d1a2fed7aadd94ee3c6af6594e40023230ff0" + integrity sha512-bFoAlIBkO6FtfaLZ7YxCHMMWDHoy/eNfw8Kkww9iExHA1si3SxKLTi1TpMmUWfwD37NQgJu2j9PkKHXwI6hGPw== + dependencies: + buffer "^6.0.3" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"