gora8-signer 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo Guillen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # gora8-signer
2
+
3
+ Local, self-custodied key signing for a gora8 agent — the private key is
4
+ generated and used wherever *your* agent runs, never inside gora8's own
5
+ infrastructure. gora8 never holds it, never has an authorization
6
+ credential over it, and has no path to reconstruct it. See
7
+ [`SELF_CUSTODY_ARCHITECTURE.md`](https://github.com/gora8/internal-repo/blob/main/SELF_CUSTODY_ARCHITECTURE.md)
8
+ in the gora8 monorepo for the full design and threat model this package
9
+ implements.
10
+
11
+ This is not `gora8-agent` (search/hire/dispute — the agent's economic
12
+ actions) or `gora8-cli` (deploying and configuring an agent). This
13
+ package does exactly one thing: hold a key safely, check a request
14
+ against the agent's own Mandate before signing anything, and produce a
15
+ signature — nothing else.
16
+
17
+ ## Why a separate process
18
+
19
+ The signer is meant to run isolated from your agent's own application
20
+ code — especially relevant for an LLM-driven agent, where a prompt
21
+ injection could otherwise trick your own business logic into doing
22
+ something harmful. Your agent's code talks to the signer only over a
23
+ local Unix domain socket, requesting one of four named operations —
24
+ never a generic "sign this" call, and never given direct access to the
25
+ key.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm install gora8-signer
31
+ ```
32
+
33
+ ## Quick start — standalone signer process
34
+
35
+ ```bash
36
+ # Generate a key (encrypted-file store shown here; OS keychain is used
37
+ # automatically instead when available — see Storage below)
38
+ export GORA8_SIGNER_KEK=$(openssl rand -hex 32) # dev only — see Storage
39
+ npx gora8-signer init my-agent-id
40
+
41
+ # Run the signer as its own process
42
+ npx gora8-signer start my-agent-id
43
+ ```
44
+
45
+ Your agent's own code then talks to it over the socket:
46
+
47
+ ```typescript
48
+ import { SignerIpcClient } from "gora8-signer";
49
+
50
+ const signer = new SignerIpcClient("~/.gora8/signer/my-agent-id/signer.sock");
51
+ const address = await signer.address();
52
+ ```
53
+
54
+ ## Storage
55
+
56
+ Defaults to the OS-native keychain (macOS Keychain, Windows Credential
57
+ Manager, Linux Secret Service) — the key never touches disk in
58
+ plaintext. Falls back to an encrypted file (AES-256-GCM, envelope
59
+ encryption) only when no OS keychain is available, e.g. a headless
60
+ container. The encryption key for that fallback (`GORA8_SIGNER_KEK`) is
61
+ expected to come from **your own** infrastructure — your own cloud KMS,
62
+ a mounted secret, whatever your deployment already uses — never gora8's.
63
+ The env-var-based provider shown above is for local development only;
64
+ implement `KekProvider` against your real KMS for production.
65
+
66
+ ## Policy enforcement
67
+
68
+ Before signing anything, the signer fetches your agent's current signed
69
+ Mandate (`GET /v1/agents/:id/mandate` — public, no auth required) and
70
+ checks the request against it: per-transaction limit, daily/monthly
71
+ caps (tracked locally from this signer's own audit log, not gora8's
72
+ count), allowed counterparties. This runs independently of, and in
73
+ addition to, gora8's on-chain `MandateEnforcer` — real defense-in-depth,
74
+ not a duplicate of the same trust.
75
+
76
+ ## Audit log
77
+
78
+ Every signing decision — allowed or refused — is appended to a local,
79
+ append-only log. Periodically (`gora8-signer commit <agent-id>`), a
80
+ Merkle root over that log can be computed and published anywhere you
81
+ choose — gora8 never sees the log's contents, only whatever root you
82
+ decide to share, using the identical commitment scheme gora8's own
83
+ reputation system uses (`buildMerkleTree`/`verifyMerkleProof`, exported
84
+ from this package too).
85
+
86
+ ## Deployment topologies
87
+
88
+ - **Persistent server**: run `gora8-signer start` as a sidecar next to
89
+ your agent's own process.
90
+ - **CLI/local development**: everything on one machine, OS keychain
91
+ storage, no extra setup.
92
+ - **Serverless/ephemeral**: harder — no persistent local storage exists
93
+ between invocations. Either run `gora8-signer` as your own small,
94
+ persistent service your functions call into over your own network, or
95
+ have your function call your own cloud KMS directly for each
96
+ signature. See `SELF_CUSTODY_ARCHITECTURE.md` for the full discussion —
97
+ this is a known, disclosed gap in the current version, not silently
98
+ glossed over.
99
+
100
+ ## What this package does not do
101
+
102
+ - Encode ERC-8004 or `MandateEnforcer` calldata — that's the caller's
103
+ job (it already exists in gora8's own client libraries); this package
104
+ only ever signs an already-built payload.
105
+ - Broadcast transactions — gora8's relay wallet still submits and pays
106
+ gas, exactly as it does today. Only the authorization to spend moves,
107
+ not gas sponsorship.
108
+ - Recover a lost key automatically — recovery (an opt-in Shamir split)
109
+ is a deliberate, separate feature, not built into this first version.
@@ -0,0 +1,33 @@
1
+ import { type Hex } from "viem";
2
+ import { type MerkleTree } from "./merkle-tree.js";
3
+ export interface AuditEntry {
4
+ timestamp: string;
5
+ operation: string;
6
+ allowed: boolean;
7
+ reason?: string;
8
+ amount: number | null;
9
+ counterparty: string | null;
10
+ txHash?: string;
11
+ }
12
+ export declare class AuditLog {
13
+ private readonly filePath;
14
+ constructor(filePath: string);
15
+ append(entry: AuditEntry): Promise<void>;
16
+ readAll(): Promise<AuditEntry[]>;
17
+ /** Sum of `amount` across allowed entries since `since` — the local,
18
+ * independent spend tracker policy.ts's SpendTracker interface
19
+ * expects (see that file's doc comment on why this doesn't rely on
20
+ * gora8's own server-side count). */
21
+ spentSince(since: Date): Promise<number>;
22
+ /** Builds a Merkle tree over every entry logged so far and returns the
23
+ * root — the caller (the signer's own periodic job, or an explicit
24
+ * CLI command) decides what to do with it: push to gora8's API as a
25
+ * commitment, print it for the owner to record independently, etc.
26
+ * This module deliberately doesn't push anywhere itself — committing
27
+ * is a policy decision (how often, to whom), not this module's job. */
28
+ computeCommitment(): Promise<{
29
+ root: Hex;
30
+ tree: MerkleTree;
31
+ entryCount: number;
32
+ }>;
33
+ }
@@ -0,0 +1,67 @@
1
+ import { appendFile, readFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { keccak256, toHex } from "viem";
4
+ import { buildMerkleTree } from "./merkle-tree.js";
5
+ function leafFor(entry) {
6
+ // Canonical JSON key order so the same logical entry always hashes to
7
+ // the same leaf regardless of object construction order.
8
+ const canonical = JSON.stringify({
9
+ timestamp: entry.timestamp,
10
+ operation: entry.operation,
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 {
20
+ filePath;
21
+ constructor(filePath) {
22
+ this.filePath = filePath;
23
+ }
24
+ async append(entry) {
25
+ await mkdir(dirname(this.filePath), { recursive: true });
26
+ await appendFile(this.filePath, JSON.stringify(entry) + "\n", { mode: 0o600 });
27
+ }
28
+ async readAll() {
29
+ try {
30
+ const raw = await readFile(this.filePath, "utf8");
31
+ return raw
32
+ .split("\n")
33
+ .filter((line) => line.length > 0)
34
+ .map((line) => JSON.parse(line));
35
+ }
36
+ catch (err) {
37
+ if (err.code === "ENOENT")
38
+ return [];
39
+ throw err;
40
+ }
41
+ }
42
+ /** Sum of `amount` across allowed entries since `since` — the local,
43
+ * independent spend tracker policy.ts's SpendTracker interface
44
+ * expects (see that file's doc comment on why this doesn't rely on
45
+ * gora8's own server-side count). */
46
+ async spentSince(since) {
47
+ const entries = await this.readAll();
48
+ return entries
49
+ .filter((e) => e.allowed && e.amount !== null && new Date(e.timestamp) >= since)
50
+ .reduce((sum, e) => sum + (e.amount ?? 0), 0);
51
+ }
52
+ /** Builds a Merkle tree over every entry logged so far and returns the
53
+ * root — the caller (the signer's own periodic job, or an explicit
54
+ * CLI command) decides what to do with it: push to gora8's API as a
55
+ * commitment, print it for the owner to record independently, etc.
56
+ * This module deliberately doesn't push anywhere itself — committing
57
+ * is a policy decision (how often, to whom), not this module's job. */
58
+ async computeCommitment() {
59
+ const entries = await this.readAll();
60
+ if (entries.length === 0) {
61
+ throw new Error("computeCommitment: no audit entries to commit yet.");
62
+ }
63
+ const leaves = entries.map(leafFor);
64
+ const tree = buildMerkleTree(leaves);
65
+ return { root: tree.root, tree, entryCount: entries.length };
66
+ }
67
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { defaultKeyStore, EnvKekProvider } from "./keystore.js";
5
+ import { PolicyEngine } from "./policy.js";
6
+ import { AuditLog } from "./audit-log.js";
7
+ import { Signer, SlidingWindowRateLimiter } from "./signer.js";
8
+ import { SignerIpcServer } from "./ipc-server.js";
9
+ // Minimal CLI so `gora8 deploy` (the Go CLI) can shell out to this
10
+ // package directly rather than needing its own reimplementation of key
11
+ // generation/storage — matches the design's "the CLI generates the key
12
+ // locally" flow (SELF_CUSTODY_ARCHITECTURE.md). Kept intentionally small:
13
+ // this is the reference implementation an owner can also run standalone
14
+ // (e.g. as the persistent signer service described for serverless/
15
+ // ephemeral deployment topologies), not a full product CLI.
16
+ function dataDir(agentId) {
17
+ return join(homedir(), ".gora8", "signer", agentId);
18
+ }
19
+ async function fetchMandate(agentId, apiBase) {
20
+ const res = await fetch(`${apiBase}/v1/agents/${agentId}/mandate`);
21
+ if (!res.ok)
22
+ throw new Error(`Failed to fetch Mandate for ${agentId}: HTTP ${res.status}`);
23
+ return (await res.json());
24
+ }
25
+ async function main() {
26
+ const [, , command, agentId] = process.argv;
27
+ const apiBase = process.env.GORA8_API_BASE_URL ?? "https://api.gora8.com";
28
+ if (!command || !agentId) {
29
+ console.error("Usage: gora8-signer <init|start|commit> <agent-id>");
30
+ process.exit(1);
31
+ }
32
+ const dir = dataDir(agentId);
33
+ const keyStore = await defaultKeyStore(agentId, join(dir, "key.enc.json"), new EnvKekProvider());
34
+ const auditLog = new AuditLog(join(dir, "audit.jsonl"));
35
+ switch (command) {
36
+ case "init": {
37
+ if (await keyStore.hasKey()) {
38
+ console.error(`A key already exists for agent "${agentId}". Not generating a new one.`);
39
+ const address = await keyStore.address();
40
+ console.log(address);
41
+ process.exit(1);
42
+ }
43
+ const address = await keyStore.generateAndStore();
44
+ console.log(address);
45
+ break;
46
+ }
47
+ case "start": {
48
+ const policyEngine = new PolicyEngine({ fetchMandate: () => fetchMandate(agentId, apiBase) }, auditLog);
49
+ const signer = new Signer({
50
+ keyStore,
51
+ policyEngine,
52
+ auditLog,
53
+ rateLimiter: new SlidingWindowRateLimiter(30, 60_000), // 30 signatures/min sanity backstop
54
+ });
55
+ const socketPath = process.env.GORA8_SIGNER_SOCKET ?? join(dir, "signer.sock");
56
+ const server = new SignerIpcServer(signer, socketPath);
57
+ await server.start();
58
+ console.log(`gora8-signer listening on ${socketPath}`);
59
+ process.on("SIGINT", async () => {
60
+ await server.stop();
61
+ process.exit(0);
62
+ });
63
+ process.on("SIGTERM", async () => {
64
+ await server.stop();
65
+ process.exit(0);
66
+ });
67
+ break;
68
+ }
69
+ case "commit": {
70
+ const { root, entryCount } = await auditLog.computeCommitment();
71
+ console.log(JSON.stringify({ root, entryCount }));
72
+ break;
73
+ }
74
+ default:
75
+ console.error(`Unknown command: ${command}`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+ main().catch((err) => {
80
+ console.error(err instanceof Error ? err.message : err);
81
+ process.exit(1);
82
+ });
@@ -0,0 +1,12 @@
1
+ export { OsKeychainStore, EncryptedFileStore, EnvKekProvider, defaultKeyStore } from "./keystore.js";
2
+ export type { KeyStore, KekProvider } from "./keystore.js";
3
+ export { PolicyEngine } from "./policy.js";
4
+ 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 { Signer, SlidingWindowRateLimiter } from "./signer.js";
8
+ export type { SignerOptions } from "./signer.js";
9
+ export { SignerIpcServer } from "./ipc-server.js";
10
+ export { SignerIpcClient } from "./ipc-client.js";
11
+ export { buildMerkleTree, getMerkleProof, verifyMerkleProof } from "./merkle-tree.js";
12
+ export type { MerkleTree } from "./merkle-tree.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { OsKeychainStore, EncryptedFileStore, EnvKekProvider, defaultKeyStore } from "./keystore.js";
2
+ export { PolicyEngine } from "./policy.js";
3
+ export { AuditLog } from "./audit-log.js";
4
+ export { Signer, SlidingWindowRateLimiter } from "./signer.js";
5
+ export { SignerIpcServer } from "./ipc-server.js";
6
+ export { SignerIpcClient } from "./ipc-client.js";
7
+ export { buildMerkleTree, getMerkleProof, verifyMerkleProof } from "./merkle-tree.js";
@@ -0,0 +1,50 @@
1
+ export declare class SignerIpcClient {
2
+ private readonly socketPath;
3
+ constructor(socketPath: string);
4
+ private call;
5
+ address(): Promise<`0x${string}`>;
6
+ signRegisterIdentity(params: {
7
+ agentURI: string;
8
+ unsignedTx: {
9
+ to: `0x${string}`;
10
+ nonce: number;
11
+ data: `0x${string}`;
12
+ gas?: bigint;
13
+ };
14
+ chainId: number;
15
+ }): Promise<{
16
+ signedTransaction: `0x${string}`;
17
+ }>;
18
+ signEIP7702Authorization(contractAddress: `0x${string}`, chainId: number, nonce: number): Promise<{
19
+ r: `0x${string}`;
20
+ s: `0x${string}`;
21
+ yParity: number;
22
+ }>;
23
+ signMandateExecute(params: {
24
+ to: `0x${string}`;
25
+ value: bigint;
26
+ data: `0x${string}`;
27
+ digest: `0x${string}`;
28
+ amount: number;
29
+ }): Promise<{
30
+ signature: `0x${string}`;
31
+ }>;
32
+ signAgreementTerms(params: {
33
+ termsHash: `0x${string}`;
34
+ counterparty: `0x${string}`;
35
+ }): Promise<{
36
+ signature: `0x${string}`;
37
+ }>;
38
+ signWithdrawal(params: {
39
+ to: `0x${string}`;
40
+ amount: number;
41
+ unsignedTx: {
42
+ to: `0x${string}`;
43
+ value: bigint;
44
+ data: `0x${string}`;
45
+ };
46
+ chainId: number;
47
+ }): Promise<{
48
+ signedTransaction: `0x${string}`;
49
+ }>;
50
+ }
@@ -0,0 +1,53 @@
1
+ import { createConnection } from "node:net";
2
+ import { randomUUID } from "node:crypto";
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 KeyStore,
5
+ // PolicyEngine, or the raw key at all; it can only send one of the four
6
+ // named requests over the socket and get back a signature or a refusal.
7
+ export class SignerIpcClient {
8
+ socketPath;
9
+ constructor(socketPath) {
10
+ this.socketPath = socketPath;
11
+ }
12
+ call(method, params) {
13
+ return new Promise((resolve, reject) => {
14
+ const conn = createConnection(this.socketPath);
15
+ const id = randomUUID();
16
+ let buffer = "";
17
+ conn.on("connect", () => {
18
+ conn.write(JSON.stringify({ id, method, params }) + "\n");
19
+ });
20
+ conn.on("data", (chunk) => {
21
+ buffer += chunk.toString("utf8");
22
+ const newlineIndex = buffer.indexOf("\n");
23
+ if (newlineIndex === -1)
24
+ return;
25
+ const response = JSON.parse(buffer.slice(0, newlineIndex));
26
+ conn.end();
27
+ if (response.error)
28
+ reject(new Error(response.error));
29
+ else
30
+ resolve(response.result);
31
+ });
32
+ conn.on("error", reject);
33
+ });
34
+ }
35
+ address() {
36
+ return this.call("address");
37
+ }
38
+ signRegisterIdentity(params) {
39
+ return this.call("signRegisterIdentity", params);
40
+ }
41
+ signEIP7702Authorization(contractAddress, chainId, nonce) {
42
+ return this.call("signEIP7702Authorization", { contractAddress, chainId, nonce });
43
+ }
44
+ signMandateExecute(params) {
45
+ return this.call("signMandateExecute", params);
46
+ }
47
+ signAgreementTerms(params) {
48
+ return this.call("signAgreementTerms", params);
49
+ }
50
+ signWithdrawal(params) {
51
+ return this.call("signWithdrawal", params);
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ import type { Signer } from "./signer.js";
2
+ export declare class SignerIpcServer {
3
+ private readonly signer;
4
+ private readonly socketPath;
5
+ private server;
6
+ constructor(signer: Signer, socketPath: string);
7
+ start(): Promise<void>;
8
+ private handle;
9
+ private dispatch;
10
+ stop(): Promise<void>;
11
+ }
@@ -0,0 +1,81 @@
1
+ import { createServer } from "node:net";
2
+ import { unlink } from "node:fs/promises";
3
+ export class SignerIpcServer {
4
+ signer;
5
+ socketPath;
6
+ server = null;
7
+ constructor(signer, socketPath) {
8
+ this.signer = signer;
9
+ this.socketPath = socketPath;
10
+ }
11
+ async start() {
12
+ // Remove a stale socket file from an unclean previous shutdown —
13
+ // net.createServer refuses to bind over an existing path otherwise.
14
+ await unlink(this.socketPath).catch(() => { });
15
+ this.server = createServer((conn) => {
16
+ let buffer = "";
17
+ conn.on("data", (chunk) => {
18
+ buffer += chunk.toString("utf8");
19
+ let newlineIndex;
20
+ while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
21
+ const line = buffer.slice(0, newlineIndex);
22
+ buffer = buffer.slice(newlineIndex + 1);
23
+ if (line.length === 0)
24
+ continue;
25
+ this.handle(line, conn);
26
+ }
27
+ });
28
+ });
29
+ await new Promise((resolve, reject) => {
30
+ this.server.once("error", reject);
31
+ this.server.listen(this.socketPath, resolve);
32
+ });
33
+ // Owner-only permissions — matches the encrypted-file store's 0o600.
34
+ // Anything reachable by other local users defeats the isolation
35
+ // this socket exists for.
36
+ const { chmod } = await import("node:fs/promises");
37
+ await chmod(this.socketPath, 0o600).catch(() => { });
38
+ }
39
+ async handle(line, conn) {
40
+ let request;
41
+ try {
42
+ request = JSON.parse(line);
43
+ }
44
+ catch {
45
+ return; // malformed line — drop it, don't crash the connection over it
46
+ }
47
+ let response;
48
+ try {
49
+ const result = await this.dispatch(request.method, request.params);
50
+ response = { id: request.id, result };
51
+ }
52
+ catch (err) {
53
+ response = { id: request.id, error: err instanceof Error ? err.message : String(err) };
54
+ }
55
+ conn.write(JSON.stringify(response) + "\n");
56
+ }
57
+ async dispatch(method, params) {
58
+ switch (method) {
59
+ case "address":
60
+ return this.signer.address();
61
+ case "signRegisterIdentity":
62
+ return this.signer.signRegisterIdentity(params);
63
+ case "signEIP7702Authorization": {
64
+ const p = params;
65
+ return this.signer.signEIP7702Authorization(p.contractAddress, p.chainId, p.nonce);
66
+ }
67
+ case "signMandateExecute":
68
+ return this.signer.signMandateExecute(params);
69
+ case "signAgreementTerms":
70
+ return this.signer.signAgreementTerms(params);
71
+ case "signWithdrawal":
72
+ return this.signer.signWithdrawal(params);
73
+ default:
74
+ throw new Error(`Unknown method: ${method}`);
75
+ }
76
+ }
77
+ async stop() {
78
+ await new Promise((resolve) => this.server?.close(() => resolve()));
79
+ await unlink(this.socketPath).catch(() => { });
80
+ }
81
+ }
@@ -0,0 +1,60 @@
1
+ import type { Address, Hex } from "viem";
2
+ export interface KeyStore {
3
+ /** True once a key has been generated and stored — callers check this
4
+ * before generateAndStore() to avoid silently overwriting an existing
5
+ * key (which would orphan whatever address was already registered
6
+ * on-chain). */
7
+ hasKey(): Promise<boolean>;
8
+ /** Generates a fresh key, persists it, and returns only the derived
9
+ * address — never the key itself. Throws if a key already exists;
10
+ * callers must not call this to "reset" a store. */
11
+ generateAndStore(): Promise<Address>;
12
+ /** The only way to use the key. `fn`'s return value escapes this
13
+ * scope; the raw private key string itself never does. */
14
+ withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
15
+ address(): Promise<Address>;
16
+ }
17
+ export declare class OsKeychainStore implements KeyStore {
18
+ private readonly service;
19
+ private readonly account;
20
+ constructor(agentId: string);
21
+ private entry;
22
+ hasKey(): Promise<boolean>;
23
+ generateAndStore(): Promise<Address>;
24
+ withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
25
+ address(): Promise<Address>;
26
+ }
27
+ export interface KekProvider {
28
+ /** Returns a 32-byte key-encryption key. Called once per
29
+ * generateAndStore()/withPrivateKey() — implementations are expected
30
+ * to cache/memoize their own upstream fetch if it's expensive. */
31
+ getKek(): Promise<Buffer>;
32
+ }
33
+ /** Development/local-only KEK provider — reads a hex-encoded 32-byte key
34
+ * from an env var. NOT a substitute for a real KMS in production: this
35
+ * exists so the encrypted-file store is usable and testable without a
36
+ * cloud account, and so self-hosted deployments have something to point
37
+ * at before wiring up their own KMS-backed provider (see this file's
38
+ * doc comment on KekProvider — implement one against AWS KMS/GCP KMS/
39
+ * age and pass it to EncryptedFileStore instead, for anything real). */
40
+ export declare class EnvKekProvider implements KekProvider {
41
+ private readonly envVar;
42
+ constructor(envVar?: string);
43
+ getKek(): Promise<Buffer>;
44
+ }
45
+ export declare class EncryptedFileStore implements KeyStore {
46
+ private readonly filePath;
47
+ private readonly kek;
48
+ constructor(filePath: string, kek: KekProvider);
49
+ hasKey(): Promise<boolean>;
50
+ generateAndStore(): Promise<Address>;
51
+ withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
52
+ address(): Promise<Address>;
53
+ private writeEncrypted;
54
+ private readEncrypted;
55
+ }
56
+ /** Picks OS keychain by default, falling back to the encrypted file
57
+ * store only when the OS keychain genuinely isn't available (headless
58
+ * container with no Secret Service, etc.) — never silently prefers the
59
+ * weaker option. */
60
+ export declare function defaultKeyStore(agentId: string, fallbackFilePath: string, kek: KekProvider): Promise<KeyStore>;
@@ -0,0 +1,164 @@
1
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
2
+ import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ function deriveAddress(privateKey) {
6
+ return privateKeyToAccount(privateKey).address;
7
+ }
8
+ // ── OS-native keychain store (default) ─────────────────────────────────
9
+ //
10
+ // Backed by @napi-rs/keyring (NAPI-RS bindings over the well-established
11
+ // Rust `keyring-rs` crate) — macOS Keychain, Windows Credential Manager,
12
+ // Linux Secret Service (org.freedesktop.secrets). This is the default
13
+ // because it's the closest local equivalent to what a TEE-based vendor
14
+ // gives you: the OS, not this process's own memory or a plain file, is
15
+ // what actually guards the secret at rest.
16
+ export class OsKeychainStore {
17
+ service;
18
+ account;
19
+ constructor(agentId) {
20
+ this.service = "gora8-signer";
21
+ this.account = agentId;
22
+ }
23
+ async entry() {
24
+ const { Entry } = await import("@napi-rs/keyring");
25
+ return new Entry(this.service, this.account);
26
+ }
27
+ async hasKey() {
28
+ try {
29
+ const entry = await this.entry();
30
+ entry.getPassword();
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ async generateAndStore() {
38
+ if (await this.hasKey()) {
39
+ throw new Error(`A key already exists for agent "${this.account}" in the OS keychain. ` +
40
+ "Refusing to overwrite — generating a new key would orphan the address already registered on-chain.");
41
+ }
42
+ const privateKey = generatePrivateKey();
43
+ const entry = await this.entry();
44
+ entry.setPassword(privateKey);
45
+ return deriveAddress(privateKey);
46
+ }
47
+ async withPrivateKey(fn) {
48
+ const entry = await this.entry();
49
+ const privateKey = entry.getPassword();
50
+ return fn(privateKey);
51
+ }
52
+ async address() {
53
+ return this.withPrivateKey((pk) => deriveAddress(pk));
54
+ }
55
+ }
56
+ /** Development/local-only KEK provider — reads a hex-encoded 32-byte key
57
+ * from an env var. NOT a substitute for a real KMS in production: this
58
+ * exists so the encrypted-file store is usable and testable without a
59
+ * cloud account, and so self-hosted deployments have something to point
60
+ * at before wiring up their own KMS-backed provider (see this file's
61
+ * doc comment on KekProvider — implement one against AWS KMS/GCP KMS/
62
+ * age and pass it to EncryptedFileStore instead, for anything real). */
63
+ export class EnvKekProvider {
64
+ envVar;
65
+ constructor(envVar = "GORA8_SIGNER_KEK") {
66
+ this.envVar = envVar;
67
+ }
68
+ async getKek() {
69
+ const hex = process.env[this.envVar];
70
+ if (!hex) {
71
+ throw new Error(`${this.envVar} is not set. EncryptedFileStore needs a 32-byte hex-encoded key-encryption ` +
72
+ "key — generate one with `openssl rand -hex 32` for local/dev use, or implement a real " +
73
+ "KekProvider against your own KMS for production (see keystore.ts's doc comment).");
74
+ }
75
+ const buf = Buffer.from(hex, "hex");
76
+ if (buf.length !== 32) {
77
+ throw new Error(`${this.envVar} must decode to exactly 32 bytes (got ${buf.length}).`);
78
+ }
79
+ return buf;
80
+ }
81
+ }
82
+ export class EncryptedFileStore {
83
+ filePath;
84
+ kek;
85
+ constructor(filePath, kek) {
86
+ this.filePath = filePath;
87
+ this.kek = kek;
88
+ }
89
+ async hasKey() {
90
+ try {
91
+ await readFile(this.filePath);
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ async generateAndStore() {
99
+ if (await this.hasKey()) {
100
+ throw new Error(`A key already exists at ${this.filePath}. Refusing to overwrite — generating a new key ` +
101
+ "would orphan the address already registered on-chain.");
102
+ }
103
+ const privateKey = generatePrivateKey();
104
+ await this.writeEncrypted(privateKey);
105
+ return deriveAddress(privateKey);
106
+ }
107
+ async withPrivateKey(fn) {
108
+ const privateKey = await this.readEncrypted();
109
+ return fn(privateKey);
110
+ }
111
+ async address() {
112
+ return this.withPrivateKey((pk) => deriveAddress(pk));
113
+ }
114
+ async writeEncrypted(privateKey) {
115
+ const salt = randomBytes(16);
116
+ const kekMaterial = await this.kek.getKek();
117
+ // scrypt-derive a per-store encryption key from the raw KEK + a
118
+ // random salt, rather than using the KEK bytes directly as the AES
119
+ // key — so a KEK reused across multiple stores never reuses the
120
+ // exact same AES key material.
121
+ const derivedKey = scryptSync(kekMaterial, salt, 32);
122
+ const iv = randomBytes(12);
123
+ const cipher = createCipheriv("aes-256-gcm", derivedKey, iv);
124
+ const ciphertext = Buffer.concat([cipher.update(privateKey, "utf8"), cipher.final()]);
125
+ const authTag = cipher.getAuthTag();
126
+ const payload = {
127
+ version: 1,
128
+ iv: iv.toString("hex"),
129
+ authTag: authTag.toString("hex"),
130
+ ciphertext: ciphertext.toString("hex"),
131
+ salt: salt.toString("hex"),
132
+ };
133
+ await mkdir(dirname(this.filePath), { recursive: true });
134
+ await writeFile(this.filePath, JSON.stringify(payload), { mode: 0o600 });
135
+ }
136
+ async readEncrypted() {
137
+ const raw = await readFile(this.filePath, "utf8");
138
+ const payload = JSON.parse(raw);
139
+ const kekMaterial = await this.kek.getKek();
140
+ const derivedKey = scryptSync(kekMaterial, Buffer.from(payload.salt, "hex"), 32);
141
+ const decipher = createDecipheriv("aes-256-gcm", derivedKey, Buffer.from(payload.iv, "hex"));
142
+ decipher.setAuthTag(Buffer.from(payload.authTag, "hex"));
143
+ const plaintext = Buffer.concat([decipher.update(Buffer.from(payload.ciphertext, "hex")), decipher.final()]);
144
+ return plaintext.toString("utf8");
145
+ }
146
+ }
147
+ /** Picks OS keychain by default, falling back to the encrypted file
148
+ * store only when the OS keychain genuinely isn't available (headless
149
+ * container with no Secret Service, etc.) — never silently prefers the
150
+ * weaker option. */
151
+ export async function defaultKeyStore(agentId, fallbackFilePath, kek) {
152
+ try {
153
+ await import("@napi-rs/keyring");
154
+ const store = new OsKeychainStore(agentId);
155
+ // Probe for a usable keychain backend (this throws on headless Linux
156
+ // hosts with no Secret Service daemon running) without touching any
157
+ // real credential.
158
+ await store.hasKey();
159
+ return store;
160
+ }
161
+ catch {
162
+ return new EncryptedFileStore(fallbackFilePath, kek);
163
+ }
164
+ }
@@ -0,0 +1,11 @@
1
+ import { type Hex } from "viem";
2
+ export interface MerkleTree {
3
+ root: Hex;
4
+ layers: Hex[][];
5
+ }
6
+ /** A single, odd leaf at any level is carried up unchanged (not
7
+ * duplicated/hashed with itself) — avoids the well-known second-preimage
8
+ * issue some naive "duplicate the last leaf" implementations have. */
9
+ export declare function buildMerkleTree(leaves: Hex[]): MerkleTree;
10
+ export declare function getMerkleProof(tree: MerkleTree, leafIndex: number): Hex[];
11
+ export declare function verifyMerkleProof(leaf: Hex, proof: Hex[], root: Hex): boolean;
@@ -0,0 +1,55 @@
1
+ import { keccak256, encodePacked } from "viem";
2
+ // Identical convention to api/src/lib/merkle-tree.ts (gora8's own
3
+ // reputation-ledger CommitmentRoot) — a standard sorted-pair keccak256
4
+ // merkle tree, the same one OpenZeppelin's MerkleProof.sol uses.
5
+ // Deliberately the same code, not a bespoke reimplementation: this
6
+ // signer's audit log commitment and gora8's reputation commitment should
7
+ // be verifiable the same way, by the same tooling, not two subtly
8
+ // different "merkle-ish" schemes an auditor has to learn separately.
9
+ function hashPair(a, b) {
10
+ const [lo, hi] = a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a];
11
+ return keccak256(encodePacked(["bytes32", "bytes32"], [lo, hi]));
12
+ }
13
+ /** A single, odd leaf at any level is carried up unchanged (not
14
+ * duplicated/hashed with itself) — avoids the well-known second-preimage
15
+ * issue some naive "duplicate the last leaf" implementations have. */
16
+ export function buildMerkleTree(leaves) {
17
+ if (leaves.length === 0)
18
+ throw new Error("buildMerkleTree: at least one leaf is required.");
19
+ const layers = [leaves];
20
+ let current = leaves;
21
+ while (current.length > 1) {
22
+ const next = [];
23
+ for (let i = 0; i < current.length; i += 2) {
24
+ if (i + 1 < current.length) {
25
+ next.push(hashPair(current[i], current[i + 1]));
26
+ }
27
+ else {
28
+ next.push(current[i]);
29
+ }
30
+ }
31
+ layers.push(next);
32
+ current = next;
33
+ }
34
+ return { root: current[0], layers };
35
+ }
36
+ export function getMerkleProof(tree, leafIndex) {
37
+ const proof = [];
38
+ let index = leafIndex;
39
+ for (let level = 0; level < tree.layers.length - 1; level++) {
40
+ const layer = tree.layers[level];
41
+ const isRightNode = index % 2 === 1;
42
+ const siblingIndex = isRightNode ? index - 1 : index + 1;
43
+ if (siblingIndex < layer.length)
44
+ proof.push(layer[siblingIndex]);
45
+ index = Math.floor(index / 2);
46
+ }
47
+ return proof;
48
+ }
49
+ export function verifyMerkleProof(leaf, proof, root) {
50
+ let computed = leaf;
51
+ for (const sibling of proof) {
52
+ computed = hashPair(computed, sibling);
53
+ }
54
+ return computed.toLowerCase() === root.toLowerCase();
55
+ }
@@ -0,0 +1,79 @@
1
+ import type { Address } from "viem";
2
+ export interface Mandate {
3
+ protocol: string;
4
+ mandateId: string;
5
+ status: "active" | "revoked";
6
+ spending: {
7
+ perTransactionLimit: number | null;
8
+ dailyCap: number | null;
9
+ monthlyCap: number | null;
10
+ currency: string;
11
+ };
12
+ counterpartyRequirements: {
13
+ allowedCounterparties: string[];
14
+ };
15
+ validUntil: string;
16
+ }
17
+ export interface SigningRequest {
18
+ /** Decimal amount in the Mandate's currency (USDC) — null for
19
+ * operations that don't move value (identity registration, the
20
+ * EIP-7702 delegation itself). */
21
+ amount: number | null;
22
+ counterparty: Address | null;
23
+ /** Contract + 4-byte selector, for a MandateEnforcer.execute()-shaped
24
+ * call — checked against an allowlist if one is configured (see
25
+ * PolicyEngine's constructor). Mandate.spending/counterpartyRequirements
26
+ * don't carry this today; it's an additional, signer-local
27
+ * restriction, opt-in per deployment. */
28
+ contract: Address | null;
29
+ selector: `0x${string}` | null;
30
+ }
31
+ export interface PolicyDecision {
32
+ allowed: boolean;
33
+ reason?: string;
34
+ }
35
+ export interface PolicyEngineOptions {
36
+ /** How the engine fetches the current Mandate — default implementation
37
+ * hits gora8's public, unauthenticated endpoint; tests/self-hosted
38
+ * setups can substitute anything. */
39
+ fetchMandate: () => Promise<Mandate>;
40
+ /** How often the cached Mandate is allowed to go stale before a fresh
41
+ * fetch is forced (ms). Defaults to 60s — short enough that a
42
+ * just-revoked or just-tightened policy takes effect quickly, long
43
+ * enough that a burst of signing requests doesn't hit gora8's API
44
+ * once per signature. */
45
+ cacheTtlMs?: number;
46
+ /** Optional allowlist of (contract, selector) pairs — if set, any
47
+ * signing request naming a contract/selector outside this list is
48
+ * refused regardless of what the Mandate itself says (the Mandate
49
+ * doesn't model this dimension; this is a signer-local addition for
50
+ * deployments that want it). Unset means "no restriction beyond the
51
+ * Mandate's own fields." */
52
+ allowedTargets?: Array<{
53
+ contract: Address;
54
+ selector: `0x${string}`;
55
+ }>;
56
+ }
57
+ /** The local, independent counterpart to daily/monthly caps —
58
+ * gora8's own server tracks cumulative spend against its database
59
+ * (see policy-enforcement.ts's sumHiredSince()); this signer keeps its
60
+ * OWN running tally from what it has actually signed, so daily/monthly
61
+ * enforcement doesn't depend on trusting gora8's count either. Backed
62
+ * by the same audit log this signer already writes (see audit-log.ts) —
63
+ * not a second, separately-maintained ledger. */
64
+ export interface SpendTracker {
65
+ spentSince(date: Date): Promise<number>;
66
+ }
67
+ export declare class PolicyEngine {
68
+ private readonly options;
69
+ private readonly spendTracker;
70
+ private cachedMandate;
71
+ private cachedAt;
72
+ constructor(options: PolicyEngineOptions, spendTracker: SpendTracker);
73
+ private mandate;
74
+ /** Forces the next check to re-fetch — call this when gora8 pushes a
75
+ * policy-edit notification, so a tightened (or revoked) Mandate takes
76
+ * effect immediately rather than waiting out the TTL. */
77
+ invalidateCache(): void;
78
+ evaluate(request: SigningRequest): Promise<PolicyDecision>;
79
+ }
package/dist/policy.js ADDED
@@ -0,0 +1,71 @@
1
+ export class PolicyEngine {
2
+ options;
3
+ spendTracker;
4
+ cachedMandate = null;
5
+ cachedAt = 0;
6
+ constructor(options, spendTracker) {
7
+ this.options = options;
8
+ this.spendTracker = spendTracker;
9
+ }
10
+ async mandate() {
11
+ const ttl = this.options.cacheTtlMs ?? 60_000;
12
+ if (this.cachedMandate && Date.now() - this.cachedAt < ttl)
13
+ return this.cachedMandate;
14
+ const fresh = await this.options.fetchMandate();
15
+ this.cachedMandate = fresh;
16
+ this.cachedAt = Date.now();
17
+ return fresh;
18
+ }
19
+ /** Forces the next check to re-fetch — call this when gora8 pushes a
20
+ * policy-edit notification, so a tightened (or revoked) Mandate takes
21
+ * effect immediately rather than waiting out the TTL. */
22
+ invalidateCache() {
23
+ this.cachedMandate = null;
24
+ }
25
+ async evaluate(request) {
26
+ const mandate = await this.mandate();
27
+ if (mandate.status !== "active") {
28
+ return { allowed: false, reason: `Mandate ${mandate.mandateId} is not active (status: ${mandate.status}).` };
29
+ }
30
+ if (new Date(mandate.validUntil).getTime() < Date.now()) {
31
+ return { allowed: false, reason: `Mandate ${mandate.mandateId} expired at ${mandate.validUntil}.` };
32
+ }
33
+ if (request.amount !== null) {
34
+ const { perTransactionLimit, dailyCap, monthlyCap } = mandate.spending;
35
+ if (perTransactionLimit !== null && request.amount > perTransactionLimit) {
36
+ return { allowed: false, reason: `Amount ${request.amount} exceeds per-transaction limit ${perTransactionLimit}.` };
37
+ }
38
+ if (dailyCap !== null) {
39
+ const since = new Date();
40
+ since.setHours(0, 0, 0, 0);
41
+ const spentToday = await this.spendTracker.spentSince(since);
42
+ if (spentToday + request.amount > dailyCap) {
43
+ return { allowed: false, reason: `Would exceed daily cap ${dailyCap} (already spent ${spentToday} today).` };
44
+ }
45
+ }
46
+ if (monthlyCap !== null) {
47
+ const since = new Date();
48
+ since.setDate(1);
49
+ since.setHours(0, 0, 0, 0);
50
+ const spentThisMonth = await this.spendTracker.spentSince(since);
51
+ if (spentThisMonth + request.amount > monthlyCap) {
52
+ return { allowed: false, reason: `Would exceed monthly cap ${monthlyCap} (already spent ${spentThisMonth} this month).` };
53
+ }
54
+ }
55
+ }
56
+ const allowedCounterparties = mandate.counterpartyRequirements.allowedCounterparties;
57
+ if (request.counterparty && allowedCounterparties.length > 0) {
58
+ const match = allowedCounterparties.some((c) => c.toLowerCase() === request.counterparty.toLowerCase());
59
+ if (!match) {
60
+ return { allowed: false, reason: `Counterparty ${request.counterparty} is not on the Mandate's allowed-counterparty list.` };
61
+ }
62
+ }
63
+ if (this.options.allowedTargets && request.contract && request.selector) {
64
+ const match = this.options.allowedTargets.some((t) => t.contract.toLowerCase() === request.contract.toLowerCase() && t.selector.toLowerCase() === request.selector.toLowerCase());
65
+ if (!match) {
66
+ return { allowed: false, reason: `${request.contract}.${request.selector} is not on the configured allowed-targets list.` };
67
+ }
68
+ }
69
+ return { allowed: true };
70
+ }
71
+ }
@@ -0,0 +1,120 @@
1
+ import { type Address, type Hex } from "viem";
2
+ import type { KeyStore } from "./keystore.js";
3
+ import type { PolicyEngine } from "./policy.js";
4
+ import { AuditLog } from "./audit-log.js";
5
+ interface RateLimiter {
6
+ /** Throws if calling now would exceed the configured rate — a sanity
7
+ * backstop independent of Mandate spend limits (catches "signing too
8
+ * often," e.g. a runaway loop, not "spending too much"). */
9
+ checkAndRecord(): void;
10
+ }
11
+ /** Simple sliding-window limiter — no external dependency needed for
12
+ * something this small. */
13
+ export declare class SlidingWindowRateLimiter implements RateLimiter {
14
+ private readonly maxCalls;
15
+ private readonly windowMs;
16
+ private timestamps;
17
+ constructor(maxCalls: number, windowMs: number);
18
+ checkAndRecord(): void;
19
+ }
20
+ export interface SignerOptions {
21
+ keyStore: KeyStore;
22
+ policyEngine: PolicyEngine;
23
+ auditLog: AuditLog;
24
+ rateLimiter?: RateLimiter;
25
+ }
26
+ export declare class Signer {
27
+ private readonly options;
28
+ constructor(options: SignerOptions);
29
+ address(): Promise<Address>;
30
+ private logAndEnforce;
31
+ /** One-time: signs the register() call that mints this agent's
32
+ * ERC-8004 identity, owned by this signer's own address. Takes an
33
+ * already-encoded, already-nonced unsigned transaction — this package
34
+ * deliberately doesn't know ERC-8004's ABI or fetch chain state (nonce,
35
+ * gas price) itself, both to avoid duplicating that knowledge (it
36
+ * already exists in erc8004-client.ts's server-side counterpart) and
37
+ * to keep this signer's own network surface at zero: it never makes
38
+ * an RPC call, only signs exactly what it's handed. `agentURI` is
39
+ * accepted for the audit log entry, not used to build the call. */
40
+ signRegisterIdentity(params: {
41
+ agentURI: string;
42
+ unsignedTx: {
43
+ to: Address;
44
+ nonce: number;
45
+ data: Hex;
46
+ gas?: bigint;
47
+ };
48
+ chainId: number;
49
+ }): Promise<{
50
+ signedTransaction: Hex;
51
+ }>;
52
+ /** Signs an EIP-7702 authorization delegating this wallet's code to
53
+ * `contractAddress` (gora8's own MandateEnforcer, or any other
54
+ * contract the owner explicitly configures) — the exact mechanism
55
+ * mandate-enforcer-client.ts's delegateAndSetMandate() already uses
56
+ * server-side today; this is the same operation, signed locally. */
57
+ signEIP7702Authorization(contractAddress: Address, chainId: number, nonce: number): Promise<{
58
+ r: Hex;
59
+ s: Hex;
60
+ yParity: number;
61
+ }>;
62
+ /** The actual spend-constrained call — MandateEnforcer.execute()'s
63
+ * signature, or any (to, value, data) tuple the caller has already
64
+ * determined needs signing. `amount` is the decimal USDC value being
65
+ * moved (for Mandate evaluation) even though `value`/`data` carry the
66
+ * real on-chain encoding — the two are related but not always
67
+ * identical (a USDC transferWithAuthorization's "value" isn't the
68
+ * transaction's native-token value field), so both are passed
69
+ * explicitly rather than this method trying to infer one from the
70
+ * other. */
71
+ signMandateExecute(params: {
72
+ to: Address;
73
+ value: bigint;
74
+ data: Hex;
75
+ /** MandateEnforcer.hashExecute(to, value, data, executeNonce)'s
76
+ * result — computed by the caller (it needs MandateEnforcer's own
77
+ * ABI, which this package doesn't duplicate; see this method's own
78
+ * doc comment and signRegisterIdentity's for why). */
79
+ digest: Hex;
80
+ amount: number;
81
+ }): Promise<{
82
+ signature: Hex;
83
+ }>;
84
+ /** Co-signs an Agreement's terms hash (api/src/lib/agreement.ts) — the
85
+ * buyer-side commitment before a hire is forwarded, or the
86
+ * provider-side counter-signature once delivery succeeds. Neither
87
+ * moves funds itself (the actual spend is a separate
88
+ * signMandateExecute call, evaluated and rate-limited on its own) —
89
+ * `amount` is deliberately omitted from the policy request so this
90
+ * doesn't double-count against the Mandate's daily/monthly caps
91
+ * alongside the real payment for the same hire; the counterparty
92
+ * check still applies, so gora8 can't get this signer to attest to
93
+ * terms with a counterparty outside the Mandate's allowlist. Like
94
+ * signMandateExecute, signs the exact digest the caller already
95
+ * computed (agreement.ts's hashTerms()) — this package doesn't
96
+ * duplicate Agreement's own hashing logic, only signs what it's
97
+ * handed. */
98
+ signAgreementTerms(params: {
99
+ termsHash: Hex;
100
+ counterparty: Address;
101
+ }): Promise<{
102
+ signature: Hex;
103
+ }>;
104
+ /** Owner-initiated funds-out — same policy checks apply (a withdrawal
105
+ * is still constrained by the Mandate's spend limits and, if
106
+ * configured, allowed-counterparty list). */
107
+ signWithdrawal(params: {
108
+ to: Address;
109
+ amount: number;
110
+ unsignedTx: {
111
+ to: Address;
112
+ value: bigint;
113
+ data: Hex;
114
+ };
115
+ chainId: number;
116
+ }): Promise<{
117
+ signedTransaction: Hex;
118
+ }>;
119
+ }
120
+ export {};
package/dist/signer.js ADDED
@@ -0,0 +1,172 @@
1
+ import { createWalletClient, http, defineChain } from "viem";
2
+ /** Simple sliding-window limiter — no external dependency needed for
3
+ * something this small. */
4
+ export class SlidingWindowRateLimiter {
5
+ maxCalls;
6
+ windowMs;
7
+ timestamps = [];
8
+ constructor(maxCalls, windowMs) {
9
+ this.maxCalls = maxCalls;
10
+ this.windowMs = windowMs;
11
+ }
12
+ checkAndRecord() {
13
+ const now = Date.now();
14
+ this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);
15
+ if (this.timestamps.length >= this.maxCalls) {
16
+ throw new Error(`Rate limit exceeded: more than ${this.maxCalls} signing requests in ${this.windowMs}ms.`);
17
+ }
18
+ this.timestamps.push(now);
19
+ }
20
+ }
21
+ // A syntactically-valid but never-actually-called placeholder — viem's
22
+ // http() transport validates its URL eagerly at client-construction
23
+ // time even though every operation this signer performs (signAuthorization,
24
+ // signTransaction, raw digest signing) is pure offline computation
25
+ // against the account and never issues a real RPC call. No real network
26
+ // access is needed or granted here; broadcasting is gora8's relay
27
+ // wallet's job, not this signer's — see this file's own doc comment.
28
+ const OFFLINE_PLACEHOLDER_RPC_URL = "http://127.0.0.1:0";
29
+ function genericChain(chainId) {
30
+ return defineChain({
31
+ id: chainId,
32
+ name: `chain-${chainId}`,
33
+ nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
34
+ rpcUrls: { default: { http: [OFFLINE_PLACEHOLDER_RPC_URL] } },
35
+ });
36
+ }
37
+ export class Signer {
38
+ options;
39
+ constructor(options) {
40
+ this.options = options;
41
+ }
42
+ async address() {
43
+ return this.options.keyStore.address();
44
+ }
45
+ async logAndEnforce(operation, request) {
46
+ this.options.rateLimiter?.checkAndRecord();
47
+ const decision = await this.options.policyEngine.evaluate(request);
48
+ const entry = {
49
+ timestamp: new Date().toISOString(),
50
+ operation,
51
+ allowed: decision.allowed,
52
+ reason: decision.reason,
53
+ amount: request.amount,
54
+ counterparty: request.counterparty,
55
+ };
56
+ await this.options.auditLog.append(entry);
57
+ if (!decision.allowed) {
58
+ throw new Error(`Refused: ${operation} — ${decision.reason}`);
59
+ }
60
+ }
61
+ /** One-time: signs the register() call that mints this agent's
62
+ * ERC-8004 identity, owned by this signer's own address. Takes an
63
+ * already-encoded, already-nonced unsigned transaction — this package
64
+ * deliberately doesn't know ERC-8004's ABI or fetch chain state (nonce,
65
+ * gas price) itself, both to avoid duplicating that knowledge (it
66
+ * already exists in erc8004-client.ts's server-side counterpart) and
67
+ * to keep this signer's own network surface at zero: it never makes
68
+ * an RPC call, only signs exactly what it's handed. `agentURI` is
69
+ * accepted for the audit log entry, not used to build the call. */
70
+ async signRegisterIdentity(params) {
71
+ await this.logAndEnforce("signRegisterIdentity", { amount: null, counterparty: null, contract: null, selector: null });
72
+ return this.options.keyStore.withPrivateKey(async (privateKey) => {
73
+ const { privateKeyToAccount } = await import("viem/accounts");
74
+ const account = privateKeyToAccount(privateKey);
75
+ const walletClient = createWalletClient({ account, chain: genericChain(params.chainId), transport: http(OFFLINE_PLACEHOLDER_RPC_URL) });
76
+ const signedTransaction = await walletClient.signTransaction({
77
+ account,
78
+ chain: genericChain(params.chainId),
79
+ to: params.unsignedTx.to,
80
+ nonce: params.unsignedTx.nonce,
81
+ data: params.unsignedTx.data,
82
+ gas: params.unsignedTx.gas,
83
+ });
84
+ return { signedTransaction };
85
+ });
86
+ }
87
+ /** Signs an EIP-7702 authorization delegating this wallet's code to
88
+ * `contractAddress` (gora8's own MandateEnforcer, or any other
89
+ * contract the owner explicitly configures) — the exact mechanism
90
+ * mandate-enforcer-client.ts's delegateAndSetMandate() already uses
91
+ * server-side today; this is the same operation, signed locally. */
92
+ async signEIP7702Authorization(contractAddress, chainId, nonce) {
93
+ await this.logAndEnforce("signEIP7702Authorization", { amount: null, counterparty: null, contract: contractAddress, selector: null });
94
+ return this.options.keyStore.withPrivateKey(async (privateKey) => {
95
+ const { privateKeyToAccount } = await import("viem/accounts");
96
+ const account = privateKeyToAccount(privateKey);
97
+ const walletClient = createWalletClient({ account, chain: genericChain(chainId), transport: http(OFFLINE_PLACEHOLDER_RPC_URL) });
98
+ const authorization = await walletClient.signAuthorization({ contractAddress, chainId, nonce });
99
+ return { r: authorization.r, s: authorization.s, yParity: authorization.yParity ?? 0 };
100
+ });
101
+ }
102
+ /** The actual spend-constrained call — MandateEnforcer.execute()'s
103
+ * signature, or any (to, value, data) tuple the caller has already
104
+ * determined needs signing. `amount` is the decimal USDC value being
105
+ * moved (for Mandate evaluation) even though `value`/`data` carry the
106
+ * real on-chain encoding — the two are related but not always
107
+ * identical (a USDC transferWithAuthorization's "value" isn't the
108
+ * transaction's native-token value field), so both are passed
109
+ * explicitly rather than this method trying to infer one from the
110
+ * other. */
111
+ async signMandateExecute(params) {
112
+ const selector = params.data.slice(0, 10);
113
+ await this.logAndEnforce("signMandateExecute", {
114
+ amount: params.amount,
115
+ counterparty: params.to,
116
+ contract: params.to,
117
+ selector,
118
+ });
119
+ return this.options.keyStore.withPrivateKey(async (privateKey) => {
120
+ const { privateKeyToAccount } = await import("viem/accounts");
121
+ const account = privateKeyToAccount(privateKey);
122
+ // Raw signature over the already-fully-encoded digest — not
123
+ // signMessage/signTypedData, matching mandate-enforcer-client.ts's
124
+ // existing executeViaMandate() convention exactly (this is the
125
+ // same operation, just signed locally instead of server-side).
126
+ const signature = await account.sign({ hash: params.digest });
127
+ return { signature };
128
+ });
129
+ }
130
+ /** Co-signs an Agreement's terms hash (api/src/lib/agreement.ts) — the
131
+ * buyer-side commitment before a hire is forwarded, or the
132
+ * provider-side counter-signature once delivery succeeds. Neither
133
+ * moves funds itself (the actual spend is a separate
134
+ * signMandateExecute call, evaluated and rate-limited on its own) —
135
+ * `amount` is deliberately omitted from the policy request so this
136
+ * doesn't double-count against the Mandate's daily/monthly caps
137
+ * alongside the real payment for the same hire; the counterparty
138
+ * check still applies, so gora8 can't get this signer to attest to
139
+ * terms with a counterparty outside the Mandate's allowlist. Like
140
+ * signMandateExecute, signs the exact digest the caller already
141
+ * computed (agreement.ts's hashTerms()) — this package doesn't
142
+ * duplicate Agreement's own hashing logic, only signs what it's
143
+ * handed. */
144
+ async signAgreementTerms(params) {
145
+ await this.logAndEnforce("signAgreementTerms", { amount: null, counterparty: params.counterparty, contract: null, selector: null });
146
+ return this.options.keyStore.withPrivateKey(async (privateKey) => {
147
+ const { privateKeyToAccount } = await import("viem/accounts");
148
+ const account = privateKeyToAccount(privateKey);
149
+ const signature = await account.sign({ hash: params.termsHash });
150
+ return { signature };
151
+ });
152
+ }
153
+ /** Owner-initiated funds-out — same policy checks apply (a withdrawal
154
+ * is still constrained by the Mandate's spend limits and, if
155
+ * configured, allowed-counterparty list). */
156
+ async signWithdrawal(params) {
157
+ await this.logAndEnforce("signWithdrawal", { amount: params.amount, counterparty: params.to, contract: null, selector: null });
158
+ return this.options.keyStore.withPrivateKey(async (privateKey) => {
159
+ const { privateKeyToAccount } = await import("viem/accounts");
160
+ const account = privateKeyToAccount(privateKey);
161
+ const walletClient = createWalletClient({ account, chain: genericChain(params.chainId), transport: http(OFFLINE_PLACEHOLDER_RPC_URL) });
162
+ const signedTransaction = await walletClient.signTransaction({
163
+ account,
164
+ to: params.unsignedTx.to,
165
+ value: params.unsignedTx.value,
166
+ data: params.unsignedTx.data,
167
+ chain: genericChain(params.chainId),
168
+ });
169
+ return { signedTransaction };
170
+ });
171
+ }
172
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "gora8-signer",
3
+ "version": "0.1.0",
4
+ "description": "Local, self-custodied key signing for gora8 agents — the key is generated and used wherever your agent runs, never inside gora8's own infrastructure. See SELF_CUSTODY_ARCHITECTURE.md in the gora8 monorepo for the full design.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "gora8-signer": "./dist/cli.js"
17
+ },
18
+ "files": ["dist", "README.md", "LICENSE"],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.6.0",
28
+ "@types/node": "^20.0.0"
29
+ },
30
+ "dependencies": {
31
+ "viem": "^2.21.0",
32
+ "@napi-rs/keyring": "^1.3.0"
33
+ }
34
+ }