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 +21 -0
- package/README.md +109 -0
- package/dist/audit-log.d.ts +33 -0
- package/dist/audit-log.js +67 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +82 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/ipc-client.d.ts +50 -0
- package/dist/ipc-client.js +53 -0
- package/dist/ipc-server.d.ts +11 -0
- package/dist/ipc-server.js +81 -0
- package/dist/keystore.d.ts +60 -0
- package/dist/keystore.js +164 -0
- package/dist/merkle-tree.d.ts +11 -0
- package/dist/merkle-tree.js +55 -0
- package/dist/policy.d.ts +79 -0
- package/dist/policy.js +71 -0
- package/dist/signer.d.ts +120 -0
- package/dist/signer.js +172 -0
- package/package.json +34 -0
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
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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>;
|
package/dist/keystore.js
ADDED
|
@@ -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
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/signer.d.ts
ADDED
|
@@ -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
|
+
}
|