gora8-signer 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/dist/audit-log.d.ts +54 -1
- package/dist/audit-log.js +112 -15
- package/dist/cli.js +109 -23
- package/dist/index.d.ts +10 -5
- package/dist/index.js +6 -3
- package/dist/ipc-client.d.ts +8 -4
- package/dist/ipc-client.js +7 -3
- package/dist/ipc-server.js +2 -0
- package/dist/keystore.d.ts +29 -23
- package/dist/keystore.js +60 -79
- package/dist/kms-signing-backend.d.ts +31 -0
- package/dist/kms-signing-backend.js +182 -0
- package/dist/rate-limit-backend.d.ts +76 -0
- package/dist/rate-limit-backend.js +143 -0
- package/dist/signer.d.ts +114 -85
- package/dist/signer.js +248 -165
- package/dist/signing-backend.d.ts +42 -0
- package/dist/signing-backend.js +58 -0
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -37,11 +37,11 @@ npm install gora8-signer
|
|
|
37
37
|
# shown here; OS keychain is used automatically instead when available —
|
|
38
38
|
# see Storage below)
|
|
39
39
|
export GORA8_SIGNER_KEK=$(openssl rand -hex 32) # dev only — see Storage
|
|
40
|
-
npx gora8-signer init my-agent-id
|
|
40
|
+
npx --package=gora8-signer gora8-signer init my-agent-id
|
|
41
41
|
# {"evmAddress":"0x...","solanaAddress":"..."}
|
|
42
42
|
|
|
43
43
|
# Run the signer as its own process
|
|
44
|
-
npx gora8-signer start my-agent-id
|
|
44
|
+
npx --package=gora8-signer gora8-signer start my-agent-id
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Your agent's own code then talks to it over the socket:
|
|
@@ -72,6 +72,15 @@ a mounted secret, whatever your deployment already uses — never gora8's.
|
|
|
72
72
|
The env-var-based provider shown above is for local development only;
|
|
73
73
|
implement `KekProvider` against your real KMS for production.
|
|
74
74
|
|
|
75
|
+
Auto-detection isn't airtight: some minimal container images (e.g.
|
|
76
|
+
Alpine) ship the OS-keychain library's native binding without a working
|
|
77
|
+
Secret Service underneath it, so it *loads* successfully but can't
|
|
78
|
+
actually store or retrieve anything — confirmed live, not hypothesized.
|
|
79
|
+
If you know your deployment target has no real OS keychain, set
|
|
80
|
+
`GORA8_SIGNER_SECRET_BACKEND=file` (or `keychain` to force the other
|
|
81
|
+
way, erroring loudly instead of silently falling back if it isn't
|
|
82
|
+
actually usable) rather than relying on auto-detection to figure it out.
|
|
83
|
+
|
|
75
84
|
## Policy enforcement
|
|
76
85
|
|
|
77
86
|
Before signing anything, the signer fetches your agent's current signed
|
package/dist/audit-log.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Hex } from "viem";
|
|
2
2
|
import { type MerkleTree } from "./merkle-tree.js";
|
|
3
|
+
import { type DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
3
4
|
export interface AuditEntry {
|
|
4
5
|
timestamp: string;
|
|
5
6
|
operation: string;
|
|
@@ -9,11 +10,63 @@ export interface AuditEntry {
|
|
|
9
10
|
counterparty: string | null;
|
|
10
11
|
txHash?: string;
|
|
11
12
|
}
|
|
12
|
-
export
|
|
13
|
+
export interface AuditLogBackend {
|
|
14
|
+
/** Appends one entry. Must preserve append order for the same reason
|
|
15
|
+
* Merkle-leaf ordering has to be stable — a backend whose writes can
|
|
16
|
+
* reorder or interleave under concurrency (multiple serverless
|
|
17
|
+
* instances appending at once, say) needs its own ordering
|
|
18
|
+
* guarantee (a monotonic sort key, a single-writer queue in front of
|
|
19
|
+
* it, etc.) or computeCommitment()'s root stops meaning "the log as
|
|
20
|
+
* it actually happened." Local, single-process storage gets this for
|
|
21
|
+
* free; a distributed backend has to earn it explicitly. */
|
|
22
|
+
append(entry: AuditEntry): Promise<void>;
|
|
23
|
+
readAll(): Promise<AuditEntry[]>;
|
|
24
|
+
}
|
|
25
|
+
/** File-backed, append-only JSONL — the original (and still the
|
|
26
|
+
* default local/persistent-process) storage, extracted here as one
|
|
27
|
+
* implementation of AuditLogBackend rather than baked into AuditLog
|
|
28
|
+
* itself. */
|
|
29
|
+
export declare class LocalFileAuditLogBackend implements AuditLogBackend {
|
|
13
30
|
private readonly filePath;
|
|
14
31
|
constructor(filePath: string);
|
|
15
32
|
append(entry: AuditEntry): Promise<void>;
|
|
16
33
|
readAll(): Promise<AuditEntry[]>;
|
|
34
|
+
}
|
|
35
|
+
/** DynamoDB-backed — the distributed case LocalFileAuditLogBackend's
|
|
36
|
+
* doc comment flagged as not yet built: multiple concurrent
|
|
37
|
+
* processes appending without a shared filesystem to serialize
|
|
38
|
+
* writes through. Ordering (this file's whole reason for calling it
|
|
39
|
+
* out as a backend-specific concern, not something the AuditLog
|
|
40
|
+
* orchestration layer can paper over) comes from a DynamoDB atomic
|
|
41
|
+
* counter, not wall-clock timestamps — two concurrent appends always
|
|
42
|
+
* get two different, strictly increasing sequence numbers via a
|
|
43
|
+
* single conditionless `ADD` (DynamoDB serializes this itself; no
|
|
44
|
+
* compare-and-swap needed here, unlike the rate-limit backend, since
|
|
45
|
+
* an atomic increment is exactly what's needed and DynamoDB provides
|
|
46
|
+
* it natively). Entries are stored under a different partition
|
|
47
|
+
* (`<key>#entries`, sort key = that sequence number) than the counter
|
|
48
|
+
* itself (`<key>#seq`), so `readAll()`'s Query never has to filter
|
|
49
|
+
* the counter item out. Requires a table with a String partition key
|
|
50
|
+
* named `pk` and a Number sort key named `sortKey`. */
|
|
51
|
+
export declare class DynamoDbAuditLogBackend implements AuditLogBackend {
|
|
52
|
+
private readonly client;
|
|
53
|
+
private readonly tableName;
|
|
54
|
+
private readonly key;
|
|
55
|
+
constructor(client: DynamoDBDocumentClient, tableName: string, key: string);
|
|
56
|
+
private nextSeq;
|
|
57
|
+
append(entry: AuditEntry): Promise<void>;
|
|
58
|
+
readAll(): Promise<AuditEntry[]>;
|
|
59
|
+
}
|
|
60
|
+
/** The orchestration layer — hash-chain leaf computation, Merkle
|
|
61
|
+
* commitment, and spend-tracking, all built on top of whatever
|
|
62
|
+
* AuditLogBackend is injected. Public API unchanged from before this
|
|
63
|
+
* file was split (append/readAll/spentSince/computeCommitment) — only
|
|
64
|
+
* the constructor changed, from a file path to a backend. */
|
|
65
|
+
export declare class AuditLog {
|
|
66
|
+
private readonly backend;
|
|
67
|
+
constructor(backend: AuditLogBackend);
|
|
68
|
+
append(entry: AuditEntry): Promise<void>;
|
|
69
|
+
readAll(): Promise<AuditEntry[]>;
|
|
17
70
|
/** Sum of `amount` across allowed entries since `since` — the local,
|
|
18
71
|
* independent spend tracker policy.ts's SpendTracker interface
|
|
19
72
|
* expects (see that file's doc comment on why this doesn't rely on
|
package/dist/audit-log.js
CHANGED
|
@@ -2,21 +2,12 @@ import { appendFile, readFile, mkdir } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { keccak256, toHex } from "viem";
|
|
4
4
|
import { buildMerkleTree } from "./merkle-tree.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
allowed: entry.allowed,
|
|
12
|
-
reason: entry.reason ?? null,
|
|
13
|
-
amount: entry.amount,
|
|
14
|
-
counterparty: entry.counterparty,
|
|
15
|
-
txHash: entry.txHash ?? null,
|
|
16
|
-
});
|
|
17
|
-
return keccak256(toHex(canonical));
|
|
18
|
-
}
|
|
19
|
-
export class AuditLog {
|
|
5
|
+
import { QueryCommand, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
6
|
+
/** File-backed, append-only JSONL — the original (and still the
|
|
7
|
+
* default local/persistent-process) storage, extracted here as one
|
|
8
|
+
* implementation of AuditLogBackend rather than baked into AuditLog
|
|
9
|
+
* itself. */
|
|
10
|
+
export class LocalFileAuditLogBackend {
|
|
20
11
|
filePath;
|
|
21
12
|
constructor(filePath) {
|
|
22
13
|
this.filePath = filePath;
|
|
@@ -39,6 +30,112 @@ export class AuditLog {
|
|
|
39
30
|
throw err;
|
|
40
31
|
}
|
|
41
32
|
}
|
|
33
|
+
}
|
|
34
|
+
/** DynamoDB-backed — the distributed case LocalFileAuditLogBackend's
|
|
35
|
+
* doc comment flagged as not yet built: multiple concurrent
|
|
36
|
+
* processes appending without a shared filesystem to serialize
|
|
37
|
+
* writes through. Ordering (this file's whole reason for calling it
|
|
38
|
+
* out as a backend-specific concern, not something the AuditLog
|
|
39
|
+
* orchestration layer can paper over) comes from a DynamoDB atomic
|
|
40
|
+
* counter, not wall-clock timestamps — two concurrent appends always
|
|
41
|
+
* get two different, strictly increasing sequence numbers via a
|
|
42
|
+
* single conditionless `ADD` (DynamoDB serializes this itself; no
|
|
43
|
+
* compare-and-swap needed here, unlike the rate-limit backend, since
|
|
44
|
+
* an atomic increment is exactly what's needed and DynamoDB provides
|
|
45
|
+
* it natively). Entries are stored under a different partition
|
|
46
|
+
* (`<key>#entries`, sort key = that sequence number) than the counter
|
|
47
|
+
* itself (`<key>#seq`), so `readAll()`'s Query never has to filter
|
|
48
|
+
* the counter item out. Requires a table with a String partition key
|
|
49
|
+
* named `pk` and a Number sort key named `sortKey`. */
|
|
50
|
+
export class DynamoDbAuditLogBackend {
|
|
51
|
+
client;
|
|
52
|
+
tableName;
|
|
53
|
+
key;
|
|
54
|
+
constructor(client, tableName, key) {
|
|
55
|
+
this.client = client;
|
|
56
|
+
this.tableName = tableName;
|
|
57
|
+
this.key = key;
|
|
58
|
+
}
|
|
59
|
+
async nextSeq() {
|
|
60
|
+
const res = await this.client.send(new UpdateCommand({
|
|
61
|
+
TableName: this.tableName,
|
|
62
|
+
Key: { pk: `${this.key}#seq`, sortKey: 0 },
|
|
63
|
+
UpdateExpression: "ADD seq :one",
|
|
64
|
+
ExpressionAttributeValues: { ":one": 1 },
|
|
65
|
+
ReturnValues: "UPDATED_NEW",
|
|
66
|
+
}));
|
|
67
|
+
const seq = res.Attributes?.seq;
|
|
68
|
+
if (typeof seq !== "number")
|
|
69
|
+
throw new Error(`DynamoDbAuditLogBackend: counter update for "${this.key}" returned no numeric seq.`);
|
|
70
|
+
return seq;
|
|
71
|
+
}
|
|
72
|
+
async append(entry) {
|
|
73
|
+
const seq = await this.nextSeq();
|
|
74
|
+
// AuditEntry's optional fields (reason, txHash) arrive as
|
|
75
|
+
// `undefined`, not omitted, whenever they're unset — fine for
|
|
76
|
+
// LocalFileAuditLogBackend (JSON.stringify silently drops undefined
|
|
77
|
+
// keys), but DynamoDB's default marshaller rejects `undefined`
|
|
78
|
+
// outright rather than dropping it (found live-verifying this: the
|
|
79
|
+
// very first signRegisterIdentity through a real CLI run threw here,
|
|
80
|
+
// since a successful signature's `reason` is always undefined). The
|
|
81
|
+
// JSON round-trip restores exactly the same "absent, not null"
|
|
82
|
+
// behavior the file backend already has, rather than requiring every
|
|
83
|
+
// caller to remember to construct their DynamoDBDocumentClient with
|
|
84
|
+
// `marshallOptions: { removeUndefinedValues: true }`.
|
|
85
|
+
const cleanEntry = JSON.parse(JSON.stringify(entry));
|
|
86
|
+
await this.client.send(new PutCommand({
|
|
87
|
+
TableName: this.tableName,
|
|
88
|
+
Item: { pk: `${this.key}#entries`, sortKey: seq, entry: cleanEntry },
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
async readAll() {
|
|
92
|
+
const entries = [];
|
|
93
|
+
let exclusiveStartKey;
|
|
94
|
+
do {
|
|
95
|
+
const res = await this.client.send(new QueryCommand({
|
|
96
|
+
TableName: this.tableName,
|
|
97
|
+
KeyConditionExpression: "pk = :pk",
|
|
98
|
+
ExpressionAttributeValues: { ":pk": `${this.key}#entries` },
|
|
99
|
+
ScanIndexForward: true, // ascending sortKey — append order, same as the file backend's line order
|
|
100
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
101
|
+
}));
|
|
102
|
+
for (const item of res.Items ?? [])
|
|
103
|
+
entries.push(item.entry);
|
|
104
|
+
exclusiveStartKey = res.LastEvaluatedKey;
|
|
105
|
+
} while (exclusiveStartKey);
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function leafFor(entry) {
|
|
110
|
+
// Canonical JSON key order so the same logical entry always hashes to
|
|
111
|
+
// the same leaf regardless of object construction order.
|
|
112
|
+
const canonical = JSON.stringify({
|
|
113
|
+
timestamp: entry.timestamp,
|
|
114
|
+
operation: entry.operation,
|
|
115
|
+
allowed: entry.allowed,
|
|
116
|
+
reason: entry.reason ?? null,
|
|
117
|
+
amount: entry.amount,
|
|
118
|
+
counterparty: entry.counterparty,
|
|
119
|
+
txHash: entry.txHash ?? null,
|
|
120
|
+
});
|
|
121
|
+
return keccak256(toHex(canonical));
|
|
122
|
+
}
|
|
123
|
+
/** The orchestration layer — hash-chain leaf computation, Merkle
|
|
124
|
+
* commitment, and spend-tracking, all built on top of whatever
|
|
125
|
+
* AuditLogBackend is injected. Public API unchanged from before this
|
|
126
|
+
* file was split (append/readAll/spentSince/computeCommitment) — only
|
|
127
|
+
* the constructor changed, from a file path to a backend. */
|
|
128
|
+
export class AuditLog {
|
|
129
|
+
backend;
|
|
130
|
+
constructor(backend) {
|
|
131
|
+
this.backend = backend;
|
|
132
|
+
}
|
|
133
|
+
append(entry) {
|
|
134
|
+
return this.backend.append(entry);
|
|
135
|
+
}
|
|
136
|
+
readAll() {
|
|
137
|
+
return this.backend.readAll();
|
|
138
|
+
}
|
|
42
139
|
/** Sum of `amount` across allowed entries since `since` — the local,
|
|
43
140
|
* independent spend tracker policy.ts's SpendTracker interface
|
|
44
141
|
* expects (see that file's doc comment on why this doesn't rely on
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
4
|
+
import { KMSClient } from "@aws-sdk/client-kms";
|
|
5
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
6
|
+
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
7
|
+
import { defaultEvmSigningBackend, defaultSolanaSigningBackend } from "./signing-backend.js";
|
|
8
|
+
import { KmsSigningBackend, SolanaKmsSigningBackend } from "./kms-signing-backend.js";
|
|
9
|
+
import { EnvKekProvider } from "./keystore.js";
|
|
5
10
|
import { PolicyEngine } from "./policy.js";
|
|
6
|
-
import { AuditLog } from "./audit-log.js";
|
|
7
|
-
import {
|
|
11
|
+
import { AuditLog, LocalFileAuditLogBackend, DynamoDbAuditLogBackend } from "./audit-log.js";
|
|
12
|
+
import { LocalFileRateLimitBackend, DynamoDbRateLimitBackend } from "./rate-limit-backend.js";
|
|
13
|
+
import { Signer } from "./signer.js";
|
|
8
14
|
import { SignerIpcServer } from "./ipc-server.js";
|
|
9
15
|
// Minimal CLI so `gora8 deploy` (the Go CLI) can shell out to this
|
|
10
16
|
// package directly rather than needing its own reimplementation of key
|
|
@@ -16,6 +22,90 @@ import { SignerIpcServer } from "./ipc-server.js";
|
|
|
16
22
|
function dataDir(agentId) {
|
|
17
23
|
return join(homedir(), ".gora8", "signer", agentId);
|
|
18
24
|
}
|
|
25
|
+
// Per-chain backend selection, independent of each other (same
|
|
26
|
+
// reasoning as everywhere else in this codebase: EVM and Solana are two
|
|
27
|
+
// entirely separate secrets, so one chain can be KMS-backed while the
|
|
28
|
+
// other stays local without conflict). Presence of the KMS key id env
|
|
29
|
+
// var *is* the selection — no separate "backend kind" var to drift out
|
|
30
|
+
// of sync with it. `provisioning` is null for a KMS-backed chain: the
|
|
31
|
+
// key already exists in KMS, provisioned out of band through the KMS
|
|
32
|
+
// console/API (see LocalKeyProvisioning's doc comment in
|
|
33
|
+
// signing-backend.ts), so `init` has nothing to generate for it.
|
|
34
|
+
let sharedKmsClient;
|
|
35
|
+
function kmsClient() {
|
|
36
|
+
// process.env.AWS_REGION, if set, is picked up by the SDK's own
|
|
37
|
+
// default provider chain automatically — no gora8-specific region
|
|
38
|
+
// handling needed here.
|
|
39
|
+
if (!sharedKmsClient)
|
|
40
|
+
sharedKmsClient = new KMSClient({});
|
|
41
|
+
return sharedKmsClient;
|
|
42
|
+
}
|
|
43
|
+
let sharedDynamoDbClient;
|
|
44
|
+
function dynamoDbClient() {
|
|
45
|
+
if (!sharedDynamoDbClient)
|
|
46
|
+
sharedDynamoDbClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
|
47
|
+
return sharedDynamoDbClient;
|
|
48
|
+
}
|
|
49
|
+
// Same independent-per-concern selection as the signing backends above:
|
|
50
|
+
// presence of the table-name env var is the selection, no separate
|
|
51
|
+
// "backend kind" var. This is what actually closes the "persistent
|
|
52
|
+
// process assumed" gap a KMS-backed signing key alone doesn't close —
|
|
53
|
+
// KMS made the *key* stateless-deployment-safe; these make the rate
|
|
54
|
+
// limiter and audit log safe for the same topology (a Lambda/Cloud-Run
|
|
55
|
+
// invocation with no durable local disk, possibly multiple concurrent
|
|
56
|
+
// instances of the same agent).
|
|
57
|
+
function selectAuditLogBackend(agentId, dir) {
|
|
58
|
+
const tableName = process.env.GORA8_AUDIT_LOG_DYNAMODB_TABLE;
|
|
59
|
+
if (tableName) {
|
|
60
|
+
console.error(`Audit log backend: DynamoDB (table "${tableName}")`);
|
|
61
|
+
return new DynamoDbAuditLogBackend(dynamoDbClient(), tableName, agentId);
|
|
62
|
+
}
|
|
63
|
+
console.error("Audit log backend: local file");
|
|
64
|
+
return new LocalFileAuditLogBackend(join(dir, "audit.jsonl"));
|
|
65
|
+
}
|
|
66
|
+
function selectRateLimitBackend(agentId, dir) {
|
|
67
|
+
const tableName = process.env.GORA8_RATE_LIMIT_DYNAMODB_TABLE;
|
|
68
|
+
if (tableName) {
|
|
69
|
+
console.error(`Rate limit backend: DynamoDB (table "${tableName}")`);
|
|
70
|
+
return new DynamoDbRateLimitBackend(dynamoDbClient(), tableName, agentId);
|
|
71
|
+
}
|
|
72
|
+
console.error("Rate limit backend: local file");
|
|
73
|
+
return new LocalFileRateLimitBackend(join(dir, "rate-limit.json"));
|
|
74
|
+
}
|
|
75
|
+
async function selectEvmBackend(agentId, dir, kek) {
|
|
76
|
+
const keyId = process.env.GORA8_EVM_KMS_KEY_ID;
|
|
77
|
+
if (keyId) {
|
|
78
|
+
console.error(`EVM signing backend: AWS KMS (key "${keyId}")`);
|
|
79
|
+
return { backend: new KmsSigningBackend(kmsClient(), keyId), provisioning: null };
|
|
80
|
+
}
|
|
81
|
+
console.error("EVM signing backend: local (OS keychain / encrypted file)");
|
|
82
|
+
const local = await defaultEvmSigningBackend(agentId, join(dir, "key.enc.json"), kek);
|
|
83
|
+
return { backend: local, provisioning: local };
|
|
84
|
+
}
|
|
85
|
+
async function selectSolanaBackend(agentId, dir, kek) {
|
|
86
|
+
const keyId = process.env.GORA8_SOLANA_KMS_KEY_ID;
|
|
87
|
+
if (keyId) {
|
|
88
|
+
console.error(`Solana signing backend: AWS KMS (key "${keyId}")`);
|
|
89
|
+
return { backend: new SolanaKmsSigningBackend(kmsClient(), keyId), provisioning: null };
|
|
90
|
+
}
|
|
91
|
+
console.error("Solana signing backend: local (OS keychain / encrypted file)");
|
|
92
|
+
const local = await defaultSolanaSigningBackend(agentId, join(dir, "solana-key.enc.json"), kek);
|
|
93
|
+
return { backend: local, provisioning: local };
|
|
94
|
+
}
|
|
95
|
+
/** Shared by both chains' `init` handling: generate-and-store for a
|
|
96
|
+
* local backend (skipping if a key already exists), or just report the
|
|
97
|
+
* address for a KMS-backed one (`provisioning === null`) — there's
|
|
98
|
+
* nothing for `init` to generate against KMS. */
|
|
99
|
+
async function resolveOrGenerateAddress(label, agentId, backend, provisioning) {
|
|
100
|
+
if (!provisioning)
|
|
101
|
+
return backend.address();
|
|
102
|
+
const hasKey = await provisioning.hasKey();
|
|
103
|
+
if (hasKey) {
|
|
104
|
+
console.error(`A ${label} key already exists for agent "${agentId}". Not generating a new one.`);
|
|
105
|
+
return backend.address();
|
|
106
|
+
}
|
|
107
|
+
return provisioning.generateAndStore();
|
|
108
|
+
}
|
|
19
109
|
async function fetchMandate(agentId, apiBase) {
|
|
20
110
|
const res = await fetch(`${apiBase}/v1/agents/${agentId}/mandate`);
|
|
21
111
|
if (!res.ok)
|
|
@@ -31,26 +121,18 @@ async function main() {
|
|
|
31
121
|
}
|
|
32
122
|
const dir = dataDir(agentId);
|
|
33
123
|
const kek = new EnvKekProvider();
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const auditLog = new AuditLog(
|
|
124
|
+
const { backend: evmSigningBackend, provisioning: evmProvisioning } = await selectEvmBackend(agentId, dir, kek);
|
|
125
|
+
const { backend: solanaSigningBackend, provisioning: solanaProvisioning } = await selectSolanaBackend(agentId, dir, kek);
|
|
126
|
+
const auditLog = new AuditLog(selectAuditLogBackend(agentId, dir));
|
|
37
127
|
switch (command) {
|
|
38
128
|
case "init": {
|
|
39
|
-
// Both
|
|
129
|
+
// Both chains handled together, same as `gora8 deploy` provisions
|
|
40
130
|
// both an EVM and a Solana wallet for every agent today — two
|
|
41
|
-
// entirely independent secrets (different curves,
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const hasEvm = await keyStore.hasKey();
|
|
47
|
-
const evmAddress = hasEvm ? await keyStore.address() : await keyStore.generateAndStore();
|
|
48
|
-
if (hasEvm)
|
|
49
|
-
console.error(`An EVM key already exists for agent "${agentId}". Not generating a new one.`);
|
|
50
|
-
const hasSolana = await solanaKeyStore.hasKey();
|
|
51
|
-
const solanaAddress = hasSolana ? await solanaKeyStore.address() : await solanaKeyStore.generateAndStore();
|
|
52
|
-
if (hasSolana)
|
|
53
|
-
console.error(`A Solana key already exists for agent "${agentId}". Not generating a new one.`);
|
|
131
|
+
// entirely independent secrets (different curves, independently
|
|
132
|
+
// local-or-KMS per resolveOrGenerateAddress above), not one key
|
|
133
|
+
// used two ways.
|
|
134
|
+
const evmAddress = await resolveOrGenerateAddress("EVM", agentId, evmSigningBackend, evmProvisioning);
|
|
135
|
+
const solanaAddress = await resolveOrGenerateAddress("Solana", agentId, solanaSigningBackend, solanaProvisioning);
|
|
54
136
|
console.log(JSON.stringify({ evmAddress, solanaAddress }));
|
|
55
137
|
break;
|
|
56
138
|
}
|
|
@@ -72,11 +154,15 @@ async function main() {
|
|
|
72
154
|
const mandateAgentId = process.env.GORA8_AGENT_ID || agentId;
|
|
73
155
|
const policyEngine = new PolicyEngine({ fetchMandate: () => fetchMandate(mandateAgentId, apiBase) }, auditLog);
|
|
74
156
|
const signer = new Signer({
|
|
75
|
-
|
|
76
|
-
|
|
157
|
+
evmSigningBackend,
|
|
158
|
+
solanaSigningBackend,
|
|
77
159
|
policyEngine,
|
|
78
160
|
auditLog,
|
|
79
|
-
|
|
161
|
+
rateLimit: {
|
|
162
|
+
backend: selectRateLimitBackend(agentId, dir),
|
|
163
|
+
windowMs: 60_000,
|
|
164
|
+
maxCalls: 30, // sanity backstop
|
|
165
|
+
},
|
|
80
166
|
});
|
|
81
167
|
const socketPath = process.env.GORA8_SIGNER_SOCKET ?? join(dir, "signer.sock");
|
|
82
168
|
const server = new SignerIpcServer(signer, socketPath);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
export { OsKeychainSecretStore, EncryptedFileSecretStore, EnvKekProvider,
|
|
2
|
-
export type {
|
|
1
|
+
export { OsKeychainSecretStore, EncryptedFileSecretStore, EnvKekProvider, defaultSecretStore } from "./keystore.js";
|
|
2
|
+
export type { SecretStore, KekProvider } from "./keystore.js";
|
|
3
|
+
export { defaultEvmSigningBackend, defaultSolanaSigningBackend } from "./signing-backend.js";
|
|
4
|
+
export type { EvmSigningBackend, SolanaSigningBackend, LocalKeyProvisioning } from "./signing-backend.js";
|
|
5
|
+
export { KmsSigningBackend, defaultKmsSigningBackend, SolanaKmsSigningBackend, defaultSolanaKmsSigningBackend } from "./kms-signing-backend.js";
|
|
3
6
|
export { PolicyEngine } from "./policy.js";
|
|
4
7
|
export type { Mandate, SigningRequest, PolicyDecision, PolicyEngineOptions, SpendTracker } from "./policy.js";
|
|
5
|
-
export { AuditLog } from "./audit-log.js";
|
|
6
|
-
export type { AuditEntry } from "./audit-log.js";
|
|
7
|
-
export {
|
|
8
|
+
export { AuditLog, LocalFileAuditLogBackend, DynamoDbAuditLogBackend } from "./audit-log.js";
|
|
9
|
+
export type { AuditEntry, AuditLogBackend } from "./audit-log.js";
|
|
10
|
+
export { InMemoryRateLimitBackend, LocalFileRateLimitBackend, DynamoDbRateLimitBackend } from "./rate-limit-backend.js";
|
|
11
|
+
export type { RateLimitStateBackend } from "./rate-limit-backend.js";
|
|
12
|
+
export { Signer } from "./signer.js";
|
|
8
13
|
export type { SignerOptions } from "./signer.js";
|
|
9
14
|
export { SignerIpcServer } from "./ipc-server.js";
|
|
10
15
|
export { SignerIpcClient } from "./ipc-client.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
export { OsKeychainSecretStore, EncryptedFileSecretStore, EnvKekProvider,
|
|
1
|
+
export { OsKeychainSecretStore, EncryptedFileSecretStore, EnvKekProvider, defaultSecretStore } from "./keystore.js";
|
|
2
|
+
export { defaultEvmSigningBackend, defaultSolanaSigningBackend } from "./signing-backend.js";
|
|
3
|
+
export { KmsSigningBackend, defaultKmsSigningBackend, SolanaKmsSigningBackend, defaultSolanaKmsSigningBackend } from "./kms-signing-backend.js";
|
|
2
4
|
export { PolicyEngine } from "./policy.js";
|
|
3
|
-
export { AuditLog } from "./audit-log.js";
|
|
4
|
-
export {
|
|
5
|
+
export { AuditLog, LocalFileAuditLogBackend, DynamoDbAuditLogBackend } from "./audit-log.js";
|
|
6
|
+
export { InMemoryRateLimitBackend, LocalFileRateLimitBackend, DynamoDbRateLimitBackend } from "./rate-limit-backend.js";
|
|
7
|
+
export { Signer } from "./signer.js";
|
|
5
8
|
export { SignerIpcServer } from "./ipc-server.js";
|
|
6
9
|
export { SignerIpcClient } from "./ipc-client.js";
|
|
7
10
|
export { buildMerkleTree, getMerkleProof, verifyMerkleProof } from "./merkle-tree.js";
|
package/dist/ipc-client.d.ts
CHANGED
|
@@ -24,8 +24,8 @@ export declare class SignerIpcClient {
|
|
|
24
24
|
to: `0x${string}`;
|
|
25
25
|
value: bigint;
|
|
26
26
|
data: `0x${string}`;
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
executeNonce: bigint;
|
|
28
|
+
chainId: number;
|
|
29
29
|
}): Promise<{
|
|
30
30
|
signature: `0x${string}`;
|
|
31
31
|
}>;
|
|
@@ -35,9 +35,13 @@ export declare class SignerIpcClient {
|
|
|
35
35
|
}): Promise<{
|
|
36
36
|
signature: `0x${string}`;
|
|
37
37
|
}>;
|
|
38
|
+
signAgreementCommit(params: {
|
|
39
|
+
termsHash: `0x${string}`;
|
|
40
|
+
counterparty: `0x${string}`;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
signature: `0x${string}`;
|
|
43
|
+
}>;
|
|
38
44
|
signWithdrawal(params: {
|
|
39
|
-
to: `0x${string}`;
|
|
40
|
-
amount: number;
|
|
41
45
|
unsignedTx: {
|
|
42
46
|
to: `0x${string}`;
|
|
43
47
|
value: bigint;
|
package/dist/ipc-client.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createConnection } from "node:net";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
// Thin client for the agent's own application code — the only thing it
|
|
4
|
-
// imports to request a signature. It never has access to
|
|
5
|
-
// PolicyEngine, or
|
|
6
|
-
// named requests over the socket and get back a signature or
|
|
4
|
+
// imports to request a signature. It never has access to the signing
|
|
5
|
+
// backend, PolicyEngine, or any key material at all; it can only send
|
|
6
|
+
// one of the named requests over the socket and get back a signature or
|
|
7
|
+
// a refusal.
|
|
7
8
|
export class SignerIpcClient {
|
|
8
9
|
socketPath;
|
|
9
10
|
constructor(socketPath) {
|
|
@@ -47,6 +48,9 @@ export class SignerIpcClient {
|
|
|
47
48
|
signAgreementTerms(params) {
|
|
48
49
|
return this.call("signAgreementTerms", params);
|
|
49
50
|
}
|
|
51
|
+
signAgreementCommit(params) {
|
|
52
|
+
return this.call("signAgreementCommit", params);
|
|
53
|
+
}
|
|
50
54
|
signWithdrawal(params) {
|
|
51
55
|
return this.call("signWithdrawal", params);
|
|
52
56
|
}
|
package/dist/ipc-server.js
CHANGED
|
@@ -68,6 +68,8 @@ export class SignerIpcServer {
|
|
|
68
68
|
return this.signer.signMandateExecute(params);
|
|
69
69
|
case "signAgreementTerms":
|
|
70
70
|
return this.signer.signAgreementTerms(params);
|
|
71
|
+
case "signAgreementCommit":
|
|
72
|
+
return this.signer.signAgreementCommit(params);
|
|
71
73
|
case "signWithdrawal":
|
|
72
74
|
return this.signer.signWithdrawal(params);
|
|
73
75
|
case "solanaAddress":
|
package/dist/keystore.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Address, Hex } from "viem";
|
|
2
1
|
export interface SecretStore {
|
|
3
2
|
hasSecret(): Promise<boolean>;
|
|
4
3
|
/** Persists `secret` — throws if one is already stored; callers must
|
|
@@ -8,21 +7,6 @@ export interface SecretStore {
|
|
|
8
7
|
* scope; the raw secret string itself never does. */
|
|
9
8
|
withSecret<T>(fn: (secret: string) => Promise<T> | T): Promise<T>;
|
|
10
9
|
}
|
|
11
|
-
export interface KeyStore {
|
|
12
|
-
hasKey(): Promise<boolean>;
|
|
13
|
-
generateAndStore(): Promise<Address>;
|
|
14
|
-
withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
|
|
15
|
-
address(): Promise<Address>;
|
|
16
|
-
}
|
|
17
|
-
export interface SolanaKeyStore {
|
|
18
|
-
hasKey(): Promise<boolean>;
|
|
19
|
-
generateAndStore(): Promise<string>;
|
|
20
|
-
/** `fn` receives the raw 64-byte Solana secret key, matching
|
|
21
|
-
* `@solana/web3.js`'s `Keypair.fromSecretKey()` input — same scoped-
|
|
22
|
-
* access discipline as EVM's `withPrivateKey`. */
|
|
23
|
-
withSecretKey<T>(fn: (secretKey: Uint8Array) => Promise<T> | T): Promise<T>;
|
|
24
|
-
address(): Promise<string>;
|
|
25
|
-
}
|
|
26
10
|
export declare class OsKeychainSecretStore implements SecretStore {
|
|
27
11
|
private readonly service;
|
|
28
12
|
private readonly account;
|
|
@@ -32,6 +16,25 @@ export declare class OsKeychainSecretStore implements SecretStore {
|
|
|
32
16
|
constructor(account: string, service?: string);
|
|
33
17
|
private entry;
|
|
34
18
|
hasSecret(): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Same read `hasSecret()` does, but lets a real access failure
|
|
21
|
+
* propagate instead of collapsing it to `false` — `defaultSecretStore`
|
|
22
|
+
* below needs exactly this distinction and `hasSecret()` structurally
|
|
23
|
+
* can't provide it (its own contract is "is there a secret," which is
|
|
24
|
+
* correctly `false`-not-throw for "backend works, nothing stored yet").
|
|
25
|
+
*
|
|
26
|
+
* Found live, not hypothesized: a container with @napi-rs/keyring's
|
|
27
|
+
* native binding present (so `import()` succeeds) but no working OS
|
|
28
|
+
* Secret Service/session keyring underneath it — `getPassword()`
|
|
29
|
+
* itself throws "Couldn't access platform storage: PermissionDenied,"
|
|
30
|
+
* not "no such entry." `defaultSecretStore`'s probe used to call
|
|
31
|
+
* `hasSecret()`, which swallowed that exact error and returned `false`
|
|
32
|
+
* indistinguishably from "keychain works, key doesn't exist yet" —
|
|
33
|
+
* so it kept the keychain backend instead of falling back to
|
|
34
|
+
* `EncryptedFileSecretStore`, and every real sign operation failed
|
|
35
|
+
* downstream, well after the fallback decision was already locked in.
|
|
36
|
+
*/
|
|
37
|
+
probe(): Promise<boolean>;
|
|
35
38
|
store(secret: string): Promise<void>;
|
|
36
39
|
withSecret<T>(fn: (secret: string) => Promise<T> | T): Promise<T>;
|
|
37
40
|
}
|
|
@@ -63,10 +66,13 @@ export declare class EncryptedFileSecretStore implements SecretStore {
|
|
|
63
66
|
private writeEncrypted;
|
|
64
67
|
private readEncrypted;
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
69
|
+
/** Picks OS keychain by default, falling back to the encrypted file
|
|
70
|
+
* store only when the OS keychain genuinely isn't available (headless
|
|
71
|
+
* container with no Secret Service, etc.) — never silently prefers the
|
|
72
|
+
* weaker option. Shared by both EVM's and Solana's local signing
|
|
73
|
+
* backends (signing-backend.ts) — `account`/`fallbackFilePath` must
|
|
74
|
+
* already be kind-specific (see OsKeychainSecretStore's doc comment).
|
|
75
|
+
* Exported (not module-private) specifically so signing-backend.ts can
|
|
76
|
+
* build a SecretStore without this file needing to know anything about
|
|
77
|
+
* curves or signing — the dependency runs one direction only. */
|
|
78
|
+
export declare function defaultSecretStore(account: string, fallbackFilePath: string, kek: KekProvider): Promise<SecretStore>;
|