Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22
24
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ PNPM is used for its speed and solution to NPM doppelgangers.

### Requirements

Node.js >= 22 is required (see `.nvmrc`).
Node.js >= 24.9 is required (see `.nvmrc`).

pnpm is required. With Node.js installed, the easiest way to get it is `corepack enable`
(the version is pinned by the `packageManager` field in `package.json`).
Expand Down
2 changes: 1 addition & 1 deletion apps/vc-api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22 AS base
FROM node:24 AS base

WORKDIR /app

Expand Down
61 changes: 34 additions & 27 deletions apps/vc-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,64 +14,66 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest && jest --config ./test/jest-e2e.json",
"test:unit": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test": "cross-env \"NODE_OPTIONS=--experimental-vm-modules --require ../../jest-setup/preload.cjs\" jest && cross-env \"NODE_OPTIONS=--experimental-vm-modules --require ../../jest-setup/preload.cjs\" jest --config ./test/jest-e2e.json",
"test:unit": "cross-env \"NODE_OPTIONS=--experimental-vm-modules --require ../../jest-setup/preload.cjs\" jest",
"test:watch": "cross-env \"NODE_OPTIONS=--experimental-vm-modules --require ../../jest-setup/preload.cjs\" jest --watch",
"test:cov": "cross-env \"NODE_OPTIONS=--experimental-vm-modules --require ../../jest-setup/preload.cjs\" jest --coverage",
"test:debug": "node --experimental-vm-modules --require ../../jest-setup/preload.cjs --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "cross-env \"NODE_OPTIONS=--experimental-vm-modules --require ../../jest-setup/preload.cjs\" jest --config ./test/jest-e2e.json",
"write-openapi": "ts-node ./scripts/write-open-api-json.ts && prettier -w docs/openapi.json",
"update-openapi": "npm run write-openapi; git add docs/openapi.json"
},
"dependencies": {
"@credo-ts/askar": "catalog:",
"@credo-ts/core": "catalog:",
"@credo-ts/node": "catalog:",
"@digitalcredentials/jsonld-signatures": "^12.0.1",
"@energyweb/ssi-did": "workspace:*",
"@energyweb/w3c-ccg-webkms": "workspace:*",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "catalog:",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "catalog:",
"@nestjs/platform-express": "catalog:",
"@nestjs/serve-static": "^5.0.0",
"@nestjs/swagger": "^11.4.0",
"@nestjs/typeorm": "^11.0.0",
"@openwallet-foundation/askar-nodejs": "catalog:",
"@openwallet-foundation/askar-shared": "catalog:",
"@sphereon/pex": "1.1.3",
"axios": "~1.18.1",
"class-transformer": "~0.5.1",
"class-validator": "~0.14.3",
"did-resolver": "catalog:",
"joi": "^17.9.2",
"jose": "catalog:",
"reflect-metadata": "catalog:",
"rimraf": "catalog:",
"rxjs": "^7.8.1",
"typeorm": "^0.3.17",
"did-resolver": "catalog:",
"class-validator": "0.14.1",
"class-transformer": "~0.5.1",
"@nestjs/swagger": "^11.4.0",
"sqlite3": "~5.1.7",
"swagger-ui-express": "~5.0.1",
"uuid": "~9.0.0",
"@sphereon/pex": "1.1.3",
"@nestjs/axios": "^4.0.0",
"@nestjs/serve-static": "^5.0.0",
"joi": "^17.9.2",
"axios": "~1.18.1",
"@credo-ts/askar": "catalog:",
"@credo-ts/core": "catalog:",
"@credo-ts/node": "catalog:",
"@hyperledger/aries-askar-nodejs": "catalog:",
"@hyperledger/aries-askar-shared": "^0.2.3",
"sqlite3": "~5.1.7"
"typeorm": "^0.3.17",
"uuid": "~9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "catalog:",
"@sphereon/pex-models": "~2.0.2",
"@types/express": "^5.0.0",
"@types/jest": "catalog:",
"@types/node": "catalog:",
"@types/supertest": "catalog:",
"cross-env": "catalog:",
"jest": "catalog:",
"nock": "^14.0.16",
"prettier": "catalog:",
"supertest": "catalog:",
"ts-jest": "catalog:",
"ts-loader": "catalog:",
"ts-node": "catalog:",
"tsconfig-paths": "catalog:",
"typescript": "catalog:",
"nock": "^14.0.0",
"@sphereon/pex-models": "~2.0.2"
"typescript": "catalog:"
},
"jest": {
"moduleFileExtensions": [
Expand All @@ -88,6 +90,11 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"moduleNameMapper": {
"^@openwallet-foundation/askar-nodejs$": "<rootDir>/../../../jest-setup/askar-nodejs.cjs",
"^@openwallet-foundation/askar-shared$": "<rootDir>/../../../jest-setup/askar-shared.cjs",
"^webcrypto-core$": "<rootDir>/../../../jest-setup/webcrypto-core.cjs"
}
}
}
36 changes: 31 additions & 5 deletions apps/vc-api/src/credo/__mocks__/credo.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import { Kms } from '@credo-ts/core';

