@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/README.md +382 -0
- package/cli.mjs +213 -0
- package/daemon-client.mjs +97 -0
- package/daemon.mjs +558 -0
- package/governance-envelope.mjs +69 -0
- package/kek-backends.mjs +192 -0
- package/keystore.mjs +108 -0
- package/log-anchor.mjs +112 -0
- package/log-checkpoint.mjs +191 -0
- package/merkle.mjs +68 -0
- package/migrate.mjs +120 -0
- package/package.json +47 -0
- package/policy-core.mjs +601 -0
- package/secure-memory.mjs +38 -0
- package/service-installer.mjs +244 -0
- package/windows-secure-pipe.mjs +367 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// GENERATED from backend/src/features/policy/mandate/governance-envelope.ts — do not edit. Regenerate: npm run build:signer-core
|
|
2
|
+
|
|
3
|
+
// src/features/policy/mandate/governance-envelope.ts
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
var ENVELOPE_VERSION = "1.0";
|
|
6
|
+
function stableStringify(value) {
|
|
7
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
|
|
8
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
9
|
+
const obj = value;
|
|
10
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
|
|
11
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
12
|
+
}
|
|
13
|
+
function envelopeIntegrityHash(env) {
|
|
14
|
+
const { integrity: _omit, ...rest } = env;
|
|
15
|
+
return createHash("sha256").update(stableStringify(rest)).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
function buildGovernanceEnvelope(input) {
|
|
18
|
+
const base = {
|
|
19
|
+
envelopeId: `env:${input.nonce}`,
|
|
20
|
+
version: ENVELOPE_VERSION,
|
|
21
|
+
createdAt: input.issuedAt,
|
|
22
|
+
agent: { did: input.agentDid },
|
|
23
|
+
action: {
|
|
24
|
+
actionId: `act:${input.nonce}`,
|
|
25
|
+
actionType: input.action,
|
|
26
|
+
amount: input.amount,
|
|
27
|
+
currency: input.currency,
|
|
28
|
+
merchant: input.merchant ?? null,
|
|
29
|
+
...input.materiality ? { materiality: input.materiality } : {}
|
|
30
|
+
},
|
|
31
|
+
...input.trace ? { trace: input.trace } : {},
|
|
32
|
+
...input.itinerary ? { context: input.itinerary } : {}
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
...base,
|
|
36
|
+
integrity: {
|
|
37
|
+
payloadHash: envelopeIntegrityHash(base),
|
|
38
|
+
signature: input.signature,
|
|
39
|
+
signatureType: "Ed25519"
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function envelopeHashFor(input) {
|
|
44
|
+
return buildGovernanceEnvelope(input).integrity.payloadHash;
|
|
45
|
+
}
|
|
46
|
+
function authorizeInputFromEnvelope(env) {
|
|
47
|
+
const nonce = env.envelopeId.startsWith("env:") ? env.envelopeId.slice(4) : env.envelopeId;
|
|
48
|
+
return {
|
|
49
|
+
agentDid: env.agent.did,
|
|
50
|
+
action: env.action.actionType,
|
|
51
|
+
amount: env.action.amount ?? 0,
|
|
52
|
+
currency: env.action.currency ?? "",
|
|
53
|
+
merchant: env.action.merchant ?? void 0,
|
|
54
|
+
itinerary: env.context,
|
|
55
|
+
trace: env.trace,
|
|
56
|
+
materiality: env.action.materiality,
|
|
57
|
+
nonce,
|
|
58
|
+
issuedAt: env.createdAt,
|
|
59
|
+
signature: env.integrity.signature ?? ""
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
ENVELOPE_VERSION,
|
|
64
|
+
authorizeInputFromEnvelope,
|
|
65
|
+
buildGovernanceEnvelope,
|
|
66
|
+
envelopeHashFor,
|
|
67
|
+
envelopeIntegrityHash,
|
|
68
|
+
stableStringify
|
|
69
|
+
};
|
package/kek-backends.mjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// kek-backends.mjs — OS-keychain-backed KEK storage, replacing the passphrase+scrypt placeholder
|
|
2
|
+
// from the first implementation pass. Shells out to each platform's own standard tool rather than
|
|
3
|
+
// adding a native-module dependency (matches this package's zero-npm-dependency discipline):
|
|
4
|
+
// Windows DPAPI via PowerShell, macOS Keychain via `security`, Linux via `systemd-creds` (fits
|
|
5
|
+
// the Tier 2 systemd-service deployment model this daemon targets) with `secret-tool` as a
|
|
6
|
+
// fallback for desktop sessions. Passphrase+scrypt remains the last-resort fallback when none of
|
|
7
|
+
// these are available — explicitly weaker, never silent about which one is actually in use.
|
|
8
|
+
//
|
|
9
|
+
// VERIFIED in this implementation pass: only the Windows DPAPI backend, on this development
|
|
10
|
+
// machine (Windows). The macOS and Linux backends are implemented against each tool's documented
|
|
11
|
+
// CLI syntax but have NOT been run against a real macOS or Linux host — see README.md "Status".
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { deriveKek, loadOrCreateSalt } from './keystore.mjs';
|
|
17
|
+
|
|
18
|
+
const KEK_BYTES = 32;
|
|
19
|
+
|
|
20
|
+
function toolAvailable(cmd, args) {
|
|
21
|
+
try {
|
|
22
|
+
execFileSync(cmd, args, { stdio: 'ignore', windowsHide: true });
|
|
23
|
+
return true;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// ENOENT = tool not installed. Any other failure (e.g. a probe command that legitimately
|
|
26
|
+
// exits non-zero) still proves the tool EXISTS, which is all this check needs to know.
|
|
27
|
+
return err.code !== 'ENOENT';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Windows: DPAPI, via PowerShell (no native addon, no extra dependency) ------------------
|
|
32
|
+
|
|
33
|
+
const DPAPI_PROTECT_SCRIPT = `
|
|
34
|
+
Add-Type -AssemblyName System.Security
|
|
35
|
+
$b64 = [Console]::In.ReadToEnd()
|
|
36
|
+
$bytes = [Convert]::FromBase64String($b64)
|
|
37
|
+
$protected = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
38
|
+
[Console]::Out.Write([Convert]::ToBase64String($protected))
|
|
39
|
+
`.trim();
|
|
40
|
+
|
|
41
|
+
const DPAPI_UNPROTECT_SCRIPT = `
|
|
42
|
+
Add-Type -AssemblyName System.Security
|
|
43
|
+
$b64 = [Console]::In.ReadToEnd()
|
|
44
|
+
$bytes = [Convert]::FromBase64String($b64)
|
|
45
|
+
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
46
|
+
[Console]::Out.Write([Convert]::ToBase64String($plain))
|
|
47
|
+
`.trim();
|
|
48
|
+
|
|
49
|
+
function dpapiRun(script, inputB64) {
|
|
50
|
+
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
51
|
+
input: inputB64,
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
}).trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const windowsDpapiBackend = {
|
|
58
|
+
name: 'dpapi',
|
|
59
|
+
available() {
|
|
60
|
+
return process.platform === 'win32';
|
|
61
|
+
},
|
|
62
|
+
getOrCreateKek(stateDir) {
|
|
63
|
+
const blobPath = path.join(stateDir, 'kek.dpapi');
|
|
64
|
+
if (fs.existsSync(blobPath)) {
|
|
65
|
+
const protectedB64 = fs.readFileSync(blobPath, 'utf8');
|
|
66
|
+
const plainB64 = dpapiRun(DPAPI_UNPROTECT_SCRIPT, protectedB64);
|
|
67
|
+
return Buffer.from(plainB64, 'base64');
|
|
68
|
+
}
|
|
69
|
+
const kek = crypto.randomBytes(KEK_BYTES);
|
|
70
|
+
const protectedB64 = dpapiRun(DPAPI_PROTECT_SCRIPT, kek.toString('base64'));
|
|
71
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
72
|
+
fs.writeFileSync(blobPath, protectedB64, { mode: 0o600 });
|
|
73
|
+
return kek;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// --- macOS: Keychain, via `security` --------------------------------------------------------
|
|
78
|
+
// NOT verified against a real macOS host in this pass — implemented per `man security`'s
|
|
79
|
+
// documented add-generic-password/find-generic-password behavior.
|
|
80
|
+
|
|
81
|
+
function keychainAccount(stateDir) {
|
|
82
|
+
return 'agentsafe-signer-' + crypto.createHash('sha256').update(path.resolve(stateDir)).digest('hex').slice(0, 16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const macosKeychainBackend = {
|
|
86
|
+
name: 'macos-keychain',
|
|
87
|
+
available() {
|
|
88
|
+
return process.platform === 'darwin' && toolAvailable('security', ['help']);
|
|
89
|
+
},
|
|
90
|
+
getOrCreateKek(stateDir) {
|
|
91
|
+
const service = 'AgentSafe Signer';
|
|
92
|
+
const account = keychainAccount(stateDir);
|
|
93
|
+
try {
|
|
94
|
+
const existing = execFileSync('security', ['find-generic-password', '-a', account, '-s', service, '-w'], { encoding: 'utf8' }).trim();
|
|
95
|
+
return Buffer.from(existing, 'base64');
|
|
96
|
+
} catch {
|
|
97
|
+
const kek = crypto.randomBytes(KEK_BYTES);
|
|
98
|
+
execFileSync('security', ['add-generic-password', '-a', account, '-s', service, '-w', kek.toString('base64'), '-U']);
|
|
99
|
+
return kek;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// --- Linux: systemd-creds first (fits the Tier 2 service model), secret-tool as a desktop
|
|
105
|
+
// fallback. Neither verified against a real Linux host in this pass.
|
|
106
|
+
|
|
107
|
+
function credName(stateDir) {
|
|
108
|
+
return 'agentsafe-signer-kek-' + crypto.createHash('sha256').update(path.resolve(stateDir)).digest('hex').slice(0, 16);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const systemdCredsBackend = {
|
|
112
|
+
name: 'systemd-creds',
|
|
113
|
+
available() {
|
|
114
|
+
return process.platform === 'linux' && toolAvailable('systemd-creds', ['--version']);
|
|
115
|
+
},
|
|
116
|
+
getOrCreateKek(stateDir) {
|
|
117
|
+
const name = credName(stateDir);
|
|
118
|
+
const blobPath = path.join(stateDir, 'kek.cred');
|
|
119
|
+
if (fs.existsSync(blobPath)) {
|
|
120
|
+
const encrypted = fs.readFileSync(blobPath, 'utf8');
|
|
121
|
+
const plainB64 = execFileSync('systemd-creds', ['decrypt', `--name=${name}`, '-', '-'], { input: encrypted, encoding: 'utf8' }).trim();
|
|
122
|
+
return Buffer.from(plainB64, 'base64');
|
|
123
|
+
}
|
|
124
|
+
const kek = crypto.randomBytes(KEK_BYTES);
|
|
125
|
+
const encrypted = execFileSync('systemd-creds', ['encrypt', `--name=${name}`, '-', '-'], { input: kek.toString('base64'), encoding: 'utf8' });
|
|
126
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
127
|
+
fs.writeFileSync(blobPath, encrypted, { mode: 0o600 });
|
|
128
|
+
return kek;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const secretToolBackend = {
|
|
133
|
+
name: 'secret-tool',
|
|
134
|
+
available() {
|
|
135
|
+
return process.platform === 'linux' && toolAvailable('secret-tool', ['--version']);
|
|
136
|
+
},
|
|
137
|
+
getOrCreateKek(stateDir) {
|
|
138
|
+
const account = credName(stateDir);
|
|
139
|
+
const attrs = ['service', 'agentsafe-signer', 'account', account];
|
|
140
|
+
try {
|
|
141
|
+
const existing = execFileSync('secret-tool', ['lookup', ...attrs], { encoding: 'utf8' }).trim();
|
|
142
|
+
if (!existing) throw new Error('not found');
|
|
143
|
+
return Buffer.from(existing, 'base64');
|
|
144
|
+
} catch {
|
|
145
|
+
const kek = crypto.randomBytes(KEK_BYTES);
|
|
146
|
+
execFileSync('secret-tool', ['store', '--label=AgentSafe Signer KEK', ...attrs], { input: kek.toString('base64') + '\n' });
|
|
147
|
+
return kek;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// --- Fallback: passphrase + scrypt (the whole KeyStore from the first pass) -----------------
|
|
153
|
+
|
|
154
|
+
function passphraseFallback(stateDir) {
|
|
155
|
+
const passphrase = process.env.AGENTSAFE_SIGNER_PASSPHRASE;
|
|
156
|
+
if (!passphrase) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'No OS-keychain backend is available on this platform/host, and AGENTSAFE_SIGNER_PASSPHRASE ' +
|
|
159
|
+
'is not set. Set it to a real secret from your own secret manager to use the weaker ' +
|
|
160
|
+
'passphrase fallback, or run on a platform with a supported keychain backend.',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const saltHex = loadOrCreateSalt(stateDir);
|
|
164
|
+
return deriveKek(passphrase, saltHex);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const PLATFORM_BACKENDS = [windowsDpapiBackend, macosKeychainBackend, systemdCredsBackend, secretToolBackend];
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolves the KEK for `stateDir`, preferring a real OS-keychain backend over the passphrase
|
|
171
|
+
* fallback, and always reporting which one was actually used — an operator should be able to see
|
|
172
|
+
* what's protecting their key, not have to assume.
|
|
173
|
+
* @returns {{ kek: Buffer, backend: string }}
|
|
174
|
+
*/
|
|
175
|
+
export function resolveKek(stateDir) {
|
|
176
|
+
for (const backend of PLATFORM_BACKENDS) {
|
|
177
|
+
if (!backend.available()) continue;
|
|
178
|
+
try {
|
|
179
|
+
return { kek: backend.getOrCreateKek(stateDir), backend: backend.name };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// `available()` only proves the tool exists and runs (e.g. `systemd-creds --version`) — it
|
|
182
|
+
// can't cheaply prove the backend actually WORKS in this environment. systemd-creds needs a
|
|
183
|
+
// host/TPM secret that a container or restricted host may not have even though the binary
|
|
184
|
+
// itself is present; caught for real on a GitHub Actions runner ("Failed to determine local
|
|
185
|
+
// credential host secret: Permission denied"), not assumed. A backend that is present but
|
|
186
|
+
// unusable is not meaningfully different from one that is absent — fall through to the next
|
|
187
|
+
// one rather than crashing the whole daemon over it.
|
|
188
|
+
console.error(`[agentsafe-signer] KEK backend "${backend.name}" is available but failed (${err.message}); trying the next one.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { kek: passphraseFallback(stateDir), backend: 'passphrase' };
|
|
192
|
+
}
|
package/keystore.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// keystore.mjs — encrypted-at-rest Ed25519 key storage. The KEK itself is supplied by the
|
|
2
|
+
// caller (daemon.mjs/cli.mjs), not derived here: this module only ever handles encrypt/decrypt
|
|
3
|
+
// of the private key, never how the KEK was obtained. See README.md "Status" for what backs the
|
|
4
|
+
// KEK today (a passphrase, not yet a real OS-keychain integration) and what that gap means.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { withLockedCopy } from './secure-memory.mjs';
|
|
9
|
+
|
|
10
|
+
export const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 };
|
|
11
|
+
|
|
12
|
+
/** Derive a 32-byte KEK from a passphrase + salt. Salt is not secret — safe to store alongside the encrypted key. */
|
|
13
|
+
export function deriveKek(passphrase, saltHex) {
|
|
14
|
+
return crypto.scryptSync(passphrase, Buffer.from(saltHex, 'hex'), SCRYPT_PARAMS.keylen, {
|
|
15
|
+
N: SCRYPT_PARAMS.N,
|
|
16
|
+
r: SCRYPT_PARAMS.r,
|
|
17
|
+
p: SCRYPT_PARAMS.p,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadOrCreateSalt(stateDir) {
|
|
22
|
+
const saltPath = path.join(stateDir, 'salt.hex');
|
|
23
|
+
if (fs.existsSync(saltPath)) return fs.readFileSync(saltPath, 'utf8').trim();
|
|
24
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const saltHex = crypto.randomBytes(16).toString('hex');
|
|
26
|
+
fs.writeFileSync(saltPath, saltHex, { mode: 0o600 });
|
|
27
|
+
return saltHex;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class KeyStoreError extends Error {
|
|
31
|
+
constructor(code) {
|
|
32
|
+
super(code);
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Encrypted-at-rest Ed25519 private key. Never exposes the private key itself — `sign()` is the
|
|
39
|
+
* only operation that touches it, and it's decrypted fresh for each call and zeroed immediately
|
|
40
|
+
* after (see sign()'s own `finally`), not held decrypted between calls.
|
|
41
|
+
*/
|
|
42
|
+
export class KeyStore {
|
|
43
|
+
#kek;
|
|
44
|
+
#dir;
|
|
45
|
+
#record = null;
|
|
46
|
+
|
|
47
|
+
constructor({ dir, kek }) {
|
|
48
|
+
this.#dir = dir;
|
|
49
|
+
this.#kek = kek;
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
this.#load();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get #keystorePath() {
|
|
55
|
+
return path.join(this.#dir, 'keystore.enc.json');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#load() {
|
|
59
|
+
if (!fs.existsSync(this.#keystorePath)) return;
|
|
60
|
+
this.#record = JSON.parse(fs.readFileSync(this.#keystorePath, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hasKey() {
|
|
64
|
+
return this.#record !== null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
publicKeyHex() {
|
|
68
|
+
return this.#record?.publicKeyHex ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Generates a new Ed25519 keypair inside this store. Refuses to overwrite an existing key. */
|
|
72
|
+
generate({ allowRekey = false } = {}) {
|
|
73
|
+
if (this.hasKey() && !allowRekey) throw new KeyStoreError('KEY_ALREADY_EXISTS');
|
|
74
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
75
|
+
const privateDer = privateKey.export({ format: 'der', type: 'pkcs8' });
|
|
76
|
+
const publicKeyHex = publicKey.export({ format: 'der', type: 'spki' }).toString('hex');
|
|
77
|
+
// withLockedCopy mlocks the DER bytes for the brief window they're plaintext here, and zeroes
|
|
78
|
+
// `privateDer` itself immediately — see secure-memory.mjs for why this needs a real dependency.
|
|
79
|
+
const { iv, authTag, ciphertext } = withLockedCopy(privateDer, (locked) => {
|
|
80
|
+
const iv = crypto.randomBytes(12);
|
|
81
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.#kek, iv);
|
|
82
|
+
const ciphertext = Buffer.concat([cipher.update(locked), cipher.final()]);
|
|
83
|
+
return { iv, authTag: cipher.getAuthTag(), ciphertext };
|
|
84
|
+
});
|
|
85
|
+
const record = {
|
|
86
|
+
publicKeyHex,
|
|
87
|
+
iv: iv.toString('hex'),
|
|
88
|
+
authTag: authTag.toString('hex'),
|
|
89
|
+
ciphertext: ciphertext.toString('hex'),
|
|
90
|
+
};
|
|
91
|
+
fs.writeFileSync(this.#keystorePath, JSON.stringify(record), { mode: 0o600 });
|
|
92
|
+
this.#record = record;
|
|
93
|
+
return publicKeyHex;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Signs `messageBuffer` with the stored private key. The decrypted key never leaves this function. */
|
|
97
|
+
sign(messageBuffer) {
|
|
98
|
+
if (!this.hasKey()) throw new KeyStoreError('NOT_PROVISIONED');
|
|
99
|
+
const { iv, authTag, ciphertext } = this.#record;
|
|
100
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.#kek, Buffer.from(iv, 'hex'));
|
|
101
|
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
|
102
|
+
const privateDer = Buffer.concat([decipher.update(Buffer.from(ciphertext, 'hex')), decipher.final()]);
|
|
103
|
+
return withLockedCopy(privateDer, (locked) => {
|
|
104
|
+
const privateKey = crypto.createPrivateKey({ key: locked, format: 'der', type: 'pkcs8' });
|
|
105
|
+
return crypto.sign(null, messageBuffer, privateKey).toString('hex');
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
package/log-anchor.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// log-anchor.mjs — the separate, optional, LIVE-NETWORK half of T11 (docs/design/agent-key-
|
|
3
|
+
// custody-local-signer-daemon-plan.md). log-checkpoint.mjs already produces local, chained
|
|
4
|
+
// checkpoints with zero network dependency; this script reads whichever ones aren't anchored yet
|
|
5
|
+
// (pendingAnchors), asks the already-running daemon to sign an anchor request for each one
|
|
6
|
+
// (sign-log-checkpoint — only the daemon holds the key), and POSTs the signed request to the
|
|
7
|
+
// backend's /evidence/signer-checkpoint endpoint, which verifies the signature against the
|
|
8
|
+
// agent's own DID and anchors the checkpoint hash via HCS.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately a SEPARATE process from daemon.mjs, not a daemon feature: the daemon stays
|
|
11
|
+
// local-first / offline-capable by design — this is the one piece of the whole system that
|
|
12
|
+
// genuinely needs a live network connection, and keeping it a separate, operator-scheduled script
|
|
13
|
+
// (cron, a systemd timer) means a daemon that never gets anchored still signs and logs correctly;
|
|
14
|
+
// anchoring is a bonus property, not a dependency of normal operation.
|
|
15
|
+
//
|
|
16
|
+
// node log-anchor.mjs --state-dir ./.signer --api-url https://api.example.com/v1
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { pendingAnchors, markAnchored } from './log-checkpoint.mjs';
|
|
20
|
+
import { parseArgs, daemonRequest } from './daemon-client.mjs';
|
|
21
|
+
|
|
22
|
+
async function anchorOne(checkpoint, { socketPath, apiUrl, agentDid }) {
|
|
23
|
+
const nonce = crypto.randomUUID();
|
|
24
|
+
const issuedAt = new Date().toISOString();
|
|
25
|
+
const { signature } = await daemonRequest(socketPath, 'sign-log-checkpoint', {
|
|
26
|
+
checkpointHash: checkpoint.checkpointHash,
|
|
27
|
+
previousCheckpointHash: checkpoint.previousCheckpointHash,
|
|
28
|
+
entryCount: checkpoint.entryCount,
|
|
29
|
+
nonce,
|
|
30
|
+
issuedAt,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const body = {
|
|
34
|
+
agentDid,
|
|
35
|
+
checkpointHash: checkpoint.checkpointHash,
|
|
36
|
+
previousCheckpointHash: checkpoint.previousCheckpointHash,
|
|
37
|
+
entryCount: checkpoint.entryCount,
|
|
38
|
+
nonce,
|
|
39
|
+
issuedAt,
|
|
40
|
+
signature,
|
|
41
|
+
};
|
|
42
|
+
const res = await fetch(new URL('/evidence/signer-checkpoint', apiUrl), {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
const json = await res.json().catch(() => null);
|
|
48
|
+
if (!res.ok || !json?.success) {
|
|
49
|
+
throw new Error(`anchor request failed (HTTP ${res.status}): ${json?.message ?? 'no response body'}`);
|
|
50
|
+
}
|
|
51
|
+
return json.data; // { txId, ref, network, anchoredAt, alreadyAnchored }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Anchors every pending local checkpoint for the daemon at `socketPath`/`stateDir`. Exported so
|
|
56
|
+
* cli.mjs's `log-anchor` subcommand and this file's own direct-invocation `main()` share one
|
|
57
|
+
* implementation rather than drifting. Returns the number of checkpoints that failed to anchor
|
|
58
|
+
* (0 = fully caught up); never throws for an individual checkpoint's failure — each is
|
|
59
|
+
* independent, and a later run retries whatever didn't get marked anchored this time.
|
|
60
|
+
*/
|
|
61
|
+
export async function runLogAnchor({ stateDir, socketPath, apiUrl }) {
|
|
62
|
+
const pending = pendingAnchors(stateDir);
|
|
63
|
+
if (pending.length === 0) {
|
|
64
|
+
console.log('[log-anchor] nothing pending — every local checkpoint is already anchored.');
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Ask the already-running daemon who it is, rather than requiring a redundant --identity flag
|
|
69
|
+
// that could drift from the daemon's own bound agentDid.
|
|
70
|
+
const { identity: agentDid } = await daemonRequest(socketPath, 'get-identity', {});
|
|
71
|
+
if (!agentDid) {
|
|
72
|
+
console.error('[log-anchor] the daemon has no bound identity yet (not provisioned) — nothing to anchor as.');
|
|
73
|
+
return pending.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let failed = 0;
|
|
77
|
+
for (const checkpoint of pending) {
|
|
78
|
+
try {
|
|
79
|
+
const data = await anchorOne(checkpoint, { socketPath, apiUrl, agentDid });
|
|
80
|
+
markAnchored(stateDir, checkpoint.checkpointHash, { txId: data.txId, ref: data.ref });
|
|
81
|
+
console.log(`[log-anchor] checkpoint ${checkpoint.index} (${checkpoint.checkpointHash.slice(0, 12)}…) -> ${data.txId}${data.alreadyAnchored ? ' (already anchored)' : ''}`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
failed++;
|
|
84
|
+
console.error(`[log-anchor] checkpoint ${checkpoint.index} FAILED: ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return failed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const args = parseArgs(process.argv.slice(2));
|
|
92
|
+
const stateDir = args['state-dir'] ?? path.join(process.cwd(), '.agentsafe-signer');
|
|
93
|
+
const socketPath = args['socket-path'] ?? path.join(stateDir, 'signer.sock');
|
|
94
|
+
const apiUrl = args['api-url'] ?? process.env.AGENTSAFE_SIGNER_API_URL;
|
|
95
|
+
if (!apiUrl) {
|
|
96
|
+
console.error('Usage: agentsafe-signer log-anchor requires --api-url <backend base url> (or AGENTSAFE_SIGNER_API_URL)');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const failed = await runLogAnchor({ stateDir, socketPath, apiUrl });
|
|
100
|
+
if (failed > 0) process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Only run as a CLI when invoked directly (`node log-anchor.mjs`), not when cli.mjs imports
|
|
104
|
+
// runLogAnchor from this same file. path.resolve(), not a raw string/URL compare — Windows path
|
|
105
|
+
// separators and drive-letter casing make a direct import.meta.url-vs-argv[1] string comparison
|
|
106
|
+
// unreliable there.
|
|
107
|
+
if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
|
|
108
|
+
main().catch((err) => {
|
|
109
|
+
console.error('[log-anchor] fatal:', err);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// log-checkpoint.mjs — periodic, local, tamper-evident checkpointing of the daemon's own
|
|
2
|
+
// signer.log (docs/design/agent-key-custody-local-signer-daemon-plan.md, T11: "periodically
|
|
3
|
+
// hash-chaining the log ... would give tamper-evidence without inventing a new mechanism").
|
|
4
|
+
//
|
|
5
|
+
// Fully offline and dependency-free: this only proves the log wasn't altered SINCE a checkpoint
|
|
6
|
+
// was taken (a privileged local attacker who tampers with an already-checkpointed line will break
|
|
7
|
+
// that checkpoint's own Merkle root, or the hash-chain link to it, and verifyLogCheckpoints below
|
|
8
|
+
// will say exactly where). It does NOT, on its own, stop an attacker who controls both the log
|
|
9
|
+
// AND the checkpoint file from rewriting both consistently — that is what externally anchoring a
|
|
10
|
+
// checkpointHash (log-anchor.mjs, a separate optional step, needs live network + backend) is for.
|
|
11
|
+
// This module never touches the network.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { sha256Hex, merkleRoot } from './merkle.mjs';
|
|
15
|
+
|
|
16
|
+
/** The `previousCheckpointHash` for checkpoint index 0 — a fixed, documented constant so the
|
|
17
|
+
* very first checkpoint's chain link is still well-defined and verifiable, not an arbitrary or
|
|
18
|
+
* attacker-choosable value. */
|
|
19
|
+
export const GENESIS_HASH = sha256Hex('agentsafe-signer-log-checkpoint-genesis');
|
|
20
|
+
|
|
21
|
+
function checkpointsPath(stateDir) {
|
|
22
|
+
return path.join(stateDir, 'signer.log.checkpoints.jsonl');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function logPath(stateDir) {
|
|
26
|
+
return path.join(stateDir, 'signer.log');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCheckpoints(stateDir) {
|
|
30
|
+
const p = checkpointsPath(stateDir);
|
|
31
|
+
if (!fs.existsSync(p)) return [];
|
|
32
|
+
return fs
|
|
33
|
+
.readFileSync(p, 'utf8')
|
|
34
|
+
.split('\n')
|
|
35
|
+
.filter((line) => line.trim().length > 0)
|
|
36
|
+
.map((line) => JSON.parse(line));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The exact byte range of the log to include in the next checkpoint: from the last checkpoint's
|
|
41
|
+
* `toOffset` (or 0) up to the end of the last COMPLETE line currently in the file. Stopping at the
|
|
42
|
+
* last complete line — never the raw file size — means a checkpoint can never include a line that
|
|
43
|
+
* was only partially flushed, even though `fs.appendFileSync`'s single synchronous write call
|
|
44
|
+
* makes a torn write for one JSON line essentially impossible in practice; this is a defensive
|
|
45
|
+
* bound, not a response to an observed failure.
|
|
46
|
+
*/
|
|
47
|
+
function pendingRange(stateDir, fromOffset) {
|
|
48
|
+
const p = logPath(stateDir);
|
|
49
|
+
if (!fs.existsSync(p)) return null;
|
|
50
|
+
const buf = fs.readFileSync(p);
|
|
51
|
+
if (buf.length <= fromOffset) return null;
|
|
52
|
+
const region = buf.subarray(fromOffset);
|
|
53
|
+
const lastNewline = region.lastIndexOf(0x0a);
|
|
54
|
+
if (lastNewline === -1) return null; // no complete line since the last checkpoint yet
|
|
55
|
+
const toOffset = fromOffset + lastNewline + 1;
|
|
56
|
+
const lines = region
|
|
57
|
+
.subarray(0, lastNewline + 1)
|
|
58
|
+
.toString('utf8')
|
|
59
|
+
.split('\n')
|
|
60
|
+
.filter((line) => line.length > 0);
|
|
61
|
+
if (lines.length === 0) return null;
|
|
62
|
+
return { toOffset, lines };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Computes and appends the next checkpoint over whatever new, complete log lines have accumulated
|
|
67
|
+
* since the last one. Returns the new checkpoint record, or `null` if there was nothing new to
|
|
68
|
+
* checkpoint (never appends an empty/no-op checkpoint — a chain of identical, content-free links
|
|
69
|
+
* would be dead weight, not evidence of anything).
|
|
70
|
+
*
|
|
71
|
+
* Each leaf is the hash of a log line's exact stored BYTES, not a re-parsed/re-serialized version
|
|
72
|
+
* of it — hashing the raw bytes catches any byte-level edit (e.g. changing one field's value
|
|
73
|
+
* in-place without touching key order), not just a naive attacker's attempt to reorder JSON keys.
|
|
74
|
+
*/
|
|
75
|
+
export function takeCheckpoint(stateDir) {
|
|
76
|
+
const existing = readCheckpoints(stateDir);
|
|
77
|
+
const fromOffset = existing.length > 0 ? existing[existing.length - 1].toOffset : 0;
|
|
78
|
+
const previousCheckpointHash = existing.length > 0 ? existing[existing.length - 1].checkpointHash : GENESIS_HASH;
|
|
79
|
+
|
|
80
|
+
const pending = pendingRange(stateDir, fromOffset);
|
|
81
|
+
if (!pending) return null;
|
|
82
|
+
|
|
83
|
+
const leaves = pending.lines.map((line) => sha256Hex(line));
|
|
84
|
+
const root = merkleRoot(leaves);
|
|
85
|
+
const checkpointHash = sha256Hex(`${previousCheckpointHash}:${root}`);
|
|
86
|
+
|
|
87
|
+
const checkpoint = {
|
|
88
|
+
index: existing.length,
|
|
89
|
+
fromOffset,
|
|
90
|
+
toOffset: pending.toOffset,
|
|
91
|
+
entryCount: leaves.length,
|
|
92
|
+
merkleRoot: root,
|
|
93
|
+
previousCheckpointHash,
|
|
94
|
+
checkpointHash,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
fs.appendFileSync(checkpointsPath(stateDir), JSON.stringify(checkpoint) + '\n', { mode: 0o600 });
|
|
99
|
+
return checkpoint;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Recomputes every checkpoint directly from the raw log bytes at its recorded offsets and confirms
|
|
104
|
+
* both properties a tamper-evident chain needs: (1) the batch's Merkle root still matches what is
|
|
105
|
+
* actually stored in the log at that byte range, and (2) each checkpoint's own hash correctly
|
|
106
|
+
* chains from the previous one (or GENESIS_HASH, for the first). Returns as soon as the first
|
|
107
|
+
* mismatch is found — a tampered log doesn't need every subsequent checkpoint checked to know it's
|
|
108
|
+
* been tampered with.
|
|
109
|
+
*/
|
|
110
|
+
export function verifyLogCheckpoints(stateDir) {
|
|
111
|
+
const checkpoints = readCheckpoints(stateDir);
|
|
112
|
+
if (checkpoints.length === 0) return { ok: true, checkedCount: 0 };
|
|
113
|
+
|
|
114
|
+
const buf = fs.existsSync(logPath(stateDir)) ? fs.readFileSync(logPath(stateDir)) : Buffer.alloc(0);
|
|
115
|
+
let previousCheckpointHash = GENESIS_HASH;
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < checkpoints.length; i++) {
|
|
118
|
+
const cp = checkpoints[i];
|
|
119
|
+
|
|
120
|
+
if (cp.previousCheckpointHash !== previousCheckpointHash) {
|
|
121
|
+
return { ok: false, checkedCount: i, failedIndex: i, reason: 'chain link does not match the previous checkpoint\'s own hash' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (buf.length < cp.toOffset) {
|
|
125
|
+
return { ok: false, checkedCount: i, failedIndex: i, reason: 'log file is shorter than this checkpoint recorded — truncated or replaced' };
|
|
126
|
+
}
|
|
127
|
+
const region = buf.subarray(cp.fromOffset, cp.toOffset).toString('utf8');
|
|
128
|
+
const lines = region.split('\n').filter((line) => line.length > 0);
|
|
129
|
+
const recomputedRoot = merkleRoot(lines.map((line) => sha256Hex(line)));
|
|
130
|
+
if (recomputedRoot !== cp.merkleRoot) {
|
|
131
|
+
return { ok: false, checkedCount: i, failedIndex: i, reason: 'Merkle root no longer matches the log content at this checkpoint\'s byte range — a covered line was modified' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const recomputedCheckpointHash = sha256Hex(`${cp.previousCheckpointHash}:${cp.merkleRoot}`);
|
|
135
|
+
if (recomputedCheckpointHash !== cp.checkpointHash) {
|
|
136
|
+
return { ok: false, checkedCount: i, failedIndex: i, reason: 'checkpointHash does not match its own recorded previousCheckpointHash + merkleRoot — the checkpoint record itself was edited' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
previousCheckpointHash = cp.checkpointHash;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { ok: true, checkedCount: checkpoints.length };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function anchorsPath(stateDir) {
|
|
146
|
+
return path.join(stateDir, 'signer.log.anchors.jsonl');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readAnchors(stateDir) {
|
|
150
|
+
const p = anchorsPath(stateDir);
|
|
151
|
+
if (!fs.existsSync(p)) return new Map();
|
|
152
|
+
const map = new Map();
|
|
153
|
+
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
|
|
154
|
+
if (!line.trim()) continue;
|
|
155
|
+
const record = JSON.parse(line);
|
|
156
|
+
map.set(record.checkpointHash, record); // last write for a given hash wins, matching a re-anchor after a retry
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Records the result of successfully anchoring one checkpoint. Deliberately a SEPARATE, append-
|
|
163
|
+
* only file rather than rewriting checkpoints.jsonl in place: `takeCheckpoint` (called only by the
|
|
164
|
+
* daemon's own periodic timer) and this function (called by the separate, operator-run
|
|
165
|
+
* `log-anchor.mjs` process) can run in different processes at any time, and an append is always
|
|
166
|
+
* safe against a concurrent append elsewhere — a read-modify-write rewrite of the checkpoints file
|
|
167
|
+
* from a second process would risk silently dropping a checkpoint the daemon appended in between.
|
|
168
|
+
*/
|
|
169
|
+
export function markAnchored(stateDir, checkpointHash, { txId, ref, anchoredAt = new Date().toISOString() }) {
|
|
170
|
+
if (!readCheckpoints(stateDir).some((cp) => cp.checkpointHash === checkpointHash)) {
|
|
171
|
+
throw new Error(`markAnchored: no checkpoint with checkpointHash ${checkpointHash}`);
|
|
172
|
+
}
|
|
173
|
+
const record = { checkpointHash, txId, ref, anchoredAt };
|
|
174
|
+
fs.appendFileSync(anchorsPath(stateDir), JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
175
|
+
return record;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every checkpoint, each with its anchor record merged in (if any) as `anchoredAt`/`txId`/`ref`. */
|
|
179
|
+
export function listCheckpoints(stateDir) {
|
|
180
|
+
const anchors = readAnchors(stateDir);
|
|
181
|
+
return readCheckpoints(stateDir).map((cp) => {
|
|
182
|
+
const anchor = anchors.get(cp.checkpointHash);
|
|
183
|
+
return anchor ? { ...cp, anchoredAt: anchor.anchoredAt, txId: anchor.txId, ref: anchor.ref } : cp;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The checkpoints not yet anchored externally (see log-anchor.mjs) — no matching record in the
|
|
188
|
+
* separate anchors file. */
|
|
189
|
+
export function pendingAnchors(stateDir) {
|
|
190
|
+
return listCheckpoints(stateDir).filter((cp) => !cp.anchoredAt);
|
|
191
|
+
}
|