@metamynd/agentsafe-signer 0.13.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/merkle.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // merkle.mjs — a minimal, dependency-light Merkle tree, ported line-for-line from the platform's
2
+ // own backend/src/features/magp/merkle.ts (same algorithm mmt-graph already ported for its own
3
+ // package) rather than importing it: agentsafe-signer has no dependency on the backend or any
4
+ // other package in this monorepo, by design, so the handful of pure functions get copied instead.
5
+ // Used by log-checkpoint.mjs to batch the daemon's own request log into periodic, chained,
6
+ // tamper-evident checkpoints (docs/design/agent-key-custody-local-signer-daemon-plan.md, T11).
7
+ //
8
+ // Pure and deterministic: sha256 only, no clock/IO. Leaves and nodes are lower-case hex; an odd
9
+ // node is paired with itself (Bitcoin-style duplication).
10
+ import { createHash } from 'node:crypto';
11
+
12
+ /** sha256 → lower-case hex. */
13
+ export function sha256Hex(data) {
14
+ return createHash('sha256').update(data).digest('hex');
15
+ }
16
+
17
+ /** Hash two hex nodes by concatenating their raw bytes (not the hex text). */
18
+ function hashNodes(a, b) {
19
+ return sha256Hex(Buffer.concat([Buffer.from(a, 'hex'), Buffer.from(b, 'hex')]));
20
+ }
21
+
22
+ /** One level of the tree: pair up nodes (duplicating a lone last node) and hash. */
23
+ function nextLevel(level) {
24
+ const next = [];
25
+ for (let i = 0; i < level.length; i += 2) {
26
+ const left = level[i];
27
+ const right = i + 1 < level.length ? level[i + 1] : left;
28
+ next.push(hashNodes(left, right));
29
+ }
30
+ return next;
31
+ }
32
+
33
+ /** The Merkle root over the ordered leaves. Single leaf → itself; empty → sha256(""). */
34
+ export function merkleRoot(leaves) {
35
+ if (leaves.length === 0) return sha256Hex('');
36
+ let level = leaves.slice();
37
+ while (level.length > 1) level = nextLevel(level);
38
+ return level[0];
39
+ }
40
+
41
+ /**
42
+ * The inclusion proof for `leaves[index]` — the sibling hashes from leaf to root.
43
+ * @returns {{sibling: string, position: 'left'|'right'}[]}
44
+ */
45
+ export function merkleProof(leaves, index) {
46
+ if (index < 0 || index >= leaves.length) throw new Error('merkleProof: index out of range');
47
+ const proof = [];
48
+ let level = leaves.slice();
49
+ let idx = index;
50
+ while (level.length > 1) {
51
+ const isRightNode = idx % 2 === 1;
52
+ const pairIdx = isRightNode ? idx - 1 : idx + 1;
53
+ const sibling = pairIdx < level.length ? level[pairIdx] : level[idx]; // lone last node pairs with itself
54
+ proof.push({ sibling, position: isRightNode ? 'left' : 'right' });
55
+ level = nextLevel(level);
56
+ idx = Math.floor(idx / 2);
57
+ }
58
+ return proof;
59
+ }
60
+
61
+ /** Verify a leaf's inclusion proof reproduces the anchored root. */
62
+ export function verifyMerkleProof(leaf, proof, root) {
63
+ let computed = leaf;
64
+ for (const step of proof) {
65
+ computed = step.position === 'left' ? hashNodes(step.sibling, computed) : hashNodes(computed, step.sibling);
66
+ }
67
+ return computed === root;
68
+ }
package/migrate.mjs ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // migrate.mjs — key-loss recovery / migration CLI (docs/design/agent-key-custody-local-signer-
3
+ // daemon-plan.md's own README used to list this as a real gap: "No migration or fresh-
4
+ // provisioning CLI driving rotateNetworkIdentity ... end to end — the daemon exposes what those
5
+ // flows need (generate-key, sign-key-control-challenge), but nothing wires them together yet.").
6
+ //
7
+ // Rotates an EXISTING agent's network identity onto a FRESH key the daemon generates and never
8
+ // exposes, using the backend's real rotateNetworkIdentity (BYOK path — the server never sees a
9
+ // private key) plus the verify-key/regenerate-challenge proof-of-possession trio
10
+ // (backend/src/features/agent-identity/agent-network-identity.service.ts) rather than inventing
11
+ // a parallel mechanism. Before that trio existed, a BYOK rotation had NO proof anyone held the
12
+ // matching private key at all — this script exists specifically to drive the now-real flow.
13
+ //
14
+ // Requires TWO sockets from an already-running daemon: its ADMIN socket open (generate-key — the
15
+ // admin socket is a brief, operator-initiated provisioning window, not always-on: `agentsafe-
16
+ // signer start --admin`) and its normal SIGNING socket (sign-key-control-challenge, always on).
17
+ // Requires a bearer token from the backend's own session auth — rotateNetworkIdentity is a
18
+ // sensitive, human-authorized, ownership-checked action, deliberately NOT something an agent's
19
+ // own self-signed request can authorize on its own (unlike, say, T11's log-checkpoint anchoring,
20
+ // which genuinely is a low-stakes self-authenticating action).
21
+ //
22
+ // Does NOT hot-swap the identity of an already-running daemon process — this package's identity
23
+ // binding is deliberately startup-only (`--identity`), not a mutable admin operation; after a
24
+ // successful migration, restart the daemon bound to the new DID (this script prints the exact
25
+ // command rather than running it).
26
+ //
27
+ // node migrate.mjs --state-dir ./.signer --api-url https://api.example.com/v1 \
28
+ // --auth-token $TOKEN --ref <agentIdentityId-or-did> --network-identity-id <id>
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { parseArgs, daemonRequest } from './daemon-client.mjs';
32
+
33
+ async function apiCall(apiUrl, method, urlPath, authToken, body) {
34
+ const res = await fetch(new URL(urlPath, apiUrl), {
35
+ method,
36
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${authToken}` },
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ });
39
+ const json = await res.json().catch(() => null);
40
+ if (!res.ok || !json?.success) {
41
+ throw new Error(`${method} ${urlPath} failed (HTTP ${res.status}): ${json?.message ?? 'no response body'}`);
42
+ }
43
+ return json.data;
44
+ }
45
+
46
+ /**
47
+ * Rotates the network identity at `networkIdentityId` onto a fresh key the daemon itself
48
+ * generates: (1) generate-key on the admin socket (`allowRekey: true` — harmless even if the
49
+ * daemon has never held a key before, and required if it has); (2) POST .../rotate with the new
50
+ * publicKey (BYOK); (3) sign-key-control-challenge over the returned keyChallenge on the signing
51
+ * socket; (4) POST .../verify-key with that signature. Skips steps 3-4 if the rotation already
52
+ * came back verified (a managed, non-BYOK path — not the normal case here, but handled rather
53
+ * than assumed impossible). Returns the new DID and the exact restart command, rather than
54
+ * hot-swapping the running daemon's identity itself — see this file's own header on why.
55
+ */
56
+ export async function runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId }) {
57
+ const { publicKeyHex } = await daemonRequest(adminSocketPath, 'generate-key', { allowRekey: true });
58
+
59
+ const rotated = await apiCall(
60
+ apiUrl,
61
+ 'POST',
62
+ `/agent-identity/${encodeURIComponent(ref)}/network-identities/${encodeURIComponent(networkIdentityId)}/rotate`,
63
+ authToken,
64
+ { publicKey: publicKeyHex },
65
+ );
66
+
67
+ if (!rotated.keyVerifiedAt) {
68
+ if (!rotated.keyChallenge) throw new Error(`rotate response has no keyChallenge to prove possession with — got: ${JSON.stringify(rotated)}`);
69
+ const { signature } = await daemonRequest(socketPath, 'sign-key-control-challenge', { challenge: rotated.keyChallenge });
70
+ await apiCall(
71
+ apiUrl,
72
+ 'POST',
73
+ `/agent-identity/${encodeURIComponent(ref)}/network-identities/${encodeURIComponent(rotated.networkIdentityId)}/verify-key`,
74
+ authToken,
75
+ { signature },
76
+ );
77
+ }
78
+
79
+ return {
80
+ did: rotated.did,
81
+ networkIdentityId: rotated.networkIdentityId,
82
+ restartCommand: `node cli.mjs start --state-dir ${stateDir} --role agent --identity ${rotated.did}`,
83
+ };
84
+ }
85
+
86
+ async function main() {
87
+ const args = parseArgs(process.argv.slice(2));
88
+ const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
89
+ const socketPath = args['socket-path'] ?? path.join(stateDir, 'signer.sock');
90
+ const adminSocketPath = args['admin-socket-path'] ?? path.join(stateDir, 'signer-admin.sock');
91
+ const apiUrl = args['api-url'] ?? process.env.AGENTSAFE_SIGNER_API_URL;
92
+ const authToken = args['auth-token'] ?? process.env.AGENTSAFE_SIGNER_AUTH_TOKEN;
93
+ const ref = args.ref;
94
+ const networkIdentityId = args['network-identity-id'];
95
+
96
+ const missing = [];
97
+ if (!apiUrl) missing.push('--api-url (or AGENTSAFE_SIGNER_API_URL)');
98
+ if (!authToken) missing.push('--auth-token (or AGENTSAFE_SIGNER_AUTH_TOKEN)');
99
+ if (!ref) missing.push('--ref <agentIdentityId or DID>');
100
+ if (!networkIdentityId) missing.push('--network-identity-id <id>');
101
+ if (missing.length > 0) {
102
+ console.error(`agentsafe-signer migrate requires: ${missing.join(', ')}`);
103
+ process.exit(1);
104
+ }
105
+
106
+ console.log('[migrate] asking the daemon to generate a fresh key (requires --admin to be open right now)...');
107
+ const result = await runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId });
108
+ console.log(`[migrate] rotated to ${result.did} and verified proof of possession.`);
109
+ console.log('[migrate] restart the daemon bound to the new identity:');
110
+ console.log(` ${result.restartCommand}`);
111
+ }
112
+
113
+ // Only run as a CLI when invoked directly (`node migrate.mjs`), not when cli.mjs imports
114
+ // runMigrate from this same file.
115
+ if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
116
+ main().catch((err) => {
117
+ console.error('[migrate] fatal:', err);
118
+ process.exit(1);
119
+ });
120
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@metamynd/agentsafe-signer",
3
+ "version": "0.13.0",
4
+ "description": "Local signer daemon for AgentSafe agent/service keys \u2014 the key never enters the calling guard's own process. See docs/design/agent-key-custody-local-signer-daemon-plan.md.",
5
+ "type": "module",
6
+ "main": "./daemon.mjs",
7
+ "exports": {
8
+ ".": "./daemon.mjs",
9
+ "./keystore": "./keystore.mjs",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "daemon.mjs",
14
+ "windows-secure-pipe.mjs",
15
+ "merkle.mjs",
16
+ "log-checkpoint.mjs",
17
+ "log-anchor.mjs",
18
+ "migrate.mjs",
19
+ "daemon-client.mjs",
20
+ "keystore.mjs",
21
+ "secure-memory.mjs",
22
+ "kek-backends.mjs",
23
+ "service-installer.mjs",
24
+ "cli.mjs",
25
+ "policy-core.mjs",
26
+ "governance-envelope.mjs",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "test": "node daemon-protocol.smoke.mjs && node daemon-key-exfiltration.smoke.mjs && node kek-backends.smoke.mjs && node service-installer.smoke.mjs && node daemon-envelope-parity.smoke.mjs && node daemon-admin-socket.smoke.mjs && node daemon-log-integrity.smoke.mjs && node daemon-socket-permissions.smoke.mjs && node daemon-hardening.smoke.mjs && node daemon-hardening-tier2.smoke.mjs && node log-anchor.smoke.mjs && node migrate.smoke.mjs && node secure-memory.smoke.mjs",
32
+ "start": "node cli.mjs"
33
+ },
34
+ "dependencies": {
35
+ "sodium-native": "^4.3.3"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "sideEffects": false,
41
+ "author": "MetaMynd",
42
+ "license": "MIT",
43
+ "homepage": "https://metamynd.ai",
44
+ "bin": {
45
+ "agentsafe-signer": "./cli.mjs"
46
+ }
47
+ }