/**
* In-memory stand-in for the agent's KMS: importKey stores the private JWK
* under its kid, getPublicKey returns the public part or throws the same
* not-found error as the real KMS.
*/
const kmsKeys = new Map<string, Record<string, unknown>>();

export const resetMockKms = () => kmsKeys.clear();

export const mockCredoService = {
wallet: {
withSession: jest.fn()
},
withAskarSession: jest.fn(),
agent: {
wallet: {
createKey: jest.fn()
kms: {
importKey: jest.fn(async ({ privateJwk }) => {
kmsKeys.set(privateJwk.kid, privateJwk);
const { d: _d, ...publicJwk } = privateJwk; // eslint-disable-line @typescript-eslint/no-unused-vars
return { keyId: privateJwk.kid, publicJwk };
}),
getPublicKey: jest.fn(async ({ keyId }) => {
const jwk = kmsKeys.get(keyId);
if (!jwk) {
throw new Kms.KeyManagementKeyNotFoundError(keyId, ['askar']);
}
const { d: _d, ...publicJwk } = jwk; // eslint-disable-line @typescript-eslint/no-unused-vars
return { ...publicJwk, kid: keyId };
})
},
dids: {
getCreatedDids: jest.fn().mockResolvedValue([]),
create: jest.fn(),
resolveDidDocument: jest.fn()
},
w3cCredentials: {
signCredential: jest.fn(),
Expand Down
89 changes: 43 additions & 46 deletions apps/vc-api/src/credo/credo.service.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,60 @@
import { AskarModule, AskarWallet } from '@credo-ts/askar';
import { Agent, InitConfig, SigningProviderRegistry, ConsoleLogger, WalletConfig } from '@credo-ts/core';
// IMPORTANT: '@openwallet-foundation/askar-nodejs' must be imported before any
// '@credo-ts/*' package. Importing it registers the native askar binding on
// the (CommonJS) '@openwallet-foundation/askar-shared' package; the ESM-only
// Credo packages snapshot that binding when they load, so loading Credo first
// leaves them with an unregistered (undefined) binding.
import { askar } from '@openwallet-foundation/askar-nodejs';
import { AskarModule, AskarStoreManager } from '@credo-ts/askar';
import { Agent } from '@credo-ts/core';
import { agentDependencies } from '@credo-ts/node';
import { ariesAskar } from '@hyperledger/aries-askar-nodejs';
import { Session } from '@openwallet-foundation/askar-shared';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class CredoService implements OnModuleInit, OnModuleDestroy {
private readonly walletConfig: WalletConfig;
private readonly askarAgent: Agent<{ askar: AskarModule }>;
private readonly askarWallet: AskarWallet;
private initialized = false;

constructor(private configService: ConfigService) {
this.walletConfig = {
id: this.configService.get<string>('CREDO_WALLET_ID'),
key: this.configService.get<string>('CREDO_WALLET_KEY'),
storage: {
type: this.configService.get<string>('CREDO_WALLET_DB_TYPE'),
config: {
path: `${this.configService.get<string>('DB_BASE_PATH')}/${this.configService.get<string>(
'CREDO_WALLET_ID'
)}/sqlite.db`
}
}
};

// Initialize agent and wallet synchronously
const config: InitConfig = {
label: this.configService.get<string>('CREDO_LABEL'),
walletConfig: this.walletConfig
};

// Create the agent
// Create the agent. Since Credo 0.6 the store configuration lives on the
// Askar module rather than the agent config ("walletConfig").
this.askarAgent = new Agent({
config,
config: {
// Migrates records of pre-0.6 stores on first startup
autoUpdateStorageOnStartup: true
},
dependencies: agentDependencies,
modules: {
askar: new AskarModule({
ariesAskar
askar,
store: {
id: this.configService.get<string>('CREDO_WALLET_ID'),
key: this.configService.get<string>('CREDO_WALLET_KEY'),
database: {
type: 'sqlite',
config: {
path: `${this.configService.get<string>('DB_BASE_PATH')}/${this.configService.get<string>(
'CREDO_WALLET_ID'
)}/sqlite.db`
}
}
}
})
}
});

// Create the wallet
this.askarWallet = new AskarWallet(
new ConsoleLogger(),
new agentDependencies.FileSystem(),
new SigningProviderRegistry([])
);
}

async onModuleInit() {
// This method is called after the module is initialized
await this.initialize();
}

// initializes the askar agent and wallet for operations
// initializes the askar agent for operations
private async initialize() {
if (!this.askarAgent.isInitialized) {
await this.askarAgent.initialize();
}
await this.askarWallet.open(this.walletConfig);
this.initialized = true;
}

Expand All @@ -73,12 +66,17 @@ export class CredoService implements OnModuleInit, OnModuleDestroy {
return this.askarAgent;
}

// Accessor for the wallet
public get wallet(): AskarWallet {
if (!this.initialized) {
throw new Error('Credo wallet is not initialized yet.');
}
return this.askarWallet;
/**
* Run a callback with a raw Askar session.
*
* The Credo 0.6+ KMS API intentionally has no private-key export, but this
* API exposes key export (see the key module). Going through the Askar
* store directly keeps that behaviour; it also couples the key module to
* the Askar backend.
*/
public async withAskarSession<Return>(callback: (session: Session) => Return): Promise<Awaited<Return>> {
const storeManager = this.agent.dependencyManager.resolve(AskarStoreManager);
return await storeManager.withSession(this.askarAgent.context, callback);
}

async onModuleDestroy() {
Expand All @@ -87,10 +85,9 @@ export class CredoService implements OnModuleInit, OnModuleDestroy {
}

private async cleanup() {
if (this.agent.isInitialized) {
// close askar agent connection
await this.wallet.close();
await this.agent.shutdown();
if (this.askarAgent.isInitialized) {
// the agent owns the store lifecycle since Credo 0.6
await this.askarAgent.shutdown();
}
}
}
7 changes: 4 additions & 3 deletions apps/vc-api/src/did/did.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ import { DIDService } from './did.service';
import { DIDDocumentEntity } from './entities/did-document.entity';
import { VerificationMethodEntity } from './entities/verification-method.entity';
import { CredoModule } from '../credo/credo.module';
import { didDocument, generatedKey, keyEntryObject, keyPair } from '../../test/did/did.service.spec.data';
import { didDocument, generatedKey, publicKeyJwk, keyPair } from '../../test/did/did.service.spec.data';
import { CredoService } from '../credo/credo.service';
import { mockCredoService } from '../credo/__mocks__/credo.service';
import { mockCredoService, resetMockKms } from '../credo/__mocks__/credo.service';

describe('DIDService', () => {
let service: DIDService;
let keyService: KeyService;

beforeEach(async () => {
resetMockKms();
const module: TestingModule = await Test.createTestingModule({
imports: [
KeyModule,
Expand All @@ -42,7 +43,7 @@ describe('DIDService', () => {
service = module.get<DIDService>(DIDService);

jest.spyOn(keyService, 'generateKey').mockResolvedValue(generatedKey);
jest.spyOn(keyService, 'fetchKey').mockResolvedValue(keyEntryObject);
jest.spyOn(keyService, 'getPublicKeyFromKeyId').mockResolvedValue(publicKeyJwk);
jest.spyOn(DIDKeyFactory, 'generate').mockResolvedValue(didDocument);
});

Expand Down
6 changes: 2 additions & 4 deletions apps/vc-api/src/did/did.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,13 @@ export class DIDService {
* @returns DID Document of registered DID
*/
public async registerKeyDID(keyId: string): Promise<DIDDocument> {
const key = await this.keyService.fetchKey(keyId);
const key = await this.keyService.getPublicKeyFromKeyId(keyId);

if (!key) {
throw new BadRequestException(`keyId=${keyId} not found`);
}

// Need to set kty because it is possibly undefined in 'jose' JWK type
const difKey = { ...key?.key?.jwkPublic, kty: 'OKP' };
const didDoc = await DIDKeyFactory.generate(this.credoService.agent, difKey);
const didDoc = await DIDKeyFactory.generate(this.credoService.agent, keyId);
return didDoc;
}

Expand Down
Loading
Loading