diff --git a/README.md b/README.md index dcaf9caf0..ce42145db 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Shared libraries used by the Wallet Gateway, SDKs, and signing providers: | `core-signing-participant` | [`core/signing-participant`](core/signing-participant) | Canton participant-managed signing driver | | `core-signing-fireblocks` | [`core/signing-fireblocks`](core/signing-fireblocks) | Fireblocks signing driver integration | | `core-signing-blockdaemon` | [`core/signing-blockdaemon`](core/signing-blockdaemon) | Blockdaemon signing driver integration | +| `core-signing-securosys` | [`core/signing-securosys`](core/signing-securosys) | Securosys TSB signing driver integration | | **RPC & Transport** | | | | `core-types` | [`core/types`](core/types) | Shared types and transport-agnostic parsers | | `core-rpc-transport` | [`core/rpc-transport`](core/rpc-transport) | RPC transport implementations | diff --git a/core/signing-lib/src/config/schema.ts b/core/signing-lib/src/config/schema.ts index e5b92d3ff..0f1205954 100644 --- a/core/signing-lib/src/config/schema.ts +++ b/core/signing-lib/src/config/schema.ts @@ -9,6 +9,7 @@ export enum SigningProvider { FIREBLOCKS = 'fireblocks', BLOCKDAEMON = 'blockdaemon', DFNS = 'dfns', + SECUROSYS = 'securosys', } // Generic signing driver configuration schema diff --git a/core/signing-securosys/README.md b/core/signing-securosys/README.md new file mode 100644 index 000000000..53136f1e1 --- /dev/null +++ b/core/signing-securosys/README.md @@ -0,0 +1,262 @@ +# @canton-network/core-signing-securosys + +Signing driver for integrating the Canton Wallet Gateway with Securosys TSB. + +## Features + +- `createKey` creates a TSB SKA key with a hardcoded empty policy. +- `getKeys` enumerates TSB keys and returns Wallet Gateway-compatible public + keys. +- `signTransaction` creates a TSB sign request and returns the TSB request ID as + the provider `txId`. +- `getTransaction` maps TSB request status/result into Wallet Gateway + transaction status/signature fields. +- `getTransactions` fetches by provider transaction IDs. Public-key-only + filtering is supported from this driver's in-memory transaction cache. +- Runtime configuration can be inspected and changed through + `getConfiguration` / `setConfiguration`. + +## Usage + +```typescript +import SecurosysSigningDriver from '@canton-network/core-signing-securosys' + +const driver = new SecurosysSigningDriver({ + baseUrl: 'http://localhost:8080', + keyManagementApiKey: process.env.TSB_KEY_MANAGEMENT_API_KEY, + keyOperationApiKey: process.env.TSB_KEY_OPERATION_API_KEY, + mtlsP12Path: process.env.TSB_MTLS_P12_PATH, + mtlsP12Password: process.env.TSB_MTLS_P12_PASSWORD, +}) +``` + +The TSB endpoints used by the driver are: + +- `GET /v1/key` +- `POST /v1/key` +- `POST /v1/key/attributes` +- `POST /v1/sign` +- `GET /v1/request/{id}` +- `POST /v1/filteredRequests` +- `DELETE /v1/request/{id}` + +## Configuration + +| Property | Description | +| :-- | :-- | +| `baseUrl` | Base URL of the TSB service. | +| `keyManagementApiKey` | `X-API-KEY` value for `/v1/key` endpoints. | +| `keyOperationApiKey` | `X-API-KEY` value for signing/request-status endpoints. | +| `bearerToken` | Optional bearer access token for access-token mode. | +| `mtlsP12Path` | Optional path to a PKCS#12/P12 client certificate used when TSB requires mTLS. | +| `mtlsP12Password` | Optional password for the PKCS#12/P12 client certificate. | +| `keyPassword` | Optional TSB key password used for key attributes and signing. | +| `signatureAlgorithm` | TSB signature algorithm. Defaults to `EDDSA`; current Wallet Gateway/Canton signing expects Ed25519-compatible signatures. | + +When these values are changed through the Wallet Gateway configuration RPC, use +the existing PascalCase convention: `MtlsP12Path` and `MtlsP12Password`. +`MtlsP12Password` is masked in `getConfiguration`. + +The remote Wallet Gateway reads the same values from these environment +variables: + +| Environment variable | Driver property | +| :-- | :-- | +| `SECUROSYS_TSB_BASE_URL` | `baseUrl` | +| `SECUROSYS_TSB_KEY_MANAGEMENT_API_KEY` | `keyManagementApiKey` | +| `SECUROSYS_TSB_KEY_OPERATION_API_KEY` | `keyOperationApiKey` | +| `SECUROSYS_TSB_BEARER_TOKEN` | `bearerToken` | +| `SECUROSYS_TSB_MTLS_P12_PATH` | `mtlsP12Path` | +| `SECUROSYS_TSB_MTLS_P12_PASSWORD` | `mtlsP12Password` | +| `SECUROSYS_TSB_KEY_PASSWORD` | `keyPassword` | +| `SECUROSYS_TSB_SIGNATURE_ALGORITHM` | `signatureAlgorithm` | + +Every key created by this driver is sent to TSB with the same empty SKA policy: + +```json +{ + "ruleUse": null, + "ruleBlock": null, + "ruleUnblock": null, + "ruleModify": null, + "keyStatus": { + "blocked": false + } +} +``` + +For EdDSA signatures, the driver validates and returns the Wallet +Gateway-compatible format: base64-encoded raw 64-byte Ed25519 signature bytes. +The TSB request payload type is hardcoded to `UNSPECIFIED`, the signature type +is hardcoded to `RAW`, and TSB Ed25519 DER/SPKI public keys are always converted +to the 32-byte raw key expected by the wallet signing API. Simple ASN.1 OCTET +STRING / BIT STRING wrappers and DER `R,S` sequences are still converted as a +compatibility guard before returning the signature. + +## Local wallet deployment + +Run the wallet monorepo commands from the wallet repository root: + +```bash +cd /path/to/wallet +``` + +Use Node.js 20+ for the wallet toolchain. +Install Yarn 4 and the wallet dependencies: + +```bash +npm install -g --force @yarnpkg/cli-dist@4.16.0 +yarn install +``` + +Download the Playwright browsers required by the wallet browser tests: + +```bash +yarn playwright:install +``` + +Download the Canton binary used by the local devnet setup: + +```bash +yarn script:fetch:canton +``` + +Start local Canton on the devnet configuration: + +```bash +yarn start:canton --network=devnet +``` + +Wait until the Canton bootstrap completes. The command can then be interrupted +with `Ctrl+C`; the Canton process keeps running under PM2. + +Start the full wallet stack with Securosys mTLS: + +```bash +SECUROSYS_TSB_BASE_URL=https://integration-test.cloudshsm.com/ \ +SECUROSYS_TSB_MTLS_P12_PATH=./etc/client_mtls_tsb.p12 \ +SECUROSYS_TSB_MTLS_P12_PASSWORD=pass \ +yarn start:all +``` + +Start the full wallet stack with a TSB bearer token instead: + +```bash +SECUROSYS_TSB_BASE_URL=https://sbx-rest-api.cloudshsm.com \ +SECUROSYS_TSB_BEARER_TOKEN="" \ +yarn start:all +``` + +Open the Wallet Gateway UI: + +```bash +open http://localhost:3030 +``` + +Check gateway health and readiness: + +```bash +curl -i http://localhost:3030/healthz +curl -i http://localhost:3030/readyz +``` + +## Process management + +List all PM2-managed wallet processes: + +```bash +yarn pm2 list +``` + +Inspect the remote Wallet Gateway logs: + +```bash +yarn pm2 logs remote +``` + +Inspect the Canton logs: + +```bash +yarn pm2 logs canton +``` + +Restart only the remote Wallet Gateway backend: + +```bash +yarn pm2 restart remote +``` + +Stop all PM2-managed wallet processes: + +```bash +yarn stop:all +``` + +Fully kill the PM2 daemon and all managed processes: + +```bash +yarn pm2 kill +``` + +## Build and test + +Build only this signing driver: + +```bash +yarn workspace @canton-network/core-signing-securosys build +``` + +Run only this signing driver's tests: + +```bash +yarn workspace @canton-network/core-signing-securosys test +``` + +Run this signing driver's tests with coverage: + +```bash +yarn workspace @canton-network/core-signing-securosys test:coverage +``` + +Build the remote Wallet Gateway: + +```bash +yarn workspace @canton-network/wallet-gateway-remote build +``` + +Run the remote Wallet Gateway transaction-signing tests: + +```bash +yarn workspace @canton-network/wallet-gateway-remote test src/ledger/transaction-service.test.ts +``` + +Run the wallet allocation tests: + +```bash +yarn workspace @canton-network/wallet-gateway-remote test src/ledger/wallet-allocation/wallet-allocation-service.test.ts +``` + +Run the shared signing-library tests: + +```bash +yarn workspace @canton-network/core-signing-lib test +``` + +Build the full wallet monorepo serially: + +```bash +yarn build:all:serial +``` + +Run the full wallet monorepo test suite: + +```bash +yarn test:all +``` + +## References + +- Upstream signing interface: + +- Blockdaemon signing driver used as the implementation reference: + diff --git a/core/signing-securosys/package.json b/core/signing-securosys/package.json new file mode 100644 index 000000000..dd5a2e583 --- /dev/null +++ b/core/signing-securosys/package.json @@ -0,0 +1,51 @@ +{ + "name": "@canton-network/core-signing-securosys", + "version": "0.1.0", + "type": "module", + "description": "Wallet Gateway signing driver for Securosys TSB", + "license": "Apache-2.0", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --onSuccess \"tsc\"", + "clean": "rm -rf ./dist ./coverage", + "test": "vitest run --project node", + "test:coverage": "vitest run --project node --coverage" + }, + "dependencies": { + "@canton-network/core-signing-lib": "workspace:^", + "@canton-network/core-wallet-auth": "workspace:^", + "undici": "^7.24.5" + }, + "devDependencies": { + "@types/node": "^25.9.4", + "@vitest/coverage-v8": "^4.1.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + }, + "files": [ + "dist/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/canton-network/wallet.git", + "directory": "core/signing-securosys" + }, + "homepage": "https://github.com/canton-network/wallet/tree/main/core/signing-securosys#readme", + "bugs": { + "url": "https://github.com/canton-network/wallet/issues" + } +} diff --git a/core/signing-securosys/src/index.test.ts b/core/signing-securosys/src/index.test.ts new file mode 100644 index 000000000..8bed643d4 --- /dev/null +++ b/core/signing-securosys/src/index.test.ts @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: Copyright 2026 Securosys SA +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, type Mocked, vi } from 'vitest' +import type { Key, Transaction } from '@canton-network/core-signing-lib' +import SecurosysSigningDriver, { + SECUROSYS_SIGNING_PROVIDER, +} from './index.js' +import { SigningAPIClient } from './signing-api-sdk.js' + +describe('SecurosysSigningDriver constructor', () => { + it('uses the securosys provider string', () => { + const driver = new SecurosysSigningDriver({ + baseUrl: 'http://localhost:8080', + }) + + expect(driver.signingProvider).toBe(SECUROSYS_SIGNING_PROVIDER) + expect(driver.signingProvider).toBe('securosys') + }) + + it('passes config to the client', () => { + const driver = new SecurosysSigningDriver({ + baseUrl: 'http://localhost:8080/', + keyOperationApiKey: 'operation', + }) + const client = (driver as unknown as { client: SigningAPIClient }) + .client + + expect(client.getConfiguration()).toMatchObject({ + BaseURL: 'http://localhost:8080', + KeyOperationApiKey: 'operation', + }) + }) +}) + +describe('SecurosysSigningDriver', () => { + const userId = 'wallet-user' + + let driver: SecurosysSigningDriver + let mockClient: Mocked + + beforeEach(() => { + vi.clearAllMocks() + + mockClient = { + signTransaction: vi.fn(), + getTransaction: vi.fn(), + getTransactions: vi.fn(), + getKeys: vi.fn(), + createKey: vi.fn(), + cancelTransaction: vi.fn(), + getKeyAttributes: vi.fn(), + getConfiguration: vi.fn().mockReturnValue({ + BaseURL: 'http://localhost:8080', + KeyManagementApiKey: 'key-secret', + KeyOperationApiKey: 'operation-secret', + BearerToken: 'bearer-secret', + MtlsP12Path: '/certs/client.p12', + MtlsP12Password: 'mtls-secret', + KeyPassword: 'password-secret', + SignatureAlgorithm: 'EDDSA', + }), + setConfiguration: vi.fn(), + } as unknown as Mocked + + driver = new SecurosysSigningDriver({ + baseUrl: 'http://localhost:8080', + }) + ;(driver as unknown as { client: SigningAPIClient }).client = mockClient + }) + + it('signTransaction calls the client with userIdentifier', async () => { + mockClient.signTransaction.mockResolvedValue({ + txId: 'tsb-request-id', + status: 'pending', + publicKey: 'public-key', + metadata: { tsbStatus: 'PENDING' }, + } as Transaction) + + const result = await driver.controller(userId).signTransaction({ + tx: 'tx', + txHash: 'hash', + keyIdentifier: { id: 'key-name' }, + internalTxId: 'wallet-tx-id', + }) + + expect(mockClient.signTransaction).toHaveBeenCalledWith({ + tx: 'tx', + txHash: 'hash', + keyIdentifier: { id: 'key-name' }, + internalTxId: 'wallet-tx-id', + userIdentifier: userId, + }) + expect(result).toEqual({ + txId: 'tsb-request-id', + status: 'pending', + publicKey: 'public-key', + metadata: { tsbStatus: 'PENDING' }, + }) + }) + + it('signTransaction requires id or publicKey', async () => { + const result = await driver.controller(userId).signTransaction({ + tx: 'tx', + txHash: 'hash', + keyIdentifier: {} as never, + }) + + expect(result).toEqual({ + error: 'key_not_found', + error_description: + 'The provided key identifier must include an id or publicKey.', + }) + expect(mockClient.signTransaction).not.toHaveBeenCalled() + }) + + it('signTransaction returns signing_error when the client throws', async () => { + mockClient.signTransaction.mockRejectedValue(new Error('TSB down')) + + const result = await driver.controller(userId).signTransaction({ + tx: 'tx', + txHash: 'hash', + keyIdentifier: { publicKey: 'pk' }, + }) + + expect(result).toEqual({ + error: 'signing_error', + error_description: 'TSB down', + }) + }) + + it('signMessage is explicitly unsupported', async () => { + const result = await driver.controller(userId).signMessage({ + message: 'hello', + keyIdentifier: { id: 'key' }, + }) + + expect(result).toEqual({ + error: 'not_allowed', + error_description: + 'Signing messages is not supported by the Securosys TSB signing driver.', + }) + }) + + it('getTransaction maps client transactions', async () => { + mockClient.getTransaction.mockResolvedValue({ + txId: 'req-1', + status: 'signed', + signature: 'signature', + publicKey: 'public-key', + metadata: { tsbStatus: 'EXECUTED' }, + } as Transaction) + + const result = await driver + .controller(userId) + .getTransaction({ txId: 'req-1' }) + + expect(result).toEqual({ + txId: 'req-1', + status: 'signed', + signature: 'signature', + publicKey: 'public-key', + metadata: { tsbStatus: 'EXECUTED' }, + }) + }) + + it('getTransaction returns transaction_not_found when the client throws', async () => { + mockClient.getTransaction.mockRejectedValue(new Error('not found')) + + const result = await driver + .controller(userId) + .getTransaction({ txId: 'missing' }) + + expect(result).toEqual({ + error: 'transaction_not_found', + error_description: 'not found', + }) + }) + + it('getTransactions requires filters', async () => { + const result = await driver.controller(userId).getTransactions({}) + + expect(result).toEqual({ + error: 'bad_arguments', + error_description: 'either public key or txIds must be supplied', + }) + expect(mockClient.getTransactions).not.toHaveBeenCalled() + }) + + it('getTransactions returns mapped transactions', async () => { + mockClient.getTransactions.mockResolvedValue([ + { + txId: 'req-1', + status: 'signed', + signature: 'signature', + }, + ] as Transaction[]) + + const result = await driver + .controller(userId) + .getTransactions({ txIds: ['req-1'] }) + + expect(mockClient.getTransactions).toHaveBeenCalledWith({ + txIds: ['req-1'], + publicKeys: undefined, + }) + expect(result).toEqual({ + transactions: [ + { + txId: 'req-1', + status: 'signed', + signature: 'signature', + }, + ], + }) + }) + + it('getKeys attaches the Wallet Gateway user identifier', async () => { + mockClient.getKeys.mockResolvedValue([ + { + id: 'key-1', + name: 'key-1', + publicKey: 'public-key', + }, + ] as Key[]) + + const result = await driver.controller(userId).getKeys() + + expect(result).toEqual({ + keys: [ + { + id: 'key-1', + name: 'key-1', + publicKey: 'public-key', + userIdentifier: userId, + }, + ], + }) + }) + + it('createKey forwards user-scoped params to the client', async () => { + mockClient.createKey.mockResolvedValue({ + id: 'new-key', + name: 'new-key', + publicKey: 'public-key', + } as Key) + + const result = await driver + .controller(userId) + .createKey({ name: 'new-key' }) + + expect(mockClient.createKey).toHaveBeenCalledWith({ + name: 'new-key', + userIdentifier: userId, + }) + expect(result).toEqual({ + id: 'new-key', + name: 'new-key', + publicKey: 'public-key', + }) + }) + + it('getConfiguration masks secrets', async () => { + const result = await driver.controller(userId).getConfiguration() + + expect(result).toMatchObject({ + BaseURL: 'http://localhost:8080', + KeyManagementApiKey: '***HIDDEN***', + KeyOperationApiKey: '***HIDDEN***', + BearerToken: '***HIDDEN***', + MtlsP12Path: '/certs/client.p12', + MtlsP12Password: '***HIDDEN***', + KeyPassword: '***HIDDEN***', + SignatureAlgorithm: 'EDDSA', + }) + expect(result).not.toHaveProperty('CreateKeyRequest') + expect(result).not.toHaveProperty('SignatureType') + expect(result).not.toHaveProperty('PayloadType') + expect(result).not.toHaveProperty('PublicKeyFormat') + }) + + it('setConfiguration forwards supported fields', async () => { + const params = { + BaseURL: 'https://tsb.example', + KeyManagementApiKey: 'key', + KeyOperationApiKey: 'operation', + BearerToken: 'token', + MtlsP12Path: '/certs/client.p12', + MtlsP12Password: 'mtls-secret', + KeyPassword: 'secret', + SignatureAlgorithm: 'SHA256_WITH_ECDSA', + CreateKeyRequest: { algorithm: 'EC' }, + } + mockClient.setConfiguration.mockReturnValue({ + BaseURL: 'https://tsb.example', + KeyManagementApiKey: 'key', + KeyOperationApiKey: 'operation', + BearerToken: 'token', + MtlsP12Path: '/certs/client.p12', + MtlsP12Password: 'mtls-secret', + KeyPassword: 'secret', + SignatureAlgorithm: 'SHA256_WITH_ECDSA', + }) + + const result = await driver.controller(userId).setConfiguration(params) + + expect(mockClient.setConfiguration).toHaveBeenCalledWith({ + BaseURL: 'https://tsb.example', + KeyManagementApiKey: 'key', + KeyOperationApiKey: 'operation', + BearerToken: 'token', + MtlsP12Path: '/certs/client.p12', + MtlsP12Password: 'mtls-secret', + KeyPassword: 'secret', + SignatureAlgorithm: 'SHA256_WITH_ECDSA', + }) + expect(result).toEqual({ + BaseURL: 'https://tsb.example', + SignatureAlgorithm: 'SHA256_WITH_ECDSA', + KeyManagementApiKey: '***HIDDEN***', + KeyOperationApiKey: '***HIDDEN***', + BearerToken: '***HIDDEN***', + MtlsP12Path: '/certs/client.p12', + MtlsP12Password: '***HIDDEN***', + KeyPassword: '***HIDDEN***', + }) + expect(result).not.toHaveProperty('CreateKeyRequest') + }) +}) diff --git a/core/signing-securosys/src/index.ts b/core/signing-securosys/src/index.ts new file mode 100644 index 000000000..59c543fcb --- /dev/null +++ b/core/signing-securosys/src/index.ts @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Copyright 2026 Securosys SA +// SPDX-License-Identifier: Apache-2.0 + +import { + buildController, + type CreateKeyParams, + type CreateKeyResult, + type GetConfigurationResult, + type GetKeysResult, + type GetTransactionParams, + type GetTransactionResult, + type GetTransactionsParams, + type GetTransactionsResult, + PartyMode, + type SetConfigurationParams, + type SetConfigurationResult, + type SigningDriverInterface, + SigningProvider, + type SignMessageResult, + type SignTransactionParams, + type SignTransactionResult, + type SubscribeTransactionsParams, + type SubscribeTransactionsResult, +} from '@canton-network/core-signing-lib' +import type { AuthContext } from '@canton-network/core-wallet-auth' +import { + SigningAPIClient, + type SecurosysTSBClientConfig, + type TsbCreateKeyRequest, + type TsbSignatureAlgorithm, +} from './signing-api-sdk.js' + +export { + mapTsbStatus, + normalizePublicKey, + normalizeSignature, + SigningAPIClient, + type SecurosysTSBClientConfig, + type TsbCreateKeyRequest, + type TsbSignatureAlgorithm, +} from './signing-api-sdk.js' + +export interface SecurosysConfig extends SecurosysTSBClientConfig {} + +export const SECUROSYS_SIGNING_PROVIDER = 'securosys' as SigningProvider + +export default class SecurosysSigningDriver + implements SigningDriverInterface +{ + private client: SigningAPIClient + + constructor(config: SecurosysConfig) { + this.client = new SigningAPIClient(config) + } + + public partyMode = PartyMode.EXTERNAL + public signingProvider = SECUROSYS_SIGNING_PROVIDER + + public controller = (userId: AuthContext['userId'] | undefined) => + buildController({ + signTransaction: async ( + params: SignTransactionParams + ): Promise => { + try { + if ( + params.keyIdentifier.id === undefined && + params.keyIdentifier.publicKey === undefined + ) { + return { + error: 'key_not_found', + error_description: + 'The provided key identifier must include an id or publicKey.', + } + } + + const tx = await this.client.signTransaction({ + ...params, + userIdentifier: userId, + }) + + return { + txId: tx.txId, + status: tx.status, + ...(tx.signature !== undefined && { + signature: tx.signature, + }), + ...(tx.publicKey !== undefined && { + publicKey: tx.publicKey, + }), + ...(tx.metadata !== undefined && { + metadata: tx.metadata, + }), + } + } catch (error) { + return { + error: 'signing_error', + error_description: (error as Error).message, + } + } + }, + + signMessage: async (): Promise => { + return { + error: 'not_allowed', + error_description: + 'Signing messages is not supported by the Securosys TSB signing driver.', + } + }, + + getTransaction: async ( + params: GetTransactionParams + ): Promise => { + try { + const tx = await this.client.getTransaction({ + txId: params.txId, + }) + return { + txId: tx.txId, + status: tx.status, + ...(tx.signature !== undefined && { + signature: tx.signature, + }), + ...(tx.publicKey !== undefined && { + publicKey: tx.publicKey, + }), + ...(tx.metadata !== undefined && { + metadata: tx.metadata, + }), + } + } catch (error) { + return { + error: 'transaction_not_found', + error_description: (error as Error).message, + } + } + }, + + getTransactions: async ( + params: GetTransactionsParams + ): Promise => { + if (params.publicKeys || params.txIds) { + try { + const transactions = await this.client.getTransactions({ + txIds: params.txIds, + publicKeys: params.publicKeys, + }) + + return { + transactions: transactions.map((tx) => ({ + txId: tx.txId, + status: tx.status, + ...(tx.signature !== undefined && { + signature: tx.signature, + }), + ...(tx.publicKey !== undefined && { + publicKey: tx.publicKey, + }), + ...(tx.metadata !== undefined && { + metadata: tx.metadata, + }), + })), + } + } catch (error) { + return { + error: 'fetch_error', + error_description: (error as Error).message, + } + } + } + + return { + error: 'bad_arguments', + error_description: + 'either public key or txIds must be supplied', + } + }, + + getKeys: async (): Promise => { + try { + const keys = await this.client.getKeys() + return { + keys: keys.map((key) => ({ + id: key.id, + name: key.name, + publicKey: key.publicKey, + userIdentifier: userId, + })), + } + } catch (error) { + return { + error: 'fetch_error', + error_description: (error as Error).message, + } + } + }, + + createKey: async ( + params: CreateKeyParams + ): Promise => { + try { + const key = await this.client.createKey({ + ...params, + userIdentifier: userId, + }) + return { + id: key.id, + name: key.name, + publicKey: key.publicKey, + } + } catch (error) { + return { + error: 'create_key_error', + error_description: (error as Error).message, + } + } + }, + + getConfiguration: async (): Promise => { + return maskConfiguration(this.client.getConfiguration()) + }, + + setConfiguration: async ( + params: SetConfigurationParams + ): Promise => { + const config = this.client.setConfiguration({ + BaseURL: params['BaseURL'] as string, + KeyManagementApiKey: params['KeyManagementApiKey'] as string, + KeyOperationApiKey: params['KeyOperationApiKey'] as string, + BearerToken: params['BearerToken'] as string, + MtlsP12Path: params['MtlsP12Path'] as string, + MtlsP12Password: params['MtlsP12Password'] as string, + KeyPassword: params['KeyPassword'] as string, + SignatureAlgorithm: + params['SignatureAlgorithm'] as TsbSignatureAlgorithm, + }) + return maskConfiguration(config) + }, + + subscribeTransactions: async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _params: SubscribeTransactionsParams + ): Promise => + Promise.resolve({} as SubscribeTransactionsResult), + }) +} + +function maskConfiguration( + config: Record +): Record { + const sensitiveFields = new Set([ + 'KeyManagementApiKey', + 'KeyOperationApiKey', + 'BearerToken', + 'MtlsP12Password', + 'KeyPassword', + ]) + + return Object.fromEntries( + Object.entries(config).map(([key, value]) => [ + key, + sensitiveFields.has(key) && value ? '***HIDDEN***' : value, + ]) + ) +} diff --git a/core/signing-securosys/src/signing-api-sdk.test.ts b/core/signing-securosys/src/signing-api-sdk.test.ts new file mode 100644 index 000000000..5b272e8bc --- /dev/null +++ b/core/signing-securosys/src/signing-api-sdk.test.ts @@ -0,0 +1,562 @@ +// SPDX-FileCopyrightText: Copyright 2026 Securosys SA +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mapTsbStatus, + normalizePublicKey, + normalizeSignature, + SigningAPIClient, +} from './signing-api-sdk.js' + +describe('SigningAPIClient', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + } + + function callsFor(endpoint: string) { + return fetchMock.mock.calls.filter((call) => + String(call[0]).endsWith(endpoint) + ) + } + + function initFor(endpoint: string): RequestInit { + const call = callsFor(endpoint)[0] + expect(call).toBeDefined() + return call![1] as RequestInit + } + + it('constructor strips a trailing slash from the base URL', () => { + const client = new SigningAPIClient('http://tsb.example/') + expect(client.getConfiguration().BaseURL).toBe('http://tsb.example') + }) + + it('normalizes Ed25519 DER/SPKI public keys to raw public keys', () => { + const raw = Buffer.alloc(32, 7) + const der = Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + raw, + ]) + + expect(normalizePublicKey(der.toString('base64'))).toBe( + raw.toString('base64') + ) + }) + + it('keeps unknown public key encodings unchanged', () => { + const publicKey = Buffer.alloc(33, 1).toString('base64') + expect(normalizePublicKey(publicKey)).toBe(publicKey) + }) + + it('keeps raw Ed25519 signatures in wallet format', () => { + const rawSignature = Buffer.alloc(64, 11).toString('base64') + + expect(normalizeSignature(rawSignature)).toBe(rawSignature) + }) + + it('unwraps simple ASN.1 Ed25519 signature containers', () => { + const rawSignature = Buffer.alloc(64, 12) + const octetString = Buffer.concat([ + Buffer.from('0440', 'hex'), + rawSignature, + ]) + const bitString = Buffer.concat([ + Buffer.from('034100', 'hex'), + rawSignature, + ]) + + expect(normalizeSignature(octetString.toString('base64'))).toBe( + rawSignature.toString('base64') + ) + expect(normalizeSignature(bitString.toString('base64'))).toBe( + rawSignature.toString('base64') + ) + }) + + it('converts DER integer-pair Ed25519 signatures to wallet format', () => { + const r = Buffer.from('80'.repeat(32), 'hex') + const s = Buffer.from('01'.repeat(32), 'hex') + const der = Buffer.concat([ + Buffer.from([0x30, 0x45]), + Buffer.from([0x02, 0x21, 0x00]), + r, + Buffer.from([0x02, 0x20]), + s, + ]) + + expect(normalizeSignature(der.toString('base64'))).toBe( + Buffer.concat([r, s]).toString('base64') + ) + }) + + it('rejects wallet-incompatible Ed25519 signatures', () => { + expect(() => normalizeSignature(Buffer.alloc(65).toString('base64'))).toThrow( + 'Wallet Gateway expects a raw 64-byte Ed25519 signature' + ) + }) + + it('leaves non-EdDSA signatures unchanged', () => { + const ecdsaSignature = Buffer.alloc(70, 13).toString('base64') + + expect(normalizeSignature(ecdsaSignature, 'SHA256_WITH_ECDSA')).toBe( + ecdsaSignature + ) + }) + + it('maps TSB statuses to signing statuses', () => { + expect(mapTsbStatus('PENDING')).toBe('pending') + expect(mapTsbStatus('APPROVED')).toBe('pending') + expect(mapTsbStatus('EXECUTED')).toBe('signed') + expect(mapTsbStatus('FAILED')).toBe('failed') + expect(mapTsbStatus('REJECTED')).toBe('rejected') + expect(mapTsbStatus('EXPIRED')).toBe('rejected') + expect(mapTsbStatus('CANCELLED')).toBe('rejected') + }) + + it('uses key-management and key-operation API keys on the right endpoints', async () => { + const client = new SigningAPIClient({ + baseUrl: 'http://tsb.example', + keyManagementApiKey: 'key-mgmt', + keyOperationApiKey: 'key-ops', + }) + const rawPublicKey = Buffer.alloc(32, 3).toString('base64') + fetchMock + .mockResolvedValueOnce(jsonResponse(['wallet-key'])) + .mockResolvedValueOnce( + jsonResponse({ + json: { + label: 'wallet-key', + publicKey: rawPublicKey, + }, + }) + ) + .mockResolvedValueOnce(jsonResponse({ signRequestId: 'req-1' })) + + await client.signTransaction({ + tx: 'tx-bytes', + txHash: 'hash', + keyIdentifier: { publicKey: rawPublicKey }, + }) + + expect(initFor('/v1/key').headers).toMatchObject({ + 'X-API-KEY': 'key-mgmt', + }) + expect(initFor('/v1/sign').headers).toMatchObject({ + 'X-API-KEY': 'key-ops', + }) + }) + + it('sends bearer access token when configured', async () => { + const client = new SigningAPIClient({ + baseUrl: 'http://tsb.example', + bearerToken: 'jwt', + }) + fetchMock.mockResolvedValueOnce(jsonResponse([])) + + await client.getKeys() + + expect(initFor('/v1/key').headers).toMatchObject({ + Authorization: 'Bearer jwt', + }) + }) + + it('uses an mTLS dispatcher when P12 configuration is provided', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tsb-mtls-')) + const p12Path = join(tempDir, 'client.p12') + writeFileSync(p12Path, 'dummy-p12') + + try { + const client = new SigningAPIClient({ + baseUrl: 'https://tsb.example', + mtlsP12Path: p12Path, + mtlsP12Password: 'secret', + }) + fetchMock.mockResolvedValueOnce(jsonResponse([])) + + await client.getKeys() + + const init = initFor('/v1/key') as RequestInit & { + dispatcher?: unknown + } + expect(init.dispatcher).toBeDefined() + expect(client.getConfiguration()).toMatchObject({ + MtlsP12Path: p12Path, + MtlsP12Password: 'secret', + }) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('createKey posts a TSB ED key request and returns a normalized key', async () => { + const client = new SigningAPIClient({ + baseUrl: 'http://tsb.example', + keyPassword: 'secret', + }) + const raw = Buffer.alloc(32, 9) + const der = Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), + raw, + ]) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + json: { + label: 'new-key', + publicKey: der.toString('base64'), + }, + }) + ) + + const result = await client.createKey({ name: 'new-key' }) + + expect(result).toEqual({ + id: 'new-key', + name: 'new-key', + publicKey: raw.toString('base64'), + }) + + const body = JSON.parse(initFor('/v1/key').body as string) + expect(body).toEqual({ + label: 'new-key', + password: 'secret', + algorithm: 'ED', + curveOid: '1.3.101.112', + attributes: { + decrypt: false, + sign: true, + verify: true, + unwrap: false, + extractable: false, + modifiable: true, + destroyable: true, + }, + policy: { + ruleUse: null, + ruleBlock: null, + ruleUnblock: null, + ruleModify: null, + keyStatus: { + blocked: false, + }, + }, + }) + }) + + it('ignores caller-provided key request shape when creating keys', async () => { + const client = new SigningAPIClient('http://tsb.example') + fetchMock.mockResolvedValueOnce( + jsonResponse({ + json: { + label: 'ska-key', + publicKey: Buffer.alloc(32, 8).toString('base64'), + }, + }) + ) + + await client.createKey({ + name: 'ska-key', + id: 'caller-id', + algorithm: 'EC', + curveOid: '1.3.132.0.10', + attributes: { decrypt: true }, + policy: { keyStatus: { blocked: true } }, + }) + + const body = JSON.parse(initFor('/v1/key').body as string) + expect(body).toMatchObject({ + label: 'ska-key', + algorithm: 'ED', + curveOid: '1.3.101.112', + attributes: { + decrypt: false, + sign: true, + verify: true, + unwrap: false, + extractable: false, + modifiable: true, + destroyable: true, + }, + policy: { + ruleUse: null, + ruleBlock: null, + ruleUnblock: null, + ruleModify: null, + keyStatus: { + blocked: false, + }, + }, + }) + expect(body).not.toHaveProperty('id') + expect(body).not.toHaveProperty('algorithmOid') + }) + + it('signTransaction creates an async TSB sign request by key id', async () => { + const client = new SigningAPIClient({ + baseUrl: 'http://tsb.example', + keyPassword: 'secret', + signatureAlgorithm: 'EDDSA', + }) + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + json: { + label: 'wallet-key', + publicKey: Buffer.alloc(32, 1).toString('base64'), + }, + }) + ) + .mockResolvedValueOnce(jsonResponse({ signRequestId: 'req-1' })) + + const result = await client.signTransaction({ + tx: 'tx-bytes', + txHash: 'hash-base64', + internalTxId: 'wallet-tx-1', + keyIdentifier: { id: 'wallet-key' }, + }) + + expect(result).toMatchObject({ + txId: 'req-1', + status: 'pending', + publicKey: Buffer.alloc(32, 1).toString('base64'), + }) + + const body = JSON.parse(initFor('/v1/sign').body as string) + expect(body).toMatchObject({ + signRequest: { + payload: 'hash-base64', + payloadType: 'UNSPECIFIED', + signKeyName: 'wallet-key', + keyPassword: 'secret', + signatureAlgorithm: 'EDDSA', + signatureType: 'RAW', + }, + }) + expect(body.requestSignature).toBeUndefined() + }) + + it('getTransaction maps executed TSB requests to signed transactions', async () => { + const client = new SigningAPIClient('http://tsb.example') + const signature = Buffer.alloc(64, 4).toString('base64') + fetchMock.mockResolvedValueOnce( + jsonResponse({ + id: 'req-1', + status: 'EXECUTED', + executionTime: '2026-06-22T12:00:00', + result: signature, + }) + ) + + const result = await client.getTransaction({ txId: 'req-1' }) + + expect(result).toEqual({ + txId: 'req-1', + status: 'signed', + signature, + metadata: { + tsbStatus: 'EXECUTED', + executionTime: '2026-06-22T12:00:00', + }, + }) + }) + + it('rejects executed EdDSA transactions with incompatible TSB signature bytes', async () => { + const client = new SigningAPIClient('http://tsb.example') + fetchMock.mockResolvedValueOnce( + jsonResponse({ + id: 'req-1', + status: 'EXECUTED', + result: Buffer.alloc(65).toString('base64'), + }) + ) + + await expect(client.getTransaction({ txId: 'req-1' })).rejects.toThrow( + 'Wallet Gateway expects a raw 64-byte Ed25519 signature' + ) + }) + + it('getTransactions fetches every provided txId', async () => { + const client = new SigningAPIClient('http://tsb.example') + fetchMock + .mockResolvedValueOnce(jsonResponse({ id: 'req-1', status: 'PENDING' })) + .mockResolvedValueOnce(jsonResponse({ id: 'req-2', status: 'FAILED' })) + + const result = await client.getTransactions({ + txIds: ['req-1', 'req-2'], + }) + + expect(result.map((tx) => tx.status)).toEqual(['pending', 'failed']) + expect(callsFor('/v1/request/req-1')).toHaveLength(1) + expect(callsFor('/v1/request/req-2')).toHaveLength(1) + }) + + it('getTransactions skips missing txIds without failing the batch', async () => { + const client = new SigningAPIClient('http://tsb.example') + fetchMock + .mockResolvedValueOnce( + new Response('missing', { + status: 404, + statusText: 'Not Found', + }) + ) + .mockResolvedValueOnce(jsonResponse({ id: 'req-2', status: 'FAILED' })) + + const result = await client.getTransactions({ + txIds: ['deleted', 'req-2'], + }) + + expect(result).toEqual([ + { + txId: 'req-2', + status: 'failed', + metadata: { + tsbStatus: 'FAILED', + }, + }, + ]) + }) + + it('getTransactions skips stale ids returned by filteredRequests', async () => { + const client = new SigningAPIClient('http://tsb.example') + const signature = Buffer.alloc(64, 5).toString('base64') + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + requests: [ + { id: 'stale', status: 'EXECUTED' }, + { id: 'live', status: 'EXECUTED' }, + ], + }) + ) + .mockResolvedValueOnce( + new Response('missing', { + status: 404, + statusText: 'Not Found', + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + id: 'live', + status: 'EXECUTED', + result: signature, + }) + ) + + const result = await client.getTransactions({}) + + expect(result).toEqual([ + { + txId: 'live', + status: 'signed', + signature, + metadata: { + tsbStatus: 'EXECUTED', + }, + }, + ]) + }) + + it('getKeys enumerates labels and fetches attributes for each key', async () => { + const client = new SigningAPIClient('http://tsb.example') + fetchMock + .mockResolvedValueOnce(jsonResponse(['key-1', 'key-2'])) + .mockResolvedValueOnce( + jsonResponse({ + json: { + label: 'key-1', + publicKey: Buffer.alloc(32, 1).toString('base64'), + }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + json: { + label: 'key-2', + publicKey: Buffer.alloc(32, 2).toString('base64'), + }, + }) + ) + + const result = await client.getKeys() + + expect(result).toEqual([ + { + id: 'key-1', + name: 'key-1', + publicKey: Buffer.alloc(32, 1).toString('base64'), + }, + { + id: 'key-2', + name: 'key-2', + publicKey: Buffer.alloc(32, 2).toString('base64'), + }, + ]) + }) + + it('throws with the response body when TSB returns an error', async () => { + const client = new SigningAPIClient('http://tsb.example') + fetchMock.mockResolvedValueOnce( + new Response('bad key', { + status: 400, + statusText: 'Bad Request', + }) + ) + + await expect(client.getKeys()).rejects.toThrow( + 'TSB API call to /v1/key failed (400): bad key' + ) + }) + + it('setConfiguration updates optional fields', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tsb-mtls-')) + const p12Path = join(tempDir, 'client.p12') + writeFileSync(p12Path, 'dummy-p12') + const client = new SigningAPIClient('http://tsb.example/') + + try { + const config = client.setConfiguration({ + BaseURL: 'https://new.tsb/', + KeyManagementApiKey: 'key', + KeyOperationApiKey: 'operation', + BearerToken: 'token', + MtlsP12Path: p12Path, + MtlsP12Password: 'mtls-secret', + KeyPassword: 'secret', + SignatureAlgorithm: 'SHA256_WITH_ECDSA', + }) + + expect(config).toMatchObject({ + BaseURL: 'https://new.tsb', + KeyManagementApiKey: 'key', + KeyOperationApiKey: 'operation', + BearerToken: 'token', + MtlsP12Path: p12Path, + MtlsP12Password: 'mtls-secret', + KeyPassword: 'secret', + SignatureAlgorithm: 'SHA256_WITH_ECDSA', + }) + expect(config).not.toHaveProperty('CreateKeyRequest') + expect(config).not.toHaveProperty('SignatureType') + expect(config).not.toHaveProperty('PayloadType') + expect(config).not.toHaveProperty('PublicKeyFormat') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/core/signing-securosys/src/signing-api-sdk.ts b/core/signing-securosys/src/signing-api-sdk.ts new file mode 100644 index 000000000..b805d2f59 --- /dev/null +++ b/core/signing-securosys/src/signing-api-sdk.ts @@ -0,0 +1,806 @@ +// SPDX-FileCopyrightText: Copyright 2026 Securosys SA +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs' +import type { + CreateKeyParams, + GetTransactionParams, + GetTransactionsParams, + Key, + KeyIdentifier, + SignTransactionParams, + SigningStatus, + Transaction, +} from '@canton-network/core-signing-lib' +import { Agent, type Dispatcher } from 'undici' + +export type TsbRequestStatus = + | 'PENDING' + | 'APPROVED' + | 'EXECUTED' + | 'FAILED' + | 'EXPIRED' + | 'REJECTED' + | 'CANCELLED' + +export type TsbSignatureAlgorithm = + | 'EDDSA' + | 'SHA256_WITH_ECDSA' + | 'SHA512_WITH_ECDSA' + | 'NONE_WITH_EC_SCHNORR_BIP0340' + | string + +export interface TsbCreateKeyAttributes { + encrypt?: boolean + decrypt?: boolean + verify?: boolean + sign?: boolean + wrap?: boolean + unwrap?: boolean + derive?: boolean + bip32?: boolean + slip10?: boolean + extractable?: boolean + modifiable?: boolean + destroyable?: boolean + sensitive?: boolean + copyable?: boolean + rollover?: boolean + [key: string]: unknown +} + +export interface TsbCreateKeyRequest { + label: string + password?: string + id?: string + algorithm?: string + algorithmOid?: string + curveOid?: string + keySize?: number + attributes: TsbCreateKeyAttributes + policy: TsbCreateKeyPolicy + [key: string]: unknown +} + +export interface TsbCreateKeyPolicy { + ruleUse: null + ruleBlock: null + ruleUnblock: null + ruleModify: null + keyStatus: { + blocked: false + } +} + +export interface TsbKeyAttributes { + label: string + id?: string + uuid?: string + algorithm?: string + algorithmOid?: string + curveOid?: string + publicKey?: string + [key: string]: unknown +} + +export interface TsbSignedKeyAttributes { + xml?: string + json: TsbKeyAttributes + xmlSignature?: string + attestationKeyName?: string +} + +export interface TsbSignRequest { + payload: string + payloadType?: string + signKeyName: string + keyPassword?: string + metaData?: string + metaDataSignature?: string + signatureAlgorithm: TsbSignatureAlgorithm + signatureType?: string + context?: string + auxiliaryRandomData?: string + taprootTweakData?: string + merkleRootData?: string + [key: string]: unknown +} + +export interface TsbSignedSignRequest { + signRequest: TsbSignRequest +} + +export interface TsbSignRequestResponse { + signRequestId: string +} + +export interface TsbRequestStatusResponse { + id: string + status: TsbRequestStatus + executionTime?: string + approvedBy?: string[] + notYetApprovedBy?: string[] + rejectedBy?: string[] + result?: string | null + inputOfflineHsm?: unknown + [key: string]: unknown +} + +export interface TsbRequestByStatus { + id: string + status: TsbRequestStatus +} + +export interface TsbRequestIdsByStatusResponse { + requests: TsbRequestByStatus[] +} + +export interface SecurosysTSBClientConfig { + baseUrl: string + keyManagementApiKey?: string + keyOperationApiKey?: string + bearerToken?: string + mtlsP12Path?: string + mtlsP12Password?: string + keyPassword?: string + signatureAlgorithm?: TsbSignatureAlgorithm +} + +type ApiRole = 'key-management' | 'key-operation' + +const WALLET_KEY_ALGORITHM = 'ED' +const WALLET_KEY_CURVE_OID = '1.3.101.112' +const WALLET_CREATE_KEY_ATTRIBUTES: TsbCreateKeyAttributes = { + decrypt: false, + sign: true, + verify: true, + unwrap: false, + extractable: false, + modifiable: true, + destroyable: true, +} + +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') +const DER_OCTET_STRING_ED25519_SIGNATURE_PREFIX = Buffer.from('0440', 'hex') +const DER_BIT_STRING_ED25519_SIGNATURE_PREFIX = Buffer.from('034100', 'hex') +const WALLET_PAYLOAD_TYPE = 'UNSPECIFIED' +const WALLET_SIGNATURE_TYPE = 'RAW' +const WALLET_EMPTY_SKA_POLICY: TsbCreateKeyPolicy = { + ruleUse: null, + ruleBlock: null, + ruleUnblock: null, + ruleModify: null, + keyStatus: { + blocked: false, + }, +} + +export function normalizePublicKey(publicKey: string): string { + const bytes = Buffer.from(publicKey, 'base64') + if (bytes.length === 32) { + return publicKey + } + if ( + bytes.length === ED25519_SPKI_PREFIX.length + 32 && + bytes.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return bytes.subarray(ED25519_SPKI_PREFIX.length).toString('base64') + } + + return publicKey +} + +export function normalizeSignature( + signature: string, + algorithm: TsbSignatureAlgorithm = 'EDDSA' +): string { + const normalizedAlgorithm = String(algorithm).toUpperCase() + if (normalizedAlgorithm !== 'EDDSA' && normalizedAlgorithm !== 'ED25519') { + return signature + } + + const bytes = Buffer.from(signature, 'base64') + let rawSignature: Buffer | undefined + + if (bytes.length === 64) { + rawSignature = bytes + } else if ( + bytes.length === + DER_OCTET_STRING_ED25519_SIGNATURE_PREFIX.length + 64 && + bytes + .subarray(0, DER_OCTET_STRING_ED25519_SIGNATURE_PREFIX.length) + .equals(DER_OCTET_STRING_ED25519_SIGNATURE_PREFIX) + ) { + rawSignature = bytes.subarray( + DER_OCTET_STRING_ED25519_SIGNATURE_PREFIX.length + ) + } else if ( + bytes.length === DER_BIT_STRING_ED25519_SIGNATURE_PREFIX.length + 64 && + bytes + .subarray(0, DER_BIT_STRING_ED25519_SIGNATURE_PREFIX.length) + .equals(DER_BIT_STRING_ED25519_SIGNATURE_PREFIX) + ) { + rawSignature = bytes.subarray( + DER_BIT_STRING_ED25519_SIGNATURE_PREFIX.length + ) + } else { + rawSignature = readDerIntegerPairSignature(bytes) + } + + if (!rawSignature) { + throw new Error( + `TSB returned an ${algorithm} signature with ${bytes.length} bytes after base64 decoding; Wallet Gateway expects a raw 64-byte Ed25519 signature.` + ) + } + + return rawSignature.toString('base64') +} + +function readDerIntegerPairSignature(bytes: Buffer): Buffer | undefined { + if (bytes[0] !== 0x30) { + return undefined + } + + const sequenceLength = readDerLength(bytes, 1) + if (!sequenceLength || sequenceLength.offset + sequenceLength.length !== bytes.length) { + return undefined + } + + const first = readDerInteger(bytes, sequenceLength.offset) + if (!first) { + return undefined + } + + const second = readDerInteger(bytes, first.offset) + if (!second || second.offset !== bytes.length) { + return undefined + } + + return Buffer.concat([first.value, second.value]) +} + +function readDerInteger( + bytes: Buffer, + offset: number +): { value: Buffer; offset: number } | undefined { + if (bytes[offset] !== 0x02) { + return undefined + } + + const integerLength = readDerLength(bytes, offset + 1) + if (!integerLength) { + return undefined + } + + const end = integerLength.offset + integerLength.length + if (end > bytes.length) { + return undefined + } + + let value = bytes.subarray(integerLength.offset, end) + while (value.length > 32 && value[0] === 0) { + value = value.subarray(1) + } + if (value.length > 32) { + return undefined + } + if (value.length < 32) { + value = Buffer.concat([Buffer.alloc(32 - value.length), value]) + } + + return { value, offset: end } +} + +function readDerLength( + bytes: Buffer, + offset: number +): { length: number; offset: number } | undefined { + const first = bytes[offset] + if (first === undefined) { + return undefined + } + + if (first < 0x80) { + return { length: first, offset: offset + 1 } + } + + const lengthBytes = first & 0x7f + if (lengthBytes === 0 || lengthBytes > 4 || offset + lengthBytes >= bytes.length) { + return undefined + } + + let length = 0 + for (let index = 0; index < lengthBytes; index += 1) { + length = (length << 8) | bytes[offset + 1 + index] + } + + return { length, offset: offset + 1 + lengthBytes } +} + +export function mapTsbStatus(status: TsbRequestStatus): SigningStatus { + switch (status) { + case 'EXECUTED': + return 'signed' + case 'FAILED': + return 'failed' + case 'REJECTED': + case 'CANCELLED': + case 'EXPIRED': + return 'rejected' + case 'PENDING': + case 'APPROVED': + default: + return 'pending' + } +} + +function compact>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, v]) => v !== undefined) + ) as T +} + +function encodeJsonBase64(value: Record): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64') +} + +/** + * TypeScript SDK client for the Securosys TSB REST API endpoints used by the + * Wallet Gateway signing driver. + */ +export class SigningAPIClient { + private baseUrl: string + private keyManagementApiKey: string | undefined + private keyOperationApiKey: string | undefined + private bearerToken: string | undefined + private mtlsP12Path: string | undefined + private mtlsP12Password: string | undefined + private dispatcher: Dispatcher | undefined + private keyPassword: string | undefined + private signatureAlgorithm: TsbSignatureAlgorithm + private transactionCache = new Map() + + constructor(configOrBaseUrl: SecurosysTSBClientConfig | string) { + const config = + typeof configOrBaseUrl === 'string' + ? { baseUrl: configOrBaseUrl } + : configOrBaseUrl + + this.baseUrl = normalizeBaseUrl(config.baseUrl) + this.keyManagementApiKey = config.keyManagementApiKey + this.keyOperationApiKey = config.keyOperationApiKey + this.bearerToken = config.bearerToken + this.configureMtls(config.mtlsP12Path, config.mtlsP12Password) + this.keyPassword = config.keyPassword + this.signatureAlgorithm = config.signatureAlgorithm ?? 'EDDSA' + } + + private configureMtls( + p12Path: string | undefined, + p12Password: string | undefined + ): void { + const normalizedPath = p12Path || undefined + const normalizedPassword = p12Password || undefined + const nextDispatcher = createMtlsDispatcher( + normalizedPath, + normalizedPassword + ) + const previousDispatcher = this.dispatcher + + this.mtlsP12Path = normalizedPath + this.mtlsP12Password = normalizedPassword + this.dispatcher = nextDispatcher + + if (previousDispatcher) { + void previousDispatcher.close() + } + } + + private roleApiKey(role: ApiRole): string | undefined { + if (role === 'key-management') { + return this.keyManagementApiKey + } + return this.keyOperationApiKey + } + + private async request( + method: 'GET' | 'POST' | 'DELETE', + endpoint: string, + role: ApiRole, + body?: object + ): Promise { + const headers: Record = { + Accept: 'application/json', + } + + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + } + + const roleApiKey = this.roleApiKey(role) + if (roleApiKey) { + headers['X-API-KEY'] = roleApiKey + } + if (this.bearerToken) { + headers.Authorization = `Bearer ${this.bearerToken}` + } + + const requestInit: RequestInit & { dispatcher?: Dispatcher } = { + method, + headers, + ...(body !== undefined && { body: JSON.stringify(body) }), + } + + if (this.dispatcher) { + requestInit.dispatcher = this.dispatcher + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, requestInit) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `TSB API call to ${endpoint} failed (${response.status}): ${errorText || response.statusText}` + ) + } + + if ( + response.status === 204 || + response.headers.get('content-length') === '0' + ) { + return {} as O + } + + return response.json() as Promise + } + + private get(endpoint: string, role: ApiRole): Promise { + return this.request('GET', endpoint, role) + } + + private post( + endpoint: string, + role: ApiRole, + body: I + ): Promise { + return this.request('POST', endpoint, role, body) + } + + private delete(endpoint: string, role: ApiRole): Promise { + return this.request('DELETE', endpoint, role) + } + + private keyFromAttributes(attributes: TsbKeyAttributes): Key { + if (!attributes.publicKey) { + throw new Error(`TSB key '${attributes.label}' does not expose a public key`) + } + + return { + id: attributes.label, + name: attributes.label, + publicKey: normalizePublicKey(attributes.publicKey), + } + } + + private transactionFromStatus( + response: TsbRequestStatusResponse, + cached?: Transaction + ): Transaction { + const status = mapTsbStatus(response.status) + const cachedMetadata = + cached?.metadata && + typeof cached.metadata === 'object' && + !Array.isArray(cached.metadata) + ? (cached.metadata as Record) + : undefined + const signatureAlgorithm = + typeof cachedMetadata?.signatureAlgorithm === 'string' + ? cachedMetadata.signatureAlgorithm + : this.signatureAlgorithm + const signature = + typeof response.result === 'string' + ? normalizeSignature(response.result, signatureAlgorithm) + : undefined + const transaction: Transaction = compact({ + txId: response.id, + status, + signature, + publicKey: cached?.publicKey, + metadata: { + tsbStatus: response.status, + executionTime: response.executionTime, + approvedBy: response.approvedBy, + notYetApprovedBy: response.notYetApprovedBy, + rejectedBy: response.rejectedBy, + inputOfflineHsm: response.inputOfflineHsm, + ...(cached?.metadata ?? {}), + }, + }) + + this.transactionCache.set(transaction.txId, transaction) + return transaction + } + + private async resolveKeyIdentifier( + keyIdentifier: KeyIdentifier + ): Promise { + if (keyIdentifier.id) { + const attributes = await this.getKeyAttributes(keyIdentifier.id) + return this.keyFromAttributes(attributes.json) + } + + if (keyIdentifier.publicKey) { + const keys = await this.getKeys() + const key = keys.find((candidate) => + publicKeysEqual(candidate.publicKey, keyIdentifier.publicKey!) + ) + if (key) { + return key + } + } + + throw new Error('Unable to resolve TSB signing key from keyIdentifier') + } + + private async getExistingTransactions(txIds: string[]): Promise { + const results = await Promise.allSettled( + txIds.map((txId) => this.getTransaction({ txId })) + ) + + return results.flatMap((result, index) => { + if (result.status === 'fulfilled') { + return [result.value] + } + if (isNotFoundError(result.reason)) { + this.transactionCache.delete(txIds[index]) + return [] + } + throw result.reason + }) + } + + public async signTransaction( + params: SignTransactionParams + ): Promise { + const key = await this.resolveKeyIdentifier(params.keyIdentifier) + const signatureAlgorithm = + params.signatureAlgorithm ?? this.signatureAlgorithm + const metadata = compact({ + internalTxId: params.internalTxId, + userIdentifier: params.userIdentifier, + keyIdentifier: params.keyIdentifier, + signatureAlgorithm, + signatureType: WALLET_SIGNATURE_TYPE, + }) + + const signRequest: TsbSignRequest = compact({ + payload: params.txHash, + payloadType: WALLET_PAYLOAD_TYPE, + signKeyName: key.name, + keyPassword: params.keyPassword ?? this.keyPassword, + metaData: + params.metaData ?? + (Object.keys(metadata).length > 0 + ? encodeJsonBase64(metadata) + : undefined), + metaDataSignature: params.metaDataSignature, + signatureAlgorithm, + signatureType: WALLET_SIGNATURE_TYPE, + context: params.context, + auxiliaryRandomData: params.auxiliaryRandomData, + taprootTweakData: params.taprootTweakData, + merkleRootData: params.merkleRootData, + }) + + const response = await this.post( + '/v1/sign', + 'key-operation', + { signRequest } + ) + + const transaction: Transaction = { + txId: response.signRequestId, + status: 'pending', + publicKey: key.publicKey, + metadata, + } + this.transactionCache.set(transaction.txId, transaction) + return transaction + } + + public async getTransaction( + params: GetTransactionParams + ): Promise { + const response = await this.get( + `/v1/request/${encodeURIComponent(params.txId)}`, + 'key-operation' + ) + + return this.transactionFromStatus( + response, + this.transactionCache.get(params.txId) + ) + } + + public async getTransactions( + params: GetTransactionsParams + ): Promise { + if (params.txIds?.length) { + return this.getExistingTransactions(params.txIds) + } + + if (params.publicKeys?.length) { + const cached = Array.from(this.transactionCache.values()).filter( + (tx) => + tx.publicKey !== undefined && + params.publicKeys!.some((publicKey) => + publicKeysEqual(publicKey, tx.publicKey!) + ) + ) + + return this.getExistingTransactions(cached.map((tx) => tx.txId)) + } + + const response = await this.post< + { requestStatusList: TsbRequestStatus[] }, + TsbRequestIdsByStatusResponse + >('/v1/filteredRequests', 'key-operation', { + requestStatusList: [ + 'PENDING', + 'APPROVED', + 'EXECUTED', + 'FAILED', + 'EXPIRED', + 'REJECTED', + 'CANCELLED', + ], + }) + + return this.getExistingTransactions( + response.requests.map((request) => request.id) + ) + } + + public async getKeys(): Promise { + const labels = await this.get('/v1/key', 'key-management') + + return Promise.all( + labels.map(async (label) => { + const attributes = await this.getKeyAttributes(label) + return this.keyFromAttributes(attributes.json) + }) + ) + } + + public async createKey(params: CreateKeyParams): Promise { + const request = compact({ + label: params.name, + password: params.keyPassword ?? this.keyPassword, + algorithm: WALLET_KEY_ALGORITHM, + curveOid: WALLET_KEY_CURVE_OID, + attributes: WALLET_CREATE_KEY_ATTRIBUTES, + policy: WALLET_EMPTY_SKA_POLICY, + }) as TsbCreateKeyRequest + + const attributes = await this.post( + '/v1/key', + 'key-management', + request + ) + + return this.keyFromAttributes(attributes.json) + } + + public async getKeyAttributes( + label: string + ): Promise { + return this.post<{ label: string; password?: string }, TsbSignedKeyAttributes>( + '/v1/key/attributes', + 'key-management', + compact({ + label, + password: this.keyPassword, + }) + ) + } + + public async cancelTransaction(txId: string): Promise { + await this.delete(`/v1/request/${encodeURIComponent(txId)}`, 'key-operation') + const cached = this.transactionCache.get(txId) + if (cached) { + this.transactionCache.set(txId, { + ...cached, + status: 'rejected', + metadata: { + ...(cached.metadata ?? {}), + tsbStatus: 'CANCELLED', + }, + }) + } + } + + public getConfiguration(): Record { + return { + BaseURL: this.baseUrl, + KeyManagementApiKey: this.keyManagementApiKey, + KeyOperationApiKey: this.keyOperationApiKey, + BearerToken: this.bearerToken, + MtlsP12Path: this.mtlsP12Path, + MtlsP12Password: this.mtlsP12Password, + KeyPassword: this.keyPassword, + SignatureAlgorithm: this.signatureAlgorithm, + } + } + + public setConfiguration(params: { + BaseURL?: string + KeyManagementApiKey?: string + KeyOperationApiKey?: string + BearerToken?: string + MtlsP12Path?: string + MtlsP12Password?: string + KeyPassword?: string + SignatureAlgorithm?: TsbSignatureAlgorithm + }): Record { + if (params.BaseURL !== undefined) { + this.baseUrl = normalizeBaseUrl(params.BaseURL) + } + if (params.KeyManagementApiKey !== undefined) { + this.keyManagementApiKey = params.KeyManagementApiKey + } + if (params.KeyOperationApiKey !== undefined) { + this.keyOperationApiKey = params.KeyOperationApiKey + } + if (params.BearerToken !== undefined) { + this.bearerToken = params.BearerToken + } + if ( + params.MtlsP12Path !== undefined || + params.MtlsP12Password !== undefined + ) { + this.configureMtls( + params.MtlsP12Path !== undefined + ? params.MtlsP12Path + : this.mtlsP12Path, + params.MtlsP12Password !== undefined + ? params.MtlsP12Password + : this.mtlsP12Password + ) + } + if (params.KeyPassword !== undefined) { + this.keyPassword = params.KeyPassword + } + if (params.SignatureAlgorithm !== undefined) { + this.signatureAlgorithm = params.SignatureAlgorithm + } + return this.getConfiguration() + } +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl +} + +function createMtlsDispatcher( + p12Path: string | undefined, + p12Password: string | undefined +): Dispatcher | undefined { + if (!p12Path) { + return undefined + } + + return new Agent({ + connect: { + pfx: readFileSync(p12Path), + passphrase: p12Password, + }, + }) +} + +function publicKeysEqual(left: string, right: string): boolean { + return left === right || normalizePublicKey(left) === normalizePublicKey(right) +} + +function isNotFoundError(error: unknown): boolean { + return error instanceof Error && error.message.includes('(404)') +} diff --git a/core/signing-securosys/tsconfig.json b/core/signing-securosys/tsconfig.json new file mode 100644 index 000000000..d19ed0c62 --- /dev/null +++ b/core/signing-securosys/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "exactOptionalPropertyTypes": false + }, + "include": ["src/**/*.ts"] +} diff --git a/core/signing-securosys/tsup.config.ts b/core/signing-securosys/tsup.config.ts new file mode 100644 index 000000000..1f4074292 --- /dev/null +++ b/core/signing-securosys/tsup.config.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'tsup' +import { base } from '../../tsup.base' + +export default defineConfig({ + ...base, + entry: ['src/index.ts'], +}) diff --git a/core/signing-securosys/vitest.config.ts b/core/signing-securosys/vitest.config.ts new file mode 100644 index 000000000..3be1b7f79 --- /dev/null +++ b/core/signing-securosys/vitest.config.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, defineProject } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'html', 'lcov', 'json-summary'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + environment: 'node', + include: ['src/**/*.test.ts'], + projects: [ + defineProject({ + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }), + ], + }, +}) diff --git a/wallet-gateway/remote/README.md b/wallet-gateway/remote/README.md index d95ac2a36..e8bbb2c63 100644 --- a/wallet-gateway/remote/README.md +++ b/wallet-gateway/remote/README.md @@ -63,6 +63,23 @@ Dfns provisions and activates Canton wallets through its validator integration, 2. set the environment variable `FIREBLOCKS_API_KEY` (get it from `API User (ID)` column in fireblocks api users table). +## Securosys + +The Securosys TSB signing driver is registered at startup as `securosys`. +Set these environment variables before starting the Gateway: + +- `SECUROSYS_TSB_BASE_URL` — TSB base URL +- `SECUROSYS_TSB_KEY_MANAGEMENT_API_KEY` — `X-API-KEY` optional for key-management endpoints +- `SECUROSYS_TSB_KEY_OPERATION_API_KEY` — `X-API-KEY` optional for signing/request endpoints +- `SECUROSYS_TSB_BEARER_TOKEN` — optional bearer token for access-token mode +- `SECUROSYS_TSB_MTLS_P12_PATH` — optional client PKCS#12/P12 file for mTLS +- `SECUROSYS_TSB_MTLS_P12_PASSWORD` — optional PKCS#12/P12 password +- `SECUROSYS_TSB_KEY_PASSWORD` — optional TSB key password +- `SECUROSYS_TSB_SIGNATURE_ALGORITHM` — TSB signature algorithm, defaults to `EDDSA` + +See [`@canton-network/core-signing-securosys`](../../core/signing-securosys/README.md) +for key creation, public-key, and signature format details. + ## Postgres connection To create a Postgres database you need to: diff --git a/wallet-gateway/remote/package.json b/wallet-gateway/remote/package.json index 872376202..7e66fc5ee 100644 --- a/wallet-gateway/remote/package.json +++ b/wallet-gateway/remote/package.json @@ -47,6 +47,7 @@ "@canton-network/core-signing-internal": "workspace:^", "@canton-network/core-signing-lib": "workspace:^", "@canton-network/core-signing-participant": "workspace:^", + "@canton-network/core-signing-securosys": "workspace:^", "@canton-network/core-signing-store-sql": "workspace:^", "@canton-network/core-tx-visualizer": "workspace:^", "@canton-network/core-types": "workspace:^", diff --git a/wallet-gateway/remote/src/env.ts b/wallet-gateway/remote/src/env.ts index 4136e936a..87d11fd64 100644 --- a/wallet-gateway/remote/src/env.ts +++ b/wallet-gateway/remote/src/env.ts @@ -10,6 +10,22 @@ export class Env { Env.get('BLOCKDAEMON_API_KEY', { fallback }) static BLOCKDAEMON_CAIP2 = (fallback: string) => Env.get('BLOCKDAEMON_CAIP2', { fallback }) + static SECUROSYS_TSB_BASE_URL = (fallback: string) => + Env.get('SECUROSYS_TSB_BASE_URL', { fallback }) + static SECUROSYS_TSB_KEY_MANAGEMENT_API_KEY = () => + Env.get('SECUROSYS_TSB_KEY_MANAGEMENT_API_KEY') + static SECUROSYS_TSB_KEY_OPERATION_API_KEY = () => + Env.get('SECUROSYS_TSB_KEY_OPERATION_API_KEY') + static SECUROSYS_TSB_BEARER_TOKEN = () => + Env.get('SECUROSYS_TSB_BEARER_TOKEN') + static SECUROSYS_TSB_MTLS_P12_PATH = () => + Env.get('SECUROSYS_TSB_MTLS_P12_PATH') + static SECUROSYS_TSB_MTLS_P12_PASSWORD = () => + Env.get('SECUROSYS_TSB_MTLS_P12_PASSWORD') + static SECUROSYS_TSB_KEY_PASSWORD = () => + Env.get('SECUROSYS_TSB_KEY_PASSWORD') + static SECUROSYS_TSB_SIGNATURE_ALGORITHM = (fallback: string) => + Env.get('SECUROSYS_TSB_SIGNATURE_ALGORITHM', { fallback }) static DFNS_ORG_ID = () => Env.get('DFNS_ORG_ID') static DFNS_BASE_URL = (fallback: string) => Env.get('DFNS_BASE_URL', { fallback }) diff --git a/wallet-gateway/remote/src/init.ts b/wallet-gateway/remote/src/init.ts index 4767070ab..bc72f740f 100644 --- a/wallet-gateway/remote/src/init.ts +++ b/wallet-gateway/remote/src/init.ts @@ -27,6 +27,9 @@ import FireblocksSigningProvider from '@canton-network/core-signing-fireblocks' import BlockdaemonSigningProvider, { CantonCaip2, } from '@canton-network/core-signing-blockdaemon' +import SecurosysSigningProvider, { + type TsbSignatureAlgorithm, +} from '@canton-network/core-signing-securosys' import { jwtAuthService } from './auth/jwt-auth-service.js' import express from 'express' import { CliOptions } from './index.js' @@ -284,6 +287,14 @@ export async function initialize(opts: CliOptions, logger: Logger) { const keyInfo = { apiKey, apiSecret } const userApiKeys = new Map([['user', keyInfo]]) + const securosysKeyManagementApiKey = + Env.SECUROSYS_TSB_KEY_MANAGEMENT_API_KEY() + const securosysKeyOperationApiKey = + Env.SECUROSYS_TSB_KEY_OPERATION_API_KEY() + const securosysBearerToken = Env.SECUROSYS_TSB_BEARER_TOKEN() + const securosysMtlsP12Path = Env.SECUROSYS_TSB_MTLS_P12_PATH() + const securosysMtlsP12Password = Env.SECUROSYS_TSB_MTLS_P12_PASSWORD() + const securosysKeyPassword = Env.SECUROSYS_TSB_KEY_PASSWORD() const drivers: SigningDrivers = { [SigningProvider.PARTICIPANT]: new ParticipantSigningDriver(), @@ -301,6 +312,24 @@ export async function initialize(opts: CliOptions, logger: Logger) { apiKey: Env.BLOCKDAEMON_API_KEY(''), caip2: Env.BLOCKDAEMON_CAIP2('canton:testnet') as CantonCaip2, }), + [SigningProvider.SECUROSYS]: new SecurosysSigningProvider({ + baseUrl: Env.SECUROSYS_TSB_BASE_URL('http://localhost:8080'), + ...(securosysKeyManagementApiKey && { + keyManagementApiKey: securosysKeyManagementApiKey, + }), + ...(securosysKeyOperationApiKey && { + keyOperationApiKey: securosysKeyOperationApiKey, + }), + ...(securosysBearerToken && { bearerToken: securosysBearerToken }), + ...(securosysMtlsP12Path && { mtlsP12Path: securosysMtlsP12Path }), + ...(securosysMtlsP12Password && { + mtlsP12Password: securosysMtlsP12Password, + }), + ...(securosysKeyPassword && { keyPassword: securosysKeyPassword }), + signatureAlgorithm: Env.SECUROSYS_TSB_SIGNATURE_ALGORITHM( + 'EDDSA' + ) as TsbSignatureAlgorithm, + }), } if ( diff --git a/wallet-gateway/remote/src/ledger/transaction-service.test.ts b/wallet-gateway/remote/src/ledger/transaction-service.test.ts index b052e8a70..03418982a 100644 --- a/wallet-gateway/remote/src/ledger/transaction-service.test.ts +++ b/wallet-gateway/remote/src/ledger/transaction-service.test.ts @@ -459,6 +459,50 @@ describe('TransactionService', () => { }) }) }) + + describe('securosys', () => { + it('starts signing and persists the TSB request id when signing is pending', async () => { + const signTransaction = vi.fn().mockResolvedValue({ + status: 'pending', + txId: 'tsb-request-1', + }) + const store = createStore() + const service = createService( + store, + { + [SigningProvider.SECUROSYS]: createDriver({ + signTransaction, + }), + }, + notifier, + logger + ) + + const result = await service.sign( + authContext, + walletWithProvider(SigningProvider.SECUROSYS), + signParams + ) + + expect(signTransaction).toHaveBeenCalledWith({ + tx: pendingTransaction.preparedTransaction, + txHash: pendingTransaction.preparedTransactionHash, + keyIdentifier: { + publicKey: wallet.publicKey, + }, + }) + expect(store.setTransactionStatus).toHaveBeenCalledWith( + pendingTransaction.id, + 'pending', + { externalTxId: 'tsb-request-1' } + ) + expect(result).toEqual({ + status: 'pending', + externalTxId: 'tsb-request-1', + partyId: wallet.partyId, + }) + }) + }) }) describe('execute', () => { @@ -605,6 +649,45 @@ describe('TransactionService', () => { ) expect(result).toEqual({ updateId: 'external-update-1' }) }) + + it('executes Securosys signed transactions as external signatures', async () => { + const signedTransaction = { + ...pendingTransaction, + status: 'signed' as const, + externalTxId: 'tsb-request-1', + } + const store = createStore(signedTransaction) + const postWithRetry = vi + .fn() + .mockResolvedValue({ updateId: 'external-update-1' }) + const ledgerClient = { + postWithRetry, + } as unknown as LedgerClient + const service = createService(store, {}, notifier, logger) + + const result = await service.execute( + authContext.userId, + walletWithProvider(SigningProvider.SECUROSYS), + signedTransaction, + executeParams, + ledgerClient, + network + ) + + expect(postWithRetry).toHaveBeenCalledWith( + '/v2/interactive-submission/executeAndWait', + expect.objectContaining({ + partySignatures: expect.objectContaining({ + signatures: [ + expect.objectContaining({ + party: wallet.partyId, + }), + ], + }), + }) + ) + expect(result).toEqual({ updateId: 'external-update-1' }) + }) }) }) diff --git a/wallet-gateway/remote/src/ledger/transaction-service.ts b/wallet-gateway/remote/src/ledger/transaction-service.ts index 5c56744fa..ed3a2184b 100644 --- a/wallet-gateway/remote/src/ledger/transaction-service.ts +++ b/wallet-gateway/remote/src/ledger/transaction-service.ts @@ -100,6 +100,13 @@ export class TransactionService { case SigningProvider.DFNS: { return this.signWithDfns(authContext.userId, wallet, signParams) } + case SigningProvider.SECUROSYS: { + return this.signWithSecurosys( + authContext.userId, + wallet, + signParams + ) + } default: throw new Error( `Unsupported signing provider: ${wallet.signingProviderId}` @@ -151,7 +158,8 @@ export class TransactionService { } case SigningProvider.WALLET_KERNEL: case SigningProvider.BLOCKDAEMON: - case SigningProvider.FIREBLOCKS: { + case SigningProvider.FIREBLOCKS: + case SigningProvider.SECUROSYS: { if (!executeParams) { throw new Error( 'Execute params are required for external signing' @@ -705,6 +713,113 @@ export class TransactionService { return { updateId: transaction.externalTxId } } + private async signWithSecurosys( + userId: UserId, + wallet: Wallet, + signParams: SignParams + ): Promise { + const signingProvider = this.signingDrivers[SigningProvider.SECUROSYS] + if (!signingProvider) { + throw new Error('Securosys signing driver not available') + } + const driver = signingProvider.controller(userId) + + const tx = await this.loadPreparedTransactionForSigning( + signParams.transactionId + ) + + let signingResult: Exclude< + GetTransactionResult | SignTransactionResult, + SigningError + > + if (tx.externalTxId) { + signingResult = await driver + .getTransaction({ + txId: tx.externalTxId, + }) + .then(handleSigningError) + } else { + signingResult = await driver + .signTransaction({ + tx: tx.preparedTransaction, + txHash: tx.preparedTransactionHash, + keyIdentifier: { + publicKey: wallet.publicKey, + }, + }) + .then(handleSigningError) + } + + const now = new Date() + + logDynamically(this.logger, 'Securosys signing result', { + info: { transactionId: tx.id, status: signingResult.status }, + debug: { signingResult, tx }, + }) + + if (signingResult.status === 'signed') { + if (!signingResult.signature) { + throw new Error('No signature returned from signing driver') + } + + const signedTx: Transaction = { + id: tx.id, + commandId: tx.commandId, + status: signingResult.status, + preparedTransaction: tx.preparedTransaction, + preparedTransactionHash: tx.preparedTransactionHash, + origin: tx?.origin ?? null, + ...(tx?.createdAt && { + createdAt: tx.createdAt, + }), + signedAt: now, + externalTxId: signingResult.txId, + } + + await this.store.setTransactionSigned( + tx.id, + now, + signingResult.txId + ) + this.notifier.emit('txChanged', signedTx) + + return { + status: signingResult.status, + signature: signingResult.signature, + signedBy: wallet.namespace, + partyId: wallet.partyId, + externalTxId: signingResult.txId, + } + } else { + const status = + signingResult.status === 'pending' ? 'pending' : 'failed' + const pendingTx: Transaction = { + id: tx.id, + commandId: tx.commandId, + status, + preparedTransaction: tx.preparedTransaction, + preparedTransactionHash: tx.preparedTransactionHash, + externalTxId: signingResult.txId, + origin: tx?.origin ?? null, + ...(tx?.createdAt && { + createdAt: tx.createdAt, + }), + } + + await this.store.setTransactionStatus(tx.id, status, { + externalTxId: signingResult.txId, + }) + + this.notifier.emit('txChanged', pendingTx) + + return { + status: signingResult.status, + externalTxId: signingResult.txId, + partyId: wallet.partyId, + } + } + } + private async executeWithParticipant( userId: UserId, executeParams: ExecuteParams, diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/signing-providers/securosys-wallet-allocator.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/signing-providers/securosys-wallet-allocator.ts new file mode 100644 index 000000000..619d9ad5c --- /dev/null +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/signing-providers/securosys-wallet-allocator.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { UserId } from '@canton-network/core-wallet-auth' +import { Store, UpdateWallet, Wallet } from '@canton-network/core-wallet-store' +import { + Error as SigningError, + SigningDriverInterface, + SigningProvider, +} from '@canton-network/core-signing-lib' +import { Logger } from 'pino' +import { PartyAllocationService } from '../../party-allocation-service.js' +import { PartyHint, Primary } from '../../../user-api/rpc-gen/typings.js' +import type { WalletAllocator } from '../wallet-allocation-service.js' +import { WALLET_DISABLED_REASON } from '@canton-network/core-types' + +function handleSigningError(result: SigningError | T): T { + if ('error' in result) { + throw new Error( + `Error from signing driver: ${result.error_description}` + ) + } + return result +} + +export class SecurosysWalletAllocator implements WalletAllocator { + constructor( + private store: Store, + private logger: Logger, + private partyAllocator: PartyAllocationService, + private signingDriver: SigningDriverInterface + ) {} + + async createWallet( + userId: UserId, + _email: string | undefined, + partyHint: PartyHint, + primary: Primary = false + ): Promise { + const driver = this.signingDriver.controller(userId) + + const key = await driver + .createKey({ name: partyHint }) + .then(handleSigningError) + + const namespace = this.partyAllocator.createFingerprintFromKey( + key.publicKey + ) + const transactions = + await this.partyAllocator.generateTopologyTransactions( + partyHint, + key.publicKey + ) + const topologyTransactions = transactions.topologyTransactions ?? [] + + const internalTxId = crypto + .randomUUID() + .replace(/-/g, '') + .substring(0, 16) + const txPayload = JSON.stringify(topologyTransactions) + + const { status, txId } = await driver + .signTransaction({ + tx: Buffer.from(txPayload).toString('base64'), + txHash: transactions.multiHash, + keyIdentifier: { id: key.id, publicKey: key.publicKey }, + internalTxId, + }) + .then(handleSigningError) + + const network = await this.store.getCurrentNetwork() + const walletBase: Omit = { + partyId: `${partyHint}::${namespace}`, + hint: partyHint, + namespace, + signingProviderId: SigningProvider.SECUROSYS, + networkId: network.id, + primary, + publicKey: key.publicKey, + externalTxId: txId, + topologyTransactions: topologyTransactions.join(', '), + rights: [], + } + + const wallet = await this.finalizeWallet( + walletBase, + userId, + status, + txId, + topologyTransactions, + namespace, + driver + ) + + await this.store.addWallet(wallet) + return wallet + } + + async allocateParty( + userId: UserId, + _email: string | undefined, + existingWallet: Wallet + ): Promise { + if ( + !existingWallet.externalTxId || + !existingWallet.topologyTransactions + ) { + throw new Error( + 'Existing wallet is missing field externalTxId or topologyTransactions' + ) + } + const driver = this.signingDriver.controller(userId) + + const { signature, status, metadata } = await driver + .getTransaction({ txId: existingWallet.externalTxId }) + .then(handleSigningError) + + let walletUpdate: UpdateWallet = { + partyId: existingWallet.partyId, + networkId: existingWallet.networkId, + } + if (status === 'signed') { + if (!signature) { + throw new Error( + 'Transaction signed but no signature found in result' + ) + } + const partyId = + await this.partyAllocator.allocatePartyWithExistingWallet( + existingWallet.namespace, + existingWallet.topologyTransactions.split(', '), + signature, + userId + ) + walletUpdate = { + ...walletUpdate, + partyId, + status: 'allocated', + reason: '', + } + } else if (status === 'pending') { + walletUpdate = { + ...walletUpdate, + status: 'initialized', + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING, + } + } else { + this.logger.warn( + `Topology transaction for wallet ${existingWallet.partyId} was ${status} with ${JSON.stringify(metadata)}` + ) + const reason = + status === 'rejected' + ? WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED + : WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_FAILED + walletUpdate = { + ...walletUpdate, + status: 'removed', + disabled: true, + reason, + } + } + + return this.store.updateWallet(walletUpdate) + } + + private async finalizeWallet( + walletBase: Omit, + userId: UserId, + status: string, + txId: string, + topologyTransactions: string[], + namespace: string, + driver: ReturnType + ): Promise { + if (status === 'signed') { + const { signature } = await driver + .getTransaction({ txId }) + .then(handleSigningError) + if (!signature) { + throw new Error( + 'Transaction signed but no signature found in result' + ) + } + const partyId = + await this.partyAllocator.allocatePartyWithExistingWallet( + namespace, + topologyTransactions, + signature, + userId + ) + return { ...walletBase, partyId, status: 'allocated' } + } + + if (status === 'pending') { + return { + ...walletBase, + status: 'initialized', + reason: WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING, + } + } + + const reason = + status === 'rejected' + ? WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_REJECTED + : WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_FAILED + return { + ...walletBase, + status: 'removed', + disabled: true, + reason, + } + } +} diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts index 00240d8f9..68a83da4e 100644 --- a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.test.ts @@ -209,6 +209,55 @@ function createDfnsDriver(options: { } as unknown as SigningDriverInterface } +function createSecurosysDriver(options: { + createKeyResult?: + | { id: string; publicKey: string } + | { error: string; error_description: string } + signTransactionResult?: { status: string; txId: string } + getTransactionResult?: { + txId: string + status: string + signature?: string + metadata?: unknown + } +}): SigningDriverInterface { + const createKeyResult = options.createKeyResult ?? { + id: 'key-1', + publicKey: 'securosys-pk', + } + const signTransactionResult = options.signTransactionResult ?? { + status: 'pending', + txId: 'tx-1', + } + const getTransactionResult = + options.getTransactionResult ?? signTransactionResult + return { + controller: vi.fn().mockReturnValue({ + createKey: vi + .fn< + () => Promise< + | { id: string; publicKey: string } + | { error: string; error_description: string } + > + >() + .mockResolvedValue(createKeyResult), + signTransaction: vi + .fn<() => Promise<{ status: string; txId: string }>>() + .mockResolvedValue(signTransactionResult), + getTransaction: vi + .fn< + () => Promise<{ + txId: string + status: string + signature?: string + metadata?: unknown + }> + >() + .mockResolvedValue(getTransactionResult), + }), + } as unknown as SigningDriverInterface +} + describe('WalletAllocationService', () => { let mockLogger: Logger let mockStore: { @@ -1503,4 +1552,108 @@ describe('WalletAllocationService', () => { } ) }) + + describe('Securosys', () => { + it('throws when Securosys signing driver not available', async () => { + const serviceWithoutSecurosys = createService({}) + + await expect( + serviceWithoutSecurosys.createWallet( + authContext, + 'alice', + false, + SigningProvider.SECUROSYS + ) + ).rejects.toThrow('Securosys signing driver not available') + }) + + it('createWallet returns initialized when signTransaction returns pending', async () => { + const driver = createSecurosysDriver({ + createKeyResult: { + id: 'alice-key', + publicKey: 'securosys-pk', + }, + signTransactionResult: { + status: 'pending', + txId: 'tsb-request-1', + }, + }) + const serviceWithSecurosys = createService({ + [SigningProvider.SECUROSYS]: driver, + }) + + const result = await serviceWithSecurosys.createWallet( + authContext, + 'alice', + false, + SigningProvider.SECUROSYS + ) + + const controller = vi.mocked(driver.controller).mock.results[0] + ?.value as { + createKey: Mock + signTransaction: Mock + } + expect(controller.createKey).toHaveBeenCalledWith({ + name: 'alice', + }) + expect(controller.signTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: 'hash', + keyIdentifier: { + id: 'alice-key', + publicKey: 'securosys-pk', + }, + }) + ) + expect(result.status).toBe('initialized') + expect(result.reason).toBe( + WALLET_DISABLED_REASON.TOPOLOGY_TRANSACTION_PENDING + ) + expect(result.signingProviderId).toBe(SigningProvider.SECUROSYS) + expect(result.externalTxId).toBe('tsb-request-1') + expect(mockStore.addWallet).toHaveBeenCalled() + }) + + it('allocateParty updates wallet to allocated when transaction is signed', async () => { + const serviceWithSecurosys = createService({ + [SigningProvider.SECUROSYS]: createSecurosysDriver({ + getTransactionResult: { + txId: 'tsb-request-1', + status: 'signed', + signature: 'sig-base64', + }, + }), + }) + mockPartyAllocator.allocatePartyWithExistingWallet.mockResolvedValue( + 'alice::namespace' + ) + + await serviceWithSecurosys.allocateParty( + authContext, + createWallet('alice::fingerprint', { + signingProviderId: SigningProvider.SECUROSYS, + namespace: 'fingerprint', + topologyTransactions: 'tx1', + externalTxId: 'tsb-request-1', + }), + SigningProvider.SECUROSYS + ) + + expect( + mockPartyAllocator.allocatePartyWithExistingWallet + ).toHaveBeenCalledWith( + 'fingerprint', + ['tx1'], + 'sig-base64', + authContext.userId + ) + expect(mockStore.updateWallet).toHaveBeenCalledWith({ + networkId: 'network1', + partyId: 'alice::namespace', + status: 'allocated', + reason: '', + }) + }) + }) }) diff --git a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts index a6f73bec8..ac966bef9 100644 --- a/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts +++ b/wallet-gateway/remote/src/ledger/wallet-allocation/wallet-allocation-service.ts @@ -19,6 +19,7 @@ import { KernelWalletAllocator } from './signing-providers/kernel-wallet-allocat import { FireblocksWalletAllocator } from './signing-providers/fireblocks-wallet-allocator.js' import { BlockdaemonWalletAllocator } from './signing-providers/blockdaemon-wallet-allocator.js' import { DfnsWalletAllocator } from './signing-providers/dfns-wallet-allocator.js' +import { SecurosysWalletAllocator } from './signing-providers/securosys-wallet-allocator.js' export interface WalletAllocator { createWallet( @@ -42,6 +43,7 @@ export class WalletAllocationService { private readonly fireblocksAllocator?: FireblocksWalletAllocator private readonly blockdaemonAllocator?: BlockdaemonWalletAllocator private readonly dfnsAllocator?: DfnsWalletAllocator + private readonly securosysAllocator?: SecurosysWalletAllocator constructor( store: Store, @@ -96,6 +98,16 @@ export class WalletAllocationService { dfnsDriver ) } + + const securosysDriver = signingDrivers[SigningProvider.SECUROSYS] + if (securosysDriver) { + this.securosysAllocator = new SecurosysWalletAllocator( + store, + logger, + partyAllocator, + securosysDriver + ) + } } public async createWallet( @@ -166,6 +178,16 @@ export class WalletAllocationService { partyHint, primary ) + case SigningProvider.SECUROSYS: + if (!this.securosysAllocator) { + throw new Error('Securosys signing driver not available') + } + return this.securosysAllocator.createWallet( + authContext.userId, + authContext.email, + partyHint, + primary + ) default: throw new Error( `Unsupported signing provider: ${signingProviderId}` @@ -228,6 +250,15 @@ export class WalletAllocationService { authContext.email, existingWallet ) + case SigningProvider.SECUROSYS: + if (!this.securosysAllocator) { + throw new Error('Securosys signing driver not available') + } + return this.securosysAllocator.allocateParty( + authContext.userId, + authContext.email, + existingWallet + ) default: throw new Error( `Unsupported signing provider: ${signingProviderId}` diff --git a/yarn.lock b/yarn.lock index 69a2a735d..7db905332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1663,6 +1663,21 @@ __metadata: languageName: unknown linkType: soft +"@canton-network/core-signing-securosys@workspace:^, @canton-network/core-signing-securosys@workspace:core/signing-securosys": + version: 0.0.0-use.local + resolution: "@canton-network/core-signing-securosys@workspace:core/signing-securosys" + dependencies: + "@canton-network/core-signing-lib": "workspace:^" + "@canton-network/core-wallet-auth": "workspace:^" + "@types/node": "npm:^25.9.4" + "@vitest/coverage-v8": "npm:^4.1.10" + tsup: "npm:^8.5.1" + typescript: "npm:^5.9.3" + undici: "npm:^7.24.5" + vitest: "npm:^4.1.10" + languageName: unknown + linkType: soft + "@canton-network/core-signing-store-sql@workspace:^, @canton-network/core-signing-store-sql@workspace:core/signing-store-sql": version: 0.0.0-use.local resolution: "@canton-network/core-signing-store-sql@workspace:core/signing-store-sql" @@ -2273,6 +2288,7 @@ __metadata: "@canton-network/core-signing-internal": "workspace:^" "@canton-network/core-signing-lib": "workspace:^" "@canton-network/core-signing-participant": "workspace:^" + "@canton-network/core-signing-securosys": "workspace:^" "@canton-network/core-signing-store-sql": "workspace:^" "@canton-network/core-tx-visualizer": "workspace:^" "@canton-network/core-types": "workspace:^"