@metamynd/agentsafe-signer 0.17.1 → 0.19.1
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 +20 -0
- package/daemon-client.mjs +24 -1
- package/daemon.mjs +16 -5
- package/migrate.mjs +9 -2
- package/package.json +2 -2
- package/policy-core.mjs +17 -2
- package/windows-secure-pipe.mjs +31 -1
package/README.md
CHANGED
|
@@ -197,6 +197,26 @@ as the counterparty that executes for it. Rate limit: 120 per 10 s. Needs `@meta
|
|
|
197
197
|
(`daemon-log-integrity.smoke.mjs`, above) — though it covers only the local hash-chain half of
|
|
198
198
|
T11, not the external-anchoring half, which remains open per the bullet above.
|
|
199
199
|
|
|
200
|
+
## Signed jurisdiction (`sign-authorize`, since 0.18.0)
|
|
201
|
+
|
|
202
|
+
`sign-authorize` accepts an optional `jurisdiction` (two ASCII letters; the client normalises it to upper case and
|
|
203
|
+
sends the same string). With it the daemon signs the v2 message (MAGP §8.3.12: the eight fields, `MAGP-AUTH-v2`, the
|
|
204
|
+
jurisdiction) and **echoes** `jurisdiction` in its result; without it, the v1 message exactly as before and no echo.
|
|
205
|
+
A malformed value is `DAEMON_MALFORMED_REQUEST` and nothing is signed. The echo is how a client tells this daemon
|
|
206
|
+
from an older one, which ignores the field and would sign v1 for a request that is sent with it:
|
|
207
|
+
`@metamynd/agentsafe-guard` ≥ 0.16.0 and `metamynd-client` ≥ 0.6.0 refuse such a daemon
|
|
208
|
+
(`JURISDICTION_SIGNING_UNSUPPORTED`) rather than send a request the gate would reject. At the gate, a registered
|
|
209
|
+
payee's country wins over the signed one (`JURISDICTION_MISMATCH`); the other refusals are `JURISDICTION_REQUIRED`
|
|
210
|
+
and `JURISDICTION_NOT_ALLOWED`.
|
|
211
|
+
|
|
212
|
+
## Context signature over the request as sent (`sign-envelope`, since 0.19.0)
|
|
213
|
+
|
|
214
|
+
`sign-envelope` hashes the envelope (MAGP §8.3.13) over the fields **as the request sends them**: an omitted
|
|
215
|
+
`currency` stays omitted (0.18.0 and earlier hashed it as `USD`) and an omitted `amount` is signed (they refused it), so
|
|
216
|
+
the signature matches the gate's `envelopeHashFor` for a non-financial request too. agentsafe-guard ≥ 0.17.0 signs the
|
|
217
|
+
context by default and checks the daemon's signature against the hash it sends: with an older daemon such a request is
|
|
218
|
+
refused `CONTEXT_SIGNING_UNSUPPORTED` before it is sent (upgrade the daemon, or `signContext: false`).
|
|
219
|
+
|
|
200
220
|
## Hardening tiers — what a real host actually confirms (T3, T5)
|
|
201
221
|
|
|
202
222
|
`daemon-hardening.smoke.mjs` checks the tier directives **from outside** the process, against the
|
package/daemon-client.mjs
CHANGED
|
@@ -45,17 +45,37 @@ export function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000
|
|
|
45
45
|
return new Promise((resolve, reject) => {
|
|
46
46
|
const deadline = Date.now() + connectTimeoutMs;
|
|
47
47
|
let settled = false;
|
|
48
|
+
let inFlight = null;
|
|
48
49
|
const overallTimer = setTimeout(() => {
|
|
49
50
|
settled = true;
|
|
51
|
+
// Close the attempt still in flight. Left open, a connect that completes AFTER this timeout
|
|
52
|
+
// stayed connected, silently, holding the daemon's pipe instance — on the ONE-SHOT admin
|
|
53
|
+
// socket that is its only connection, so the caller's retry found the admin socket gone.
|
|
54
|
+
inFlight?.destroy();
|
|
50
55
|
reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: timed out after ${connectTimeoutMs}ms`), { code: 'DAEMON_UNREACHABLE' }));
|
|
51
56
|
}, connectTimeoutMs);
|
|
52
57
|
function attempt() {
|
|
53
58
|
if (settled) return;
|
|
54
59
|
const sock = net.connect(toPlatformSocketPath(socketPath));
|
|
60
|
+
inFlight = sock;
|
|
55
61
|
const requestId = crypto.randomUUID();
|
|
56
62
|
let buf = '';
|
|
57
63
|
const cleanup = () => sock.destroy();
|
|
64
|
+
let attemptOver = false; // this attempt already errored — its 'close' is not news
|
|
65
|
+
// The daemon side closing the connection before a full response line arrived used to go
|
|
66
|
+
// unnoticed (no 'close' handler), so the call sat until the overall timeout and then blamed
|
|
67
|
+
// reachability. Fail at once instead, and do NOT retry: a close with no response does not mean
|
|
68
|
+
// the request was not handled (windows-secure-pipe.mjs's one-shot close once dropped a
|
|
69
|
+
// generate-key response AFTER the daemon had rekeyed), so re-sending could run it twice.
|
|
70
|
+
sock.once('close', () => {
|
|
71
|
+
if (settled || attemptOver) return;
|
|
72
|
+
attemptOver = true;
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(overallTimer);
|
|
75
|
+
reject(Object.assign(new Error(`agentsafe-signer daemon at ${socketPath} closed the connection before responding (the request may or may not have been handled)`), { code: 'DAEMON_CONNECTION_CLOSED' }));
|
|
76
|
+
});
|
|
58
77
|
sock.once('error', (err) => {
|
|
78
|
+
attemptOver = true;
|
|
59
79
|
cleanup();
|
|
60
80
|
if (settled) return;
|
|
61
81
|
if (err.code === 'ENOENT' && Date.now() < deadline) {
|
|
@@ -67,7 +87,10 @@ export function daemonRequest(socketPath, op, params, { connectTimeoutMs = 3000
|
|
|
67
87
|
reject(Object.assign(new Error(`agentsafe-signer daemon unreachable at ${socketPath}: ${err.message}`), { code: 'DAEMON_UNREACHABLE' }));
|
|
68
88
|
});
|
|
69
89
|
sock.once('connect', () => {
|
|
70
|
-
if (settled)
|
|
90
|
+
if (settled) {
|
|
91
|
+
sock.destroy();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
71
94
|
sock.write(JSON.stringify({ protocolVersion: PROTOCOL_VERSION, requestId, op, params }) + '\n');
|
|
72
95
|
});
|
|
73
96
|
sock.on('data', (chunk) => {
|
package/daemon.mjs
CHANGED
|
@@ -300,12 +300,20 @@ export class SignerDaemon {
|
|
|
300
300
|
#handleSignAuthorize(params = {}) {
|
|
301
301
|
this.#assertRateLimit('sign-authorize');
|
|
302
302
|
this.#validateCoreFields(params);
|
|
303
|
-
const { agentDid, action, amount, currency = 'USD', merchant, resource, nonce, issuedAt } = params;
|
|
303
|
+
const { agentDid, action, amount, currency = 'USD', merchant, resource, nonce, issuedAt, jurisdiction } = params;
|
|
304
|
+
// The signed jurisdiction (spec §8.3.12): present → the v2 message, absent → v1 exactly as before. Signed as the
|
|
305
|
+
// literal value given (the client normalises it and sends the same string), so it must be two ASCII letters — the
|
|
306
|
+
// shape the gate accepts. Echoed back so a client can tell this daemon from one that predates the field (which
|
|
307
|
+
// ignores it and signs v1): signed and sent must never differ.
|
|
308
|
+
if (jurisdiction !== undefined && (typeof jurisdiction !== 'string' || !/^[A-Za-z]{2}$/.test(jurisdiction))) {
|
|
309
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'jurisdiction must be an ISO 3166-1 alpha-2 code');
|
|
310
|
+
}
|
|
304
311
|
// `resource` is the 8th canonical field (canonical.ts) — dropping it here (found live: it
|
|
305
312
|
// was) means every request signed via this daemon silently omits it from the signature,
|
|
306
313
|
// so the real gate's 8-field reconstruction never matches for any resource-bearing call.
|
|
307
|
-
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, resource, nonce, issuedAt });
|
|
308
|
-
|
|
314
|
+
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, resource, nonce, issuedAt, ...(jurisdiction === undefined ? {} : { jurisdiction }) });
|
|
315
|
+
const signature = this.#sign(Buffer.from(message, 'utf8'));
|
|
316
|
+
return jurisdiction === undefined ? { signature } : { signature, jurisdiction };
|
|
309
317
|
}
|
|
310
318
|
|
|
311
319
|
/**
|
|
@@ -410,8 +418,11 @@ export class SignerDaemon {
|
|
|
410
418
|
|
|
411
419
|
#handleSignEnvelope(params = {}) {
|
|
412
420
|
this.#assertRateLimit('sign-envelope');
|
|
413
|
-
|
|
414
|
-
|
|
421
|
+
// The envelope hash is over the request AS SENT (MAGP §8.3.13): an omitted amount/currency stays omitted — no 0/'USD'
|
|
422
|
+
// default here (that default belongs to the authorize message only). Before signer 0.19.0 this refused an omitted
|
|
423
|
+
// amount and hashed an omitted currency as 'USD', so the gate refused the context signature.
|
|
424
|
+
this.#validateCoreFields({ ...params, amount: params.amount ?? 0 });
|
|
425
|
+
const { agentDid, action, amount, currency, merchant, itinerary, trace, materiality, nonce, issuedAt } = params;
|
|
415
426
|
const hash = envelopeHashFor({ agentDid, action, amount, currency, merchant, itinerary, trace, materiality, nonce, issuedAt, signature: '' });
|
|
416
427
|
return { envelopeSignature: this.#sign(Buffer.from(hash, 'utf8')) };
|
|
417
428
|
}
|
package/migrate.mjs
CHANGED
|
@@ -30,6 +30,13 @@ import path from 'node:path';
|
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
31
|
import { parseArgs, daemonRequest } from './daemon-client.mjs';
|
|
32
32
|
|
|
33
|
+
// The admin socket is ONE-SHOT: its first connection is its only one. On Windows each pipe is
|
|
34
|
+
// built by a relay process (windows-secure-pipe.mjs, with bounded spawn retries), which under load
|
|
35
|
+
// can take longer than daemonRequest's 3s default — and giving up early does not just fail this
|
|
36
|
+
// call, it can spend the single connection, so a rerun finds the admin socket closed. Wait longer
|
|
37
|
+
// here (still bounded) rather than burn the window; the signing socket keeps the default.
|
|
38
|
+
const ADMIN_CONNECT_TIMEOUT_MS = 15_000;
|
|
39
|
+
|
|
33
40
|
async function apiCall(apiUrl, method, urlPath, authToken, body) {
|
|
34
41
|
const res = await fetch(new URL(urlPath, apiUrl), {
|
|
35
42
|
method,
|
|
@@ -53,8 +60,8 @@ async function apiCall(apiUrl, method, urlPath, authToken, body) {
|
|
|
53
60
|
* than assumed impossible). Returns the new DID and the exact restart command, rather than
|
|
54
61
|
* hot-swapping the running daemon's identity itself — see this file's own header on why.
|
|
55
62
|
*/
|
|
56
|
-
export async function runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId }) {
|
|
57
|
-
const { publicKeyHex } = await daemonRequest(adminSocketPath, 'generate-key', { allowRekey: true });
|
|
63
|
+
export async function runMigrate({ stateDir, socketPath, adminSocketPath, apiUrl, authToken, ref, networkIdentityId, adminConnectTimeoutMs = ADMIN_CONNECT_TIMEOUT_MS }) {
|
|
64
|
+
const { publicKeyHex } = await daemonRequest(adminSocketPath, 'generate-key', { allowRekey: true }, { connectTimeoutMs: adminConnectTimeoutMs });
|
|
58
65
|
|
|
59
66
|
const rotated = await apiCall(
|
|
60
67
|
apiUrl,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-signer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./daemon.mjs",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"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
|
+
"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-jurisdiction.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",
|
|
33
33
|
"start": "node cli.mjs"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
package/policy-core.mjs
CHANGED
|
@@ -411,6 +411,10 @@ function requiredContextFor(predicates) {
|
|
|
411
411
|
|
|
412
412
|
// src/policy-core/standards-rules.ts
|
|
413
413
|
var CONTEXT_UNVERIFIABLE = "CONTEXT_UNVERIFIABLE";
|
|
414
|
+
var JURISDICTION_ATOM = "jurisdiction-not-allowed";
|
|
415
|
+
function documentEnforcesJurisdiction(doc) {
|
|
416
|
+
return (doc?.molecules ?? []).some((m) => m?.decision !== "observe" && (m?.atoms ?? []).some((a) => a?.predicate === JURISDICTION_ATOM));
|
|
417
|
+
}
|
|
414
418
|
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5, decommission: 6 };
|
|
415
419
|
function atomFires(atom, ctx) {
|
|
416
420
|
const pred = ATOM_REGISTRY[atom.predicate];
|
|
@@ -489,7 +493,7 @@ function evaluateBoundStandards(standards, ctx) {
|
|
|
489
493
|
const r = evaluateStandardRules(s.document?.molecules, ctx, s.standardKey);
|
|
490
494
|
if (PRECEDENCE[r.decision] > PRECEDENCE[best.decision]) best = r;
|
|
491
495
|
}
|
|
492
|
-
return best;
|
|
496
|
+
return standards.some((s) => documentEnforcesJurisdiction(s.document)) ? { ...best, jurisdictionRequired: true } : best;
|
|
493
497
|
}
|
|
494
498
|
function configValueValid(field, value) {
|
|
495
499
|
switch (field.type) {
|
|
@@ -587,12 +591,17 @@ var REASON_BY_OPERAND = {
|
|
|
587
591
|
"mm:counterparty": "COUNTERPARTY_NOT_ALLOWED"
|
|
588
592
|
};
|
|
589
593
|
var AMOUNT_OPERANDS = /* @__PURE__ */ new Set(["mm:payAmount", "mm:cumulativeSpend"]);
|
|
594
|
+
var JURISDICTION_OPERANDS = /* @__PURE__ */ new Set(["mm:jurisdiction", "jurisdiction"]);
|
|
590
595
|
function reasonFor(constraint, req) {
|
|
591
596
|
if (!constraint) return "CONSTRAINT_FAILED";
|
|
592
597
|
const { leftOperand } = constraint;
|
|
593
598
|
if (AMOUNT_OPERANDS.has(leftOperand) && !Object.prototype.hasOwnProperty.call(req.values, leftOperand)) {
|
|
594
599
|
return "AMOUNT_NOT_DETERMINABLE";
|
|
595
600
|
}
|
|
601
|
+
if (JURISDICTION_OPERANDS.has(leftOperand)) {
|
|
602
|
+
const v = req.values[leftOperand];
|
|
603
|
+
return v === void 0 || v === null || v === "" ? "JURISDICTION_REQUIRED" : "JURISDICTION_NOT_ALLOWED";
|
|
604
|
+
}
|
|
596
605
|
return REASON_BY_OPERAND[leftOperand] ?? `CONSTRAINT_FAILED:${leftOperand}`;
|
|
597
606
|
}
|
|
598
607
|
function constraintSatisfied(c, req, strict) {
|
|
@@ -703,11 +712,14 @@ function evaluate(input) {
|
|
|
703
712
|
}
|
|
704
713
|
|
|
705
714
|
// src/policy-core/canonical.ts
|
|
715
|
+
var AUTH_MESSAGE_V2_TAG = "MAGP-AUTH-v2";
|
|
706
716
|
function escapeField(v) {
|
|
707
717
|
return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
708
718
|
}
|
|
709
719
|
function buildAuthMessage(f) {
|
|
710
|
-
|
|
720
|
+
const v1 = [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.resource ?? "", f.nonce, f.issuedAt];
|
|
721
|
+
const fields = f.jurisdiction === void 0 || f.jurisdiction === null ? v1 : [...v1, AUTH_MESSAGE_V2_TAG, f.jurisdiction];
|
|
722
|
+
return fields.map((v) => escapeField(String(v))).join("|");
|
|
711
723
|
}
|
|
712
724
|
function buildLegacyAuthMessageV1(f) {
|
|
713
725
|
return [f.agentDid, f.action, f.amount, f.currency, f.merchant ?? "", f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
@@ -768,8 +780,10 @@ export {
|
|
|
768
780
|
ATOM_DEFAULT_REQUIRED_CONTEXT,
|
|
769
781
|
ATOM_REGISTRY,
|
|
770
782
|
ATOM_SPECS,
|
|
783
|
+
AUTH_MESSAGE_V2_TAG,
|
|
771
784
|
CATALOGUED_ATOMS,
|
|
772
785
|
CONTEXT_UNVERIFIABLE,
|
|
786
|
+
JURISDICTION_ATOM,
|
|
773
787
|
MODES_BY_RANK,
|
|
774
788
|
MODE_RANK,
|
|
775
789
|
PROVENANCE_KEY,
|
|
@@ -789,6 +803,7 @@ export {
|
|
|
789
803
|
buildRuleContext,
|
|
790
804
|
canAuthorize,
|
|
791
805
|
contextFieldProblem,
|
|
806
|
+
documentEnforcesJurisdiction,
|
|
792
807
|
evaluate,
|
|
793
808
|
evaluateBoundStandards,
|
|
794
809
|
evaluateMandate,
|
package/windows-secure-pipe.mjs
CHANGED
|
@@ -36,6 +36,9 @@ const SPAWN_TIMEOUT_MS = 1500;
|
|
|
36
36
|
// faster from the common case — the observed contention tends to clear within one or two
|
|
37
37
|
// retries — while keeping a similar worst-case total bound.
|
|
38
38
|
const MAX_SPAWN_ATTEMPTS = 8;
|
|
39
|
+
// How long createSecurePipeOnce's close() lets a relay with a connected client drain its stdin to
|
|
40
|
+
// the pipe and exit on its own before killing it anyway.
|
|
41
|
+
const GRACEFUL_CLOSE_MS = 2000;
|
|
39
42
|
|
|
40
43
|
function relayScript(pipeName, maxInstances) {
|
|
41
44
|
return [
|
|
@@ -264,6 +267,7 @@ export function createSecurePipeOnce(pipeName, onConnection, { timeoutMs = 60_00
|
|
|
264
267
|
let closed = false; // true once emitter.close() has been called, OR a terminal finish/fail happened
|
|
265
268
|
let everReady = false; // true once 'aclVerified' has fired at least once, across any attempt
|
|
266
269
|
let child = null;
|
|
270
|
+
let connectedChild = null; // the relay whose client connected (see emitter.close)
|
|
267
271
|
let attempts = 0;
|
|
268
272
|
const timer = setTimeout(() => emitter.close(), timeoutMs);
|
|
269
273
|
emitter.closed = false; // readable synchronously by a caller that only gets `emitter` AFTER it
|
|
@@ -319,7 +323,10 @@ export function createSecurePipeOnce(pipeName, onConnection, { timeoutMs = 60_00
|
|
|
319
323
|
clearTimeout(spawnTimer);
|
|
320
324
|
emitter.emit('aclVerified', n);
|
|
321
325
|
},
|
|
322
|
-
onConnected: () =>
|
|
326
|
+
onConnected: () => {
|
|
327
|
+
connectedChild = child;
|
|
328
|
+
onConnection(socketAdapter(child));
|
|
329
|
+
},
|
|
323
330
|
});
|
|
324
331
|
|
|
325
332
|
const onExit = () => {
|
|
@@ -356,6 +363,29 @@ export function createSecurePipeOnce(pipeName, onConnection, { timeoutMs = 60_00
|
|
|
356
363
|
emitter.close = () => {
|
|
357
364
|
if (closed) return;
|
|
358
365
|
closed = true;
|
|
366
|
+
if (connectedChild === child) {
|
|
367
|
+
// A client is connected, and the caller (daemon.mjs's startAdminServer) closes right after
|
|
368
|
+
// writing that client's response into the relay's stdin. Killing the relay here raced its
|
|
369
|
+
// stdin->pipe copy: the response was often lost, so generate-key REKEYED the daemon while the
|
|
370
|
+
// client saw only a dropped connection (found chasing migrate.smoke's flake: CONNECTED, then
|
|
371
|
+
// "admin socket closed" 17ms later, no response). End stdin instead — the relay copies what is
|
|
372
|
+
// left, its CopyToAsync completes, it disposes the pipe and exits on its own, and that exit is
|
|
373
|
+
// what finish()es. The kill stays as a bounded fallback for a relay that does not exit.
|
|
374
|
+
try {
|
|
375
|
+
child.stdin.end();
|
|
376
|
+
} catch {
|
|
377
|
+
/* stdin already gone — the fallback below still bounds it */
|
|
378
|
+
}
|
|
379
|
+
const fallback = setTimeout(() => {
|
|
380
|
+
try {
|
|
381
|
+
child.kill();
|
|
382
|
+
} catch {
|
|
383
|
+
/* already exited */
|
|
384
|
+
}
|
|
385
|
+
}, GRACEFUL_CLOSE_MS);
|
|
386
|
+
fallback.unref?.();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
359
389
|
try {
|
|
360
390
|
child.kill();
|
|
361
391
|
} catch {
|