@0xsend/external-signing
v0.2.2
Published
TypeScript library for external signing on the Canton Network
Readme
Canton External Signing
A TypeScript library for integrating external signing mechanisms with the Canton Network, enabling secure transaction signing using external key management systems, hardware security modules (HSMs), or software wallets.
Overview
Canton External Signing provides a flexible framework for signing Canton Network transactions without exposing private keys to the application. This is essential for production deployments where key security is paramount.
Key Features
- 🔐 Secure Key Management - Keep private keys in HSMs, secure enclaves, or external wallets
- 🔑 Multiple Signer Support - Register and manage multiple signing identities
- ⚡ Async Signing - Non-blocking signature operations with timeout support
- 🛡️ Type Safety - Full TypeScript support with strict typing
- 🧪 Well Tested - Comprehensive test suite included
- 🔌 Extensible - Easy to implement custom signers
- 📝 Prepare/Submit Pattern - Canton handles transaction building, client only signs
Architecture
Overall System Architecture
graph TB
subgraph "Browser/Client"
WA[Web App]
ES[External Signer]
KS[Key Storage]
end
subgraph "Canton Network"
LED[Ledger API]
VAL[Validator]
SYNC[Synchronizer]
end
WA -->|1. Prepare Tx| LED
LED -->|2. Return Hash| WA
WA -->|3. Sign Hash| ES
ES -->|4. Access Key| KS
KS -->|5. Return Signature| ES
ES -->|6. Return Signature| WA
WA -->|7. Submit Signed Tx| LED
LED -->|8. Validate| VAL
VAL -->|9. Commit| SYNCSigning Flow Sequence
sequenceDiagram
participant User
participant WebApp
participant Signer
participant Canton
User->>WebApp: Initiate Transaction
WebApp->>Canton: Prepare Command
Canton->>WebApp: Return Tx Hash
WebApp->>Signer: Request Signature
Signer->>Signer: Generate Signature
Signer->>WebApp: Return Signature
WebApp->>Canton: Submit Signed Tx
Canton->>WebApp: Confirmation
WebApp->>User: Show ResultComponent Architecture
graph LR
subgraph "External Signing Package"
API[API Client]
SM[Signer Manager]
CS[Crypto Services]
ST[Storage]
end
subgraph "Signer Implementations"
MEM[Memory Signer]
HSM[HSM Signer]
HW[Hardware Wallet]
ED[Ed25519 Signer]
end
API --> SM
SM --> CS
SM --> ST
CS --> MEM
CS --> HSM
CS --> HW
CS --> EDInstallation
# Using yarn (recommended for canton-monorepo)
yarn add @0xsend/external-signing
# Using npm
npm install @0xsend/external-signingQuick Start
Basic Example
import {
CantonExternalClient,
InMemorySigner,
createTestSigner,
} from "@0xsend/external-signing";
// 1. Create a client instance
const client = new CantonExternalClient({
ledgerApiUrl: "https://canton.example.com/",
token: "your-jwt-token",
});
// 2. Create and register a signer
const signer = await createTestSigner("my-signer", "ES256");
client.registerSigner(signer);
// 3. Create a DAML command
const command = {
template: MyTemplate,
argument: {
owner: "alice",
value: 100,
},
party: "alice::1234...",
};
// 4. Submit with external signing
const result = await client.submitCreateCommand(command, "my-signer", {
verifySignature: true,
});
console.log("Transaction ID:", result.transactionId);Using the Prepare/Submit Pattern
For more control over the signing process, use the prepare/submit pattern:
import { CantonExternalClient, Ed25519Signer } from "@0xsend/external-signing";
// Create client with external party API
const client = new CantonExternalClient({
validatorUrl: "https://validator.example.com/",
authToken: "your-auth-token",
});
// Create an Ed25519 signer
const signer = new Ed25519Signer();
// Create external party
const party = await client.createExternalParty("alice", signer);
console.log("Party ID:", party.partyId);
// Execute a transfer with prepare/submit
const transferParams = {
sender_party_id: party.partyId,
receiver_party_id: "bob::5678...",
amount: "100.00",
transfer_preapproval_contract_id: "contract-id",
};
const result = await client.executeTransferPreapproval(transferParams, signer);API Reference
CantonExternalClient
The main client for interacting with Canton using external signers.
class CantonExternalClient {
constructor(config: CantonLedgerConfig);
// Signer management
registerSigner(signer: ExternalSigner): void;
unregisterSigner(signerId: string): boolean;
getSigner(signerId: string): ExternalSigner | undefined;
listSigners(): string[];
// Command submission
submitCreateCommand<T>(
command: DamlCommand<T>,
signerId: string,
options?: ExternalSigningOptions
): Promise<CommandSubmissionResult>;
submitExerciseCommand<T, R>(
command: DamlExerciseCommand<T, R>,
signerId: string,
options?: ExternalSigningOptions
): Promise<CommandSubmissionResult>;
}ExternalSigner Interface
All signers must implement this interface:
interface ExternalSigner {
readonly id: string;
sign(data: Uint8Array): Promise<Signature>;
getPublicKeyHex(): Promise<string>;
getPublicKey(): Promise<Uint8Array>;
getSupportedAlgorithms?(): string[];
}Built-in Signers
InMemorySigner
For development and testing:
const signer = new InMemorySigner("signer-id", "ES256");
await signer.initialize();Ed25519Signer
For production use with Ed25519 keys:
const signer = new Ed25519Signer();
// Or restore from private key
const signer = Ed25519Signer.fromPrivateKey(privateKeyHex);MockHSMSigner
Simulates HSM behavior for testing:
const signer = new MockHSMSigner(
"hsm-signer",
"key-id",
"ES256",
100 // simulated latency in ms
);
await signer.initialize("PIN-CODE");Core Concepts
Canton's Prepare/Submit Pattern
Canton uses a server-side transaction preparation pattern:
- Prepare: Canton builds the transaction and returns a hash
- Sign: Client signs only the hash (no serialization needed)
- Submit: Client submits the signature back to Canton
// Canton prepares the transaction
const prepared = await api.prepareTransferPreapproval(params);
// prepared = { transaction: "<base64>", tx_hash: "<hex>" }
// Client signs the hash
const signature = await signer.sign(hexToBytes(prepared.tx_hash));
// Submit the signature
await api.submitTransferPreapproval({
party_id: params.sender_party_id,
transaction: prepared.transaction,
signed_tx_hash: signature.value,
public_key: publicKeyHex,
});External Party Management
The library provides complete external party lifecycle management:
// Create a new party with generated Ed25519 keys
const alice = await manager.createParty("alice");
// Import existing party from backup
const bob = await manager.importParty(
"bob",
privateKeyHex,
knownPartyId // optional
);
// List all managed parties
const parties = await manager.listParties();
// Export private key for backup
const privateKey = await manager.exportPartyKey(alice.partyId);Integration Guide
Web Application Integration
React Example
import { useState, useEffect } from "react";
import { CantonExternalClient, Ed25519Signer } from "@0xsend/external-signing";
function WalletComponent() {
const [client, setClient] = useState<CantonExternalClient>();
const [signer, setSigner] = useState<Ed25519Signer>();
useEffect(() => {
// Initialize client
const client = new CantonExternalClient({
ledgerApiUrl: process.env.CANTON_LEDGER_URL,
token: localStorage.getItem("auth-token"),
});
// Create or restore signer
const signer = new Ed25519Signer();
client.registerSigner(signer);
setClient(client);
setSigner(signer);
}, []);
const handleTransfer = async () => {
if (!client || !signer) return;
const command = {
template: TransferTemplate,
argument: {
/* ... */
},
party: getCurrentParty(),
};
try {
const result = await client.submitCreateCommand(command, signer.id);
console.log("Transfer successful:", result.transactionId);
} catch (error) {
console.error("Transfer failed:", error);
}
};
return <button onClick={handleTransfer}>Send Transfer</button>;
}Next.js Integration
// app/lib/canton-client.ts
import { CantonExternalClient } from "@0xsend/external-signing";
let client: CantonExternalClient;
export function getCantonClient() {
if (!client) {
client = new CantonExternalClient({
ledgerApiUrl: process.env.NEXT_PUBLIC_CANTON_URL!,
// Token handled by middleware
});
}
return client;
}
// app/actions/transfer.ts
("use server");
import { getCantonClient } from "@/lib/canton-client";
import { Ed25519Signer } from "@0xsend/external-signing";
export async function executeTransfer(amount: string, recipient: string) {
const client = getCantonClient();
const signer = new Ed25519Signer();
// ... implement transfer logic
}Custom Signer Implementation
Implement your own signer for custom requirements:
import { ExternalSigner, Signature } from "@0xsend/external-signing";
class MyCustomSigner implements ExternalSigner {
readonly id: string;
constructor(id: string) {
this.id = id;
}
async sign(data: Uint8Array): Promise<Signature> {
// Your signing logic here
// e.g., call to HSM, hardware wallet, etc.
return {
algorithm: "ES256",
value: signatureHex,
publicKey: publicKeyHex,
keyId: this.id,
};
}
async getPublicKeyHex(): Promise<string> {
// Return hex-encoded public key
}
async getPublicKey(): Promise<Uint8Array> {
// Return raw public key bytes
}
getSupportedAlgorithms(): string[] {
return ["ES256", "ES384"];
}
}Error Handling
The library provides specific error types for different scenarios:
import {
ExternalSigningError,
ExternalSigningErrorCode,
} from "@0xsend/external-signing";
try {
await client.submitCreateCommand(command, signerId);
} catch (error) {
if (error instanceof ExternalSigningError) {
switch (error.code) {
case ExternalSigningErrorCode.SIGNER_NOT_FOUND:
console.error("Signer not registered");
break;
case ExternalSigningErrorCode.SIGNING_FAILED:
console.error("Signing operation failed");
break;
case ExternalSigningErrorCode.TIMEOUT:
console.error("Signing timed out");
break;
// ... handle other error codes
}
}
}Authentication and API Requirements
Important: Admin Authentication Required
External party management endpoints require admin authentication. Regular user tokens will receive 401 Unauthorized errors.
JWT Token Requirements
For Canton localnet with JWKS authentication:
// Get admin token from Keycloak using client credentials
async function getAdminToken(): Promise<string> {
const response = await fetch(
"http://auth-dev.cantonwallet.com/realms/localnet/protocol/openid-connect/token",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: "localnet-validator",
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
audience:
"https://ledger-api.canton.local https://canton.network.global",
}),
}
);
const data = await response.json();
return data.access_token;
}
// Use admin token for external party operations
const adminToken = await getAdminToken();
const manager = new ExternalPartyManager(
{
validatorUrl: "http://localhost:45003",
authToken: adminToken,
},
storage
);Legacy HMAC Authentication (Development Only)
For backwards compatibility with unsafe HMAC tokens:
import { SignJWT } from "jose";
// Only for development environments with SPLICE_APP_UI_UNSAFE=true
async function generateUnsafeAdminToken(): Promise<string> {
const secret = new TextEncoder().encode("unsafe");
const token = await new SignJWT({
sub: "ledger-api-user",
aud: ["https://ledger-api.canton.local", "https://canton.network.global"],
scope: "daml_ledger_api",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(secret);
return token;
}Security Considerations
Best Practices
- Never expose private keys - Keep them in secure storage
- Use hardware security - HSMs or secure enclaves for production
- Implement key rotation - Regular key updates
- Audit signing requests - Log all signature operations
- Validate inputs - Always validate transaction data before signing
Production Checklist
- [ ] Private keys stored in HSM or secure enclave
- [ ] Signing operations require authentication
- [ ] Transaction limits implemented
- [ ] Audit logging enabled
- [ ] Key backup and recovery procedures
- [ ] Regular security audits
Testing
Running Tests
# Unit tests
yarn test src/testxsend/external-signing.test.ts
# Integration tests (requires Canton localnet)
yarn test:integration
# Watch mode
yarn test:watchWriting Tests
import { createTestSigner } from "@0xsend/external-signing";
describe("My Canton Integration", () => {
it("should sign and submit transaction", async () => {
const signer = await createTestSigner("test-signer");
const client = new CantonExternalClient({
ledgerApiUrl: "http://localhost:5001/",
});
client.registerSigner(signer);
// ... test your integration
});
});Troubleshooting
Common Issues
Ledger URL must end with '/'
// ❌ Wrong
ledgerApiUrl: "http://localhost:5001";
// ✅ Correct
ledgerApiUrl: "http://localhost:5001/";Signer not found error
// Make sure to register the signer first
client.registerSigner(signer);Timeout errors
// Increase timeout for slow signers
await client.submitCreateCommand(command, signerId, {
signingTimeoutMs: 30000, // 30 seconds
});401 Unauthorized for external party operations
// Use admin token with 'ledger-api-user' subject
const adminToken = await generateAdminToken();Development
Project Structure
packages/canton-external-signing/
├── src/
│ ├── api/ # Canton API integrations
│ ├── client/ # Client implementations
│ ├── party/ # External party management
│ ├── signers/ # Signer implementations
│ ├── storage/ # Storage interfaces
│ ├── types/ # TypeScript types
│ ├── utils/ # Utilities
│ └── test/ # Test files
├── README.md
└── package.jsonBuilding
# Type checking
yarn typecheck
# Run linter
yarn lint
# Build (if needed)
yarn buildContributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This package is part of the Canton monorepo and follows the same license terms.
Support
For issues and questions:
- GitHub Issues: canton-monorepo/issues
- Documentation: Canton Network Docs
- Canton Community: Join the discussions
