Skip to content

Commit e28f9c2

Browse files
committed
feat: introduce worker mode for signing with AES-256-GCM key sealing
- Added support for a new signing mode ("worker") that uses AES-256-GCM encryption for private keys. - Updated README.md to reflect changes in key management and deployment instructions for worker mode. - Modified API to handle identity creation and signing in both enclave and worker modes. - Implemented workerSigner module for key generation and signing in worker mode. - Enhanced error handling for identity restoration and signing processes. - Updated deployment scripts to support worker mode deployment. - Adjusted runbook documentation to include details on switching between enclave and worker modes.
1 parent 93eaba7 commit e28f9c2

File tree

11 files changed

+493
-89
lines changed

11 files changed

+493
-89
lines changed

README.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Bitcoin for AI Agents.
66

7-
Agents hold Bitcoin — the only money they can cryptographically verify. When they need to pay for something (APIs, stablecoins, inference), they swap BTC on the fly. Private keys live in hardware enclaves. One CLI for BTC, Lightning, Ark, and stablecoins.
7+
Agents hold Bitcoin — the only money they can cryptographically verify. When they need to pay for something (APIs, stablecoins, inference), they swap BTC on the fly. Private keys are managed by the API in one of two modes: a hardware-isolated Evervault Enclave (operator cannot read keys), or a pure Cloudflare Worker using AES-256-GCM encryption (cheaper, operator-trusted). One CLI for BTC, Lightning, Ark, and stablecoins.
88

99
Works with any agent harness — [OpenClaw](https://openclaw.ai), Claude Code, or your own. Give your agent a wallet it can actually verify.
1010

@@ -18,17 +18,17 @@ The swap infrastructure (LendaSwap + Boltz) and ECDSA signing are already in pla
1818

1919
## How it works
2020

21-
```
21+
```text
2222
Agent ──► cash CLI ──► skills/ ──► sdk/ ──► clw.cash API ──► Enclave (secp256k1)
2323
2424
└── audit log, rate limits, 2FA via Telegram
2525
```
2626

2727
## Layout
2828

29-
```
30-
api/ Public-facing REST API (auth, identities, signing)
31-
enclave/ Signer service (runs inside Evervault Enclave)
29+
```text
30+
api/ Public-facing REST API (auth, identities, signing, worker-mode signer)
31+
enclave/ Signer service (runs inside Evervault Enclave — enclave mode only)
3232
sdk/ TypeScript SDK — RemoteSignerIdentity, API client, signing utils
3333
skills/ Bitcoin, Lightning, and Stablecoin skills (Ark, Boltz, LendaSwap)
3434
cli/ Agent-friendly CLI ("cash") — send, receive, balance
@@ -136,7 +136,7 @@ pnpm typecheck
136136

137137
clw.cash acts as a **factory bot** — a backend service that other Telegram bots use to give their users Bitcoin wallets. Your bot authenticates with a shared API key and gets per-user sessions without any user-facing auth flow.
138138

139-
### How it works
139+
### Factory bot flow
140140

141141
```text
142142
User (Telegram) Your Bot clw.cash API Enclave
@@ -206,19 +206,42 @@ const result = await bitcoin.send({ address: "ark1q...", amount: 1000 });
206206
| **Bot session** | Bot API key + `telegram_user_id` → instant JWT | Telegram bot serving many users |
207207
| **Test mode** | No `TELEGRAM_BOT_TOKEN` → auto-resolves | Local dev, CI |
208208

209-
## Deploy to Evervault
209+
## Deploy
210+
211+
See [docs/runbook.md](docs/runbook.md) for the full operator guide.
212+
213+
### Worker mode (Cloudflare-only, no enclave)
214+
215+
```bash
216+
# Generate a 32-byte AES-256 key for encrypting private keys at rest
217+
openssl rand -hex 32 | wrangler secret put WORKER_SEALING_KEY --env production
218+
219+
# Set SIGNER_MODE = "worker" in api/wrangler.toml [env.production.vars], then:
220+
cd api && pnpm deploy:prod
221+
```
222+
223+
### Enclave mode (Evervault, hardware-isolated)
210224

211225
Install the [Evervault CLI](https://docs.evervault.com/cli), then:
212226

213227
```bash
214228
# one-time: generate signing certs
215229
ev enclave cert new --output ./infra
216230

217-
# build enclave image
231+
# build and deploy
218232
ev enclave build -v --output . -c ./infra/enclave.toml ./enclave
219-
220-
# deploy
221233
ev enclave deploy -v --eif-path ./enclave.eif -c ./infra/enclave.toml
234+
235+
# then deploy the API worker (SIGNER_MODE = "enclave")
236+
cd api && pnpm deploy:prod
237+
```
238+
239+
Or use the deploy script:
240+
241+
```bash
242+
./scripts/deploy.sh generate-secrets
243+
./scripts/deploy.sh enclave # enclave mode: build + deploy enclave + API
244+
./scripts/deploy.sh worker-api # worker mode: deploy API only (no enclave needed)
222245
```
223246

224247
## Roadmap

api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"typecheck": "tsc --noEmit"
1313
},
1414
"dependencies": {
15+
"@noble/hashes": "^1.6.0",
16+
"@noble/secp256k1": "3.0.0",
1517
"hono": "^4.7.0",
1618
"jose": "^6.0.0",
1719
"zod": "^3.24.2"

api/src/bindings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export interface Env {
1818
// Variables
1919
ALLOW_TEST_AUTH: string;
2020
ENCLAVE_BASE_URL: string;
21+
SIGNER_MODE: string; // "enclave" (default) | "worker"
22+
23+
// Secrets (worker mode only)
24+
WORKER_SEALING_KEY: string; // 32-byte hex AES-256 master key for encrypting private keys
2125
TICKET_TTL_SECONDS: string;
2226
SESSION_TTL_SECONDS: string;
2327
CHALLENGE_TTL_SECONDS: string;

api/src/index.ts

Lines changed: 117 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Env } from "./bindings.js";
66
import { CloudflareStore } from "./store.js";
77
import { KVRateLimiter } from "./rateLimit.js";
88
import { EnclaveClient, EnclaveClientError } from "./enclaveClient.js";
9+
import * as workerSigner from "./workerSigner.js";
910
import { signSessionToken, signTicketToken, verifySessionToken, verifyTicketToken } from "./auth.js";
1011
import {
1112
challengeRequestSchema,
@@ -52,6 +53,21 @@ function getEnclave(env: Env): EnclaveClient {
5253
return new EnclaveClient(env.ENCLAVE_BASE_URL, env.INTERNAL_API_KEY, env.EV_API_KEY || undefined);
5354
}
5455

56+
function isWorkerMode(env: Env): boolean {
57+
return env.SIGNER_MODE === "worker";
58+
}
59+
60+
function requireMasterKey(env: Env): string {
61+
if (!env.WORKER_SEALING_KEY) throw new HTTPException(500, { message: "WORKER_SEALING_KEY secret not configured for worker mode" });
62+
return env.WORKER_SEALING_KEY;
63+
}
64+
65+
// Worker sealed keys are "{24 hex iv}:{variable hex ciphertext+tag}".
66+
// Enclave (Evervault) keys are opaque blobs that don't match this pattern.
67+
function isWorkerSealedKey(sealedKey: string): boolean {
68+
return /^[0-9a-f]{24}:[0-9a-f]+$/.test(sealedKey);
69+
}
70+
5571
async function currentUser(store: CloudflareStore, userId: string) {
5672
const user = await store.getUserById(userId);
5773
if (!user) throw new HTTPException(401, { message: "Session user no longer exists" });
@@ -210,7 +226,6 @@ app.post("/v1/identities", requireAuth, async (c) => {
210226
const auth = c.get("auth");
211227
const store = getStore(c.env);
212228
const limiter = getLimiter(c.env);
213-
const enclave = getEnclave(c.env);
214229

215230
const user = await currentUser(store, auth.sub);
216231
await enforceRate(limiter, `user:${user.id}:identity_create`, c.env);
@@ -219,11 +234,21 @@ app.post("/v1/identities", requireAuth, async (c) => {
219234
const identityId = crypto.randomUUID();
220235
const alg: SupportedAlg = body.alg ?? "secp256k1";
221236

222-
const generated = await enclave.generate(identityId, alg);
223-
const exported = await enclave.exportKey(identityId);
224-
await store.putBackup({ identity_id: identityId, alg: exported.alg, sealed_key: exported.sealed_key });
237+
let publicKey: string;
238+
if (isWorkerMode(c.env)) {
239+
const masterKey = requireMasterKey(c.env);
240+
const generated = await workerSigner.generateKey(masterKey);
241+
await store.putBackup({ identity_id: identityId, alg, sealed_key: generated.sealedKey });
242+
publicKey = generated.publicKey;
243+
} else {
244+
const enclave = getEnclave(c.env);
245+
const generated = await enclave.generate(identityId, alg);
246+
const exported = await enclave.exportKey(identityId);
247+
await store.putBackup({ identity_id: identityId, alg: exported.alg, sealed_key: exported.sealed_key });
248+
publicKey = generated.public_key;
249+
}
225250

226-
const identity = await store.createIdentity({ id: identityId, user_id: user.id, alg, public_key: generated.public_key });
251+
const identity = await store.createIdentity({ id: identityId, user_id: user.id, alg, public_key: publicKey });
227252
await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.create", metadata: { alg: identity.alg } });
228253

229254
return c.json(identity, 201);
@@ -233,21 +258,35 @@ app.post("/v1/identities/:id/restore", requireAuth, async (c) => {
233258
const auth = c.get("auth");
234259
const identityId = c.req.param("id");
235260
const store = getStore(c.env);
236-
const enclave = getEnclave(c.env);
237261

238262
const user = await currentUser(store, auth.sub);
239263

240264
const existing = await store.getIdentity(identityId);
241265
if (existing) {
242266
if (existing.user_id !== user.id) throw new HTTPException(403, { message: "Identity does not belong to session user" });
243-
await restoreBackup(store, enclave, identityId);
267+
if (isWorkerMode(c.env)) {
268+
const backup = await store.getBackup(identityId);
269+
if (backup && !isWorkerSealedKey(backup.sealed_key)) {
270+
throw new HTTPException(409, { message: "Identity was created in enclave mode and cannot be used in worker mode. Please create a new identity." });
271+
}
272+
} else {
273+
const enclave = getEnclave(c.env);
274+
await restoreBackup(store, enclave, identityId);
275+
}
244276
return c.json(existing);
245277
}
246278

247279
const backup = await store.getBackup(identityId);
248280
if (!backup) throw new HTTPException(404, { message: "No backup found for this identity" });
249281

250-
await enclave.importKey(identityId, backup.alg, stripWrappingQuotes(backup.sealed_key));
282+
if (isWorkerMode(c.env)) {
283+
if (!isWorkerSealedKey(backup.sealed_key)) {
284+
throw new HTTPException(409, { message: "Identity was created in enclave mode and cannot be used in worker mode. Please create a new identity." });
285+
}
286+
} else {
287+
const enclave = getEnclave(c.env);
288+
await enclave.importKey(identityId, backup.alg, stripWrappingQuotes(backup.sealed_key));
289+
}
251290

252291
const body = (await c.req.json()) as { public_key?: string };
253292
if (!body.public_key) throw new HTTPException(400, { message: "Missing public_key in request body" });
@@ -292,7 +331,6 @@ app.post("/v1/identities/:id/sign", requireAuth, async (c) => {
292331
const identityId = c.req.param("id");
293332
const store = getStore(c.env);
294333
const limiter = getLimiter(c.env);
295-
const enclave = getEnclave(c.env);
296334

297335
const user = await currentUser(store, auth.sub);
298336
const identity = await ownedActiveIdentity(store, identityId, user.id);
@@ -314,14 +352,22 @@ app.post("/v1/identities/:id/sign", requireAuth, async (c) => {
314352
if (new Date(ticket.expires_at).getTime() <= Date.now()) throw new HTTPException(410, { message: "Ticket expired" });
315353

316354
let signature: string;
317-
try {
318-
signature = (await enclave.sign(identity.id, digest, body.ticket)).signature;
319-
} catch (error) {
320-
if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
321-
if (!(await restoreBackup(store, enclave, identity.id))) {
322-
throw new HTTPException(409, { message: "Key not present in enclave and no backup available" });
355+
if (isWorkerMode(c.env)) {
356+
const masterKey = requireMasterKey(c.env);
357+
const backup = await store.getBackup(identity.id);
358+
if (!backup) throw new HTTPException(409, { message: "No key backup found" });
359+
signature = await workerSigner.signDigest(backup.sealed_key, masterKey, digest);
360+
} else {
361+
const enclave = getEnclave(c.env);
362+
try {
363+
signature = (await enclave.sign(identity.id, digest, body.ticket)).signature;
364+
} catch (error) {
365+
if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
366+
if (!(await restoreBackup(store, enclave, identity.id))) {
367+
throw new HTTPException(409, { message: "Key not present in enclave and no backup available" });
368+
}
369+
signature = (await enclave.sign(identity.id, digest, body.ticket)).signature;
323370
}
324-
signature = (await enclave.sign(identity.id, digest, body.ticket)).signature;
325371
}
326372

327373
await store.markTicketUsed(ticket.id);
@@ -335,7 +381,6 @@ app.post("/v1/identities/:id/sign-batch", requireAuth, async (c) => {
335381
const identityId = c.req.param("id");
336382
const store = getStore(c.env);
337383
const limiter = getLimiter(c.env);
338-
const enclave = getEnclave(c.env);
339384

340385
const user = await currentUser(store, auth.sub);
341386
const identity = await ownedActiveIdentity(store, identityId, user.id);
@@ -345,34 +390,55 @@ app.post("/v1/identities/:id/sign-batch", requireAuth, async (c) => {
345390
const ticketTtl = parseInt(c.env.TICKET_TTL_SECONDS, 10);
346391
const signatures: string[] = [];
347392

348-
for (const item of body.digests) {
349-
const digest = normalizeDigestHex(item.digest);
350-
const digestHash = await CloudflareStore.digestHash(digest);
351-
const nonce = crypto.randomUUID();
352-
const ticketId = crypto.randomUUID();
353-
354-
const ticket = await signTicketToken(
355-
{ jti: ticketId, sub: user.id, identity_id: identity.id, digest_hash: digestHash, scope: "sign", nonce },
356-
c.env.TICKET_SIGNING_SECRET,
357-
ticketTtl,
358-
);
359-
360-
const expiresAt = new Date(Date.now() + ticketTtl * 1000).toISOString();
361-
await store.createTicket({ id: ticketId, identity_id: identity.id, digest_hash: digestHash, scope: "sign", expires_at: expiresAt, nonce }, ticketTtl);
362-
363-
let signature: string;
364-
try {
365-
signature = (await enclave.sign(identity.id, digest, ticket)).signature;
366-
} catch (error) {
367-
if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
368-
if (!(await restoreBackup(store, enclave, identity.id))) {
369-
throw new HTTPException(409, { message: "Key not present in enclave and no backup available" });
370-
}
371-
signature = (await enclave.sign(identity.id, digest, ticket)).signature;
393+
// Worker mode: fetch backup once for all digests
394+
let workerBackupSealedKey: string | null = null;
395+
if (isWorkerMode(c.env)) {
396+
const masterKey = requireMasterKey(c.env);
397+
const backup = await store.getBackup(identity.id);
398+
if (!backup) throw new HTTPException(409, { message: "No key backup found" });
399+
workerBackupSealedKey = backup.sealed_key;
400+
// masterKey captured in closure below
401+
for (const item of body.digests) {
402+
const digest = normalizeDigestHex(item.digest);
403+
const digestHash = await CloudflareStore.digestHash(digest);
404+
const ticketId = crypto.randomUUID();
405+
const expiresAt = new Date(Date.now() + ticketTtl * 1000).toISOString();
406+
await store.createTicket({ id: ticketId, identity_id: identity.id, digest_hash: digestHash, scope: "sign", expires_at: expiresAt, nonce: crypto.randomUUID() }, ticketTtl);
407+
const signature = await workerSigner.signDigest(workerBackupSealedKey, masterKey, digest);
408+
await store.markTicketUsed(ticketId);
409+
signatures.push(signature);
372410
}
411+
} else {
412+
const enclave = getEnclave(c.env);
413+
for (const item of body.digests) {
414+
const digest = normalizeDigestHex(item.digest);
415+
const digestHash = await CloudflareStore.digestHash(digest);
416+
const nonce = crypto.randomUUID();
417+
const ticketId = crypto.randomUUID();
418+
419+
const ticket = await signTicketToken(
420+
{ jti: ticketId, sub: user.id, identity_id: identity.id, digest_hash: digestHash, scope: "sign", nonce },
421+
c.env.TICKET_SIGNING_SECRET,
422+
ticketTtl,
423+
);
424+
425+
const expiresAt = new Date(Date.now() + ticketTtl * 1000).toISOString();
426+
await store.createTicket({ id: ticketId, identity_id: identity.id, digest_hash: digestHash, scope: "sign", expires_at: expiresAt, nonce }, ticketTtl);
427+
428+
let signature: string;
429+
try {
430+
signature = (await enclave.sign(identity.id, digest, ticket)).signature;
431+
} catch (error) {
432+
if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
433+
if (!(await restoreBackup(store, enclave, identity.id))) {
434+
throw new HTTPException(409, { message: "Key not present in enclave and no backup available" });
435+
}
436+
signature = (await enclave.sign(identity.id, digest, ticket)).signature;
437+
}
373438

374-
await store.markTicketUsed(ticketId);
375-
signatures.push(signature);
439+
await store.markTicketUsed(ticketId);
440+
signatures.push(signature);
441+
}
376442
}
377443

378444
await store.addAuditEvent({ user_id: user.id, identity_id: identity.id, action: "identity.sign", metadata: { batch_size: body.digests.length } });
@@ -385,18 +451,20 @@ app.delete("/v1/identities/:id", requireAuth, async (c) => {
385451
const identityId = c.req.param("id");
386452
const store = getStore(c.env);
387453
const limiter = getLimiter(c.env);
388-
const enclave = getEnclave(c.env);
389454

390455
const user = await currentUser(store, auth.sub);
391456
const identity = await ownedActiveIdentity(store, identityId, user.id);
392457
await enforceRate(limiter, `identity:${identity.id}:destroy`, c.env);
393458

394-
try {
395-
await enclave.destroy(identity.id);
396-
} catch (error) {
397-
if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
398-
if (await restoreBackup(store, enclave, identity.id)) {
459+
if (!isWorkerMode(c.env)) {
460+
const enclave = getEnclave(c.env);
461+
try {
399462
await enclave.destroy(identity.id);
463+
} catch (error) {
464+
if (!(error instanceof EnclaveClientError) || error.statusCode !== 404) throw error;
465+
if (await restoreBackup(store, enclave, identity.id)) {
466+
await enclave.destroy(identity.id);
467+
}
400468
}
401469
}
402470

0 commit comments

Comments
 (0)