@metamynd/agentsafe-signer 0.14.0 → 0.15.3

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 CHANGED
@@ -28,7 +28,7 @@ tells you what to set. Opens the signing socket (always on) and the admin socket
28
28
  ## Status
29
29
 
30
30
  **Implemented and tested:**
31
- - The full eight-operation signing-socket protocol (`sign-authorize`, `sign-envelope`,
31
+ - The full nine-operation signing-socket protocol (`sign-authorize`, `sign-envelope`, `sign-payload`,
32
32
  `sign-handshake-nonce`, `sign-key-control-challenge`, `sign-log-checkpoint`,
33
33
  `sign-local-decision`, `get-identity`, `ping`) and the two-operation admin socket
34
34
  (`generate-key`, `status`), matching the design doc's closed operation set exactly — no generic
@@ -146,6 +146,15 @@ tells you what to set. Opens the signing socket (always on) and the admin socket
146
146
  same as `sign-authorize`/`sign-envelope` (a service-role daemon never evaluates a mandate
147
147
  locally). `daemon-protocol.smoke.mjs`, `daemon-envelope-parity.smoke.mjs`.
148
148
 
149
+ - **`sign-payload` (0.15.0) — payload binding without giving up custody.** An agent-role daemon signs the
150
+ digest of the COMPLETE payload an agent will execute (MAGP 8.3.9), and — when given an `authorizationId` — the
151
+ late binding of a hold that already exists (8.3.11, a reviewer's MODIFY mints one with no digest). Like every op
152
+ here it builds the message itself from validated fields and never signs caller-supplied bytes: the digest must be
153
+ `sha256:` + 64 lower-case hex, the authorization id a UUID, and the message starts with a domain prefix
154
+ (`MAGP-PAYLOAD-v1` / `MAGP-PAYLOAD-REBIND-v1`) that nothing else this daemon signs starts with, so the signature
155
+ cannot be replayed as an authorize, envelope, handshake or challenge. Before this a daemon-backed guard asked to
156
+ bind a payload failed closed. `daemon-protocol.smoke.mjs`.
157
+
149
158
  **Deliberately not yet implemented — real gaps, not oversights:**
150
159
  - **The generated systemd unit has never started a daemon.** Running it for real on Ubuntu 24.04
151
160
  LTS / systemd 255 (the first time this was tried on any Linux host) found five defects in a
package/daemon.mjs CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import crypto from 'node:crypto';
9
9
  import { buildAuthMessage, buildCheckpointAnchorMessage, buildLocalDecisionMessage } from './policy-core.mjs';
10
10
  import { envelopeHashFor } from './governance-envelope.mjs';
11
+ import { buildPayloadBindingMessage, buildPayloadRebindMessage, isPayloadDigest } from './payload-binding.mjs';
11
12
  import { KeyStore, KeyStoreError } from './keystore.mjs';
12
13
  import { createSecurePipePool, createSecurePipeOnce } from './windows-secure-pipe.mjs';
13
14
  import { takeCheckpoint } from './log-checkpoint.mjs';
@@ -17,7 +18,7 @@ export const MAX_LINE_BYTES = 64 * 1024;
17
18
  export const FRESHNESS_MS = 5 * 60 * 1000;
18
19
  export const CLOCK_SKEW_MS = 30 * 1000;
19
20
 
20
- const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']);
21
+ const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']);
21
22
 
22
23
  // sign-log-checkpoint is in BOTH role sets, unlike every other sign-* op: it authenticates the
23
24
  // daemon's own log-tamper-evidence checkpoint (T11), which every daemon keeps regardless of
@@ -27,13 +28,15 @@ const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-handsh
27
28
  // (agentsafe-guard.mjs's reportLocalDecision()); a service-role daemon never evaluates a
28
29
  // mandate locally, so it has nothing to report.
29
30
  const SIGNING_OPS_BY_ROLE = {
30
- agent: new Set(['sign-authorize', 'sign-envelope', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
31
+ agent: new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
31
32
  service: new Set(['sign-handshake-nonce', 'sign-log-checkpoint', 'get-identity', 'ping']),
32
33
  };
33
34
 
34
35
  const DEFAULT_RATE_LIMITS = {
35
36
  'sign-authorize': { max: 20, windowMs: 10_000 },
36
37
  'sign-envelope': { max: 20, windowMs: 10_000 },
38
+ // One per bound authorize, plus one per late binding (a modified hold): never more than sign-authorize itself.
39
+ 'sign-payload': { max: 20, windowMs: 10_000 },
37
40
  'sign-handshake-nonce': { max: 10, windowMs: 60_000 },
38
41
  'sign-key-control-challenge': { max: 3, windowMs: 60 * 60 * 1000 },
39
42
  // Local decisions are reported for every locally-decided block/escalate/non-value-allow, not
@@ -238,6 +241,8 @@ export class SignerDaemon {
238
241
  return this.#handleSignAuthorize(params);
239
242
  case 'sign-envelope':
240
243
  return this.#handleSignEnvelope(params);
244
+ case 'sign-payload':
245
+ return this.#handleSignPayload(params);
241
246
  case 'sign-handshake-nonce':
242
247
  return this.#handleSignHandshakeNonce(params);
243
248
  case 'sign-key-control-challenge':
@@ -309,6 +314,37 @@ export class SignerDaemon {
309
314
  return { signature: this.#sign(Buffer.from(message, 'utf8')) };
310
315
  }
311
316
 
317
+ /**
318
+ * Payload binding (MAGP 8.3.9 / 8.3.11): signs the digest of the COMPLETE payload an agent will execute, bound to ONE
319
+ * authorization. Like every other op here the daemon builds the message ITSELF from structured, validated fields; it never
320
+ * signs bytes a caller hands it. The digest is checked to be `sha256:` + 64 lower-case hex, so the only free-form thing in the
321
+ * message is a fixed-shape hash under a fixed domain prefix (MAGP-PAYLOAD-v1 / MAGP-PAYLOAD-REBIND-v1) that no other message
322
+ * this daemon signs starts with — a signature made here cannot be replayed as an authorize, envelope, handshake or challenge.
323
+ *
324
+ * Without `authorizationId` it signs the authorize-time binding (named by the request's own nonce and issuedAt). With one it
325
+ * signs the LATE binding for a hold that already exists (a reviewer's MODIFY mints one with no digest), which names that
326
+ * authorization so the digest cannot be lifted onto another. The id must be a UUID, which is what the issuer mints.
327
+ */
328
+ #handleSignPayload(params = {}) {
329
+ this.#assertRateLimit('sign-payload');
330
+ const { agentDid, action, nonce, issuedAt, payloadDigest, authorizationId } = params;
331
+ if (typeof agentDid !== 'string' || typeof action !== 'string' || typeof nonce !== 'string' || typeof issuedAt !== 'string') {
332
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'missing required field');
333
+ }
334
+ if (action.length === 0 || action.includes('|')) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid action');
335
+ if (nonce.length === 0 || nonce.length > 128) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid nonce');
336
+ if (!isPayloadDigest(payloadDigest)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'payloadDigest must be sha256: followed by 64 lower-case hex');
337
+ if (authorizationId !== undefined && (typeof authorizationId !== 'string' || !UUID_RE.test(authorizationId))) {
338
+ throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'authorizationId must be a UUID');
339
+ }
340
+ this.#assertIdentity(agentDid);
341
+ if (!freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
342
+ const message = authorizationId === undefined
343
+ ? buildPayloadBindingMessage({ agentDid, action, nonce, issuedAt, payloadDigest })
344
+ : buildPayloadRebindMessage({ agentDid, action, authorizationId, nonce, issuedAt, payloadDigest });
345
+ return { signature: this.#sign(Buffer.from(message, 'utf8')) };
346
+ }
347
+
312
348
  #handleSignEnvelope(params = {}) {
313
349
  this.#assertRateLimit('sign-envelope');
314
350
  this.#validateCoreFields(params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamynd/agentsafe-signer",
3
- "version": "0.14.0",
3
+ "version": "0.15.3",
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",
@@ -24,6 +24,7 @@
24
24
  "cli.mjs",
25
25
  "policy-core.mjs",
26
26
  "governance-envelope.mjs",
27
+ "payload-binding.mjs",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
@@ -0,0 +1,121 @@
1
+ // GENERATED from backend/src/features/magp/payload-binding.ts - do not edit. Regenerate: npm run build:signer-core
2
+
3
+ // src/features/magp/payload-binding.ts
4
+ import { createHash } from "node:crypto";
5
+
6
+ // src/policy-core/canonical.ts
7
+ function escapeField(v) {
8
+ return v.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
9
+ }
10
+
11
+ // src/features/magp/payload-binding.ts
12
+ var PAYLOAD_BINDING_PREFIX = "MAGP-PAYLOAD-v1";
13
+ var PAYLOAD_REBIND_PREFIX = "MAGP-PAYLOAD-REBIND-v1";
14
+ var PAYLOAD_DIGEST_PREFIX = "sha256:";
15
+ var PAYLOAD_DIGEST_HEADER = "x-magp-payload-digest";
16
+ var MAX_CANONICAL_PAYLOAD_BYTES = 256 * 1024;
17
+ var MAX_DEPTH = 32;
18
+ var PayloadNotCanonicalizable = class extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "PayloadNotCanonicalizable";
22
+ }
23
+ };
24
+ function hasLoneSurrogate(s) {
25
+ for (let i = 0; i < s.length; i++) {
26
+ const c = s.charCodeAt(i);
27
+ if (c >= 55296 && c <= 56319) {
28
+ const next = s.charCodeAt(i + 1);
29
+ if (!(next >= 56320 && next <= 57343)) return true;
30
+ i++;
31
+ } else if (c >= 56320 && c <= 57343) {
32
+ return true;
33
+ }
34
+ }
35
+ return false;
36
+ }
37
+ function serialize(value, depth, path) {
38
+ if (depth > MAX_DEPTH) throw new PayloadNotCanonicalizable(`payload is nested deeper than ${MAX_DEPTH} levels at ${path}`);
39
+ if (value === null) return "null";
40
+ switch (typeof value) {
41
+ case "boolean":
42
+ return value ? "true" : "false";
43
+ case "number":
44
+ if (!Number.isFinite(value)) throw new PayloadNotCanonicalizable(`${path} is not a finite number`);
45
+ return JSON.stringify(value);
46
+ // ECMAScript Number::toString — what RFC 8785 specifies; -0 serialises as "0"
47
+ case "string":
48
+ if (hasLoneSurrogate(value)) throw new PayloadNotCanonicalizable(`${path} contains an unpaired surrogate`);
49
+ return JSON.stringify(value);
50
+ case "object": {
51
+ if (Array.isArray(value)) return `[${value.map((v, i) => serialize(v, depth + 1, `${path}[${i}]`)).join(",")}]`;
52
+ const proto = Object.getPrototypeOf(value);
53
+ if (proto !== Object.prototype && proto !== null) throw new PayloadNotCanonicalizable(`${path} is not a plain JSON object`);
54
+ const obj = value;
55
+ const keys = Object.keys(obj).sort();
56
+ const parts = keys.map((k) => {
57
+ if (hasLoneSurrogate(k)) throw new PayloadNotCanonicalizable(`${path} has a key with an unpaired surrogate`);
58
+ return `${JSON.stringify(k)}:${serialize(obj[k], depth + 1, `${path}.${k}`)}`;
59
+ });
60
+ return `{${parts.join(",")}}`;
61
+ }
62
+ default:
63
+ throw new PayloadNotCanonicalizable(`${path} is a ${typeof value}, which JSON cannot represent`);
64
+ }
65
+ }
66
+ function canonicalPayload(value) {
67
+ const text = serialize(value, 0, "$");
68
+ if (Buffer.byteLength(text, "utf8") > MAX_CANONICAL_PAYLOAD_BYTES) {
69
+ throw new PayloadNotCanonicalizable(`canonical payload exceeds ${MAX_CANONICAL_PAYLOAD_BYTES} bytes`);
70
+ }
71
+ return text;
72
+ }
73
+ function payloadDigestOf(value) {
74
+ return PAYLOAD_DIGEST_PREFIX + createHash("sha256").update(canonicalPayload(value), "utf8").digest("hex");
75
+ }
76
+ var DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
77
+ function isPayloadDigest(value) {
78
+ return typeof value === "string" && DIGEST_RE.test(value);
79
+ }
80
+ function toWireJson(value) {
81
+ const text = JSON.stringify(value);
82
+ if (text === void 0) throw new PayloadNotCanonicalizable("payload is not JSON-serialisable");
83
+ return JSON.parse(text);
84
+ }
85
+ function buildPayloadBindingMessage(input) {
86
+ return [PAYLOAD_BINDING_PREFIX, input.agentDid, input.action, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
87
+ }
88
+ function buildPayloadRebindMessage(input) {
89
+ return [PAYLOAD_REBIND_PREFIX, input.agentDid, input.action, input.authorizationId, input.nonce, input.issuedAt, input.payloadDigest].map((f) => escapeField(String(f))).join("|");
90
+ }
91
+ function decideClaimPayload(stored, presented) {
92
+ if (stored) {
93
+ if (!presented) return { ok: false, reasonCode: "PAYLOAD_DIGEST_REQUIRED" };
94
+ return presented === stored ? { ok: true } : { ok: false, reasonCode: "PAYLOAD_DIGEST_MISMATCH" };
95
+ }
96
+ return presented ? { ok: false, reasonCode: "PAYLOAD_NOT_BOUND_AT_AUTHORIZE" } : { ok: true };
97
+ }
98
+ function claimDigestField(payloadDigest) {
99
+ return `payload=${payloadDigest}`;
100
+ }
101
+ function claimEvidenceRefs(idempotencyRef, payloadDigest) {
102
+ const refs = [...idempotencyRef ? [idempotencyRef] : [], ...payloadDigest ? [`payload:${payloadDigest}`] : []];
103
+ return refs.length ? refs : null;
104
+ }
105
+ export {
106
+ MAX_CANONICAL_PAYLOAD_BYTES,
107
+ PAYLOAD_BINDING_PREFIX,
108
+ PAYLOAD_DIGEST_HEADER,
109
+ PAYLOAD_DIGEST_PREFIX,
110
+ PAYLOAD_REBIND_PREFIX,
111
+ PayloadNotCanonicalizable,
112
+ buildPayloadBindingMessage,
113
+ buildPayloadRebindMessage,
114
+ canonicalPayload,
115
+ claimDigestField,
116
+ claimEvidenceRefs,
117
+ decideClaimPayload,
118
+ isPayloadDigest,
119
+ payloadDigestOf,
120
+ toWireJson
121
+ };
package/policy-core.mjs CHANGED
@@ -43,6 +43,15 @@ function riskFloorFor(mandate, target) {
43
43
  }
44
44
  return floor;
45
45
  }
46
+ function requiresPayloadBindingFor(mandate, target) {
47
+ if (!mandate) return false;
48
+ for (const p of mandate.permission ?? []) {
49
+ if (!p || typeof p !== "object") continue;
50
+ if ((p.target ?? mandate.target) !== target) continue;
51
+ if (p.requirePayloadBinding === true) return true;
52
+ }
53
+ return false;
54
+ }
46
55
  var FIELD_KINDS = {
47
56
  riskLevel: "risk",
48
57
  consent: "boolean",
@@ -634,16 +643,24 @@ function evaluateMandate(mandate, req) {
634
643
  };
635
644
  }
636
645
  for (const p of perms) {
637
- const failing = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
638
- if (!failing) return { decision: "allow", reasonCode: "AUTHORIZED" };
646
+ const failing2 = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
647
+ if (!failing2) return { decision: "allow", reasonCode: "AUTHORIZED" };
639
648
  }
640
- const firstFail = (perms[0].constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
649
+ const failing = (perms[0].constraint ?? []).filter((c) => !constraintSatisfied(c, req, true));
650
+ const firstFail = failing[0];
651
+ const reported = reportedFailure(failing) ?? firstFail;
641
652
  return {
642
653
  decision: firstFail?.onFail ?? "block",
643
- reasonCode: reasonFor(firstFail, req),
644
- matched: { kind: "permission", target: perms[0].target, constraint: firstFail }
654
+ reasonCode: reasonFor(reported, req),
655
+ matched: { kind: "permission", target: perms[0].target, constraint: reported }
645
656
  };
646
657
  }
658
+ function reportedFailure(failing) {
659
+ const first = failing[0];
660
+ if (!first || !AMOUNT_OPERANDS.has(first.leftOperand)) return void 0;
661
+ const decision = first.onFail ?? "block";
662
+ return failing.find((c) => !AMOUNT_OPERANDS.has(c.leftOperand) && (c.onFail ?? "block") === decision);
663
+ }
647
664
  function remainingBudget(b) {
648
665
  return Math.max(0, b.cap - b.spent - b.held);
649
666
  }
@@ -791,6 +808,7 @@ export {
791
808
  remainingBudget,
792
809
  requiredContextFor,
793
810
  requiredContextOf,
811
+ requiresPayloadBindingFor,
794
812
  riskFloorFor,
795
813
  sumEventField,
796
814
  validateMolecules
@@ -26,6 +26,13 @@ function defaultNodePath() {
26
26
  return process.execPath || '/usr/bin/node';
27
27
  }
28
28
 
29
+ /** One ExecStart= word. systemd splits the line on whitespace, so a path with a space in it (a node under
30
+ * `/opt/Node JS/`, or `C:\Program Files\nodejs\node.exe` when the unit is generated on Windows) became two words and
31
+ * the unit failed at exec. Double quotes are systemd's own quoting, with `\` and `"` escaped inside them. */
32
+ function systemdArg(value) {
33
+ return /\s/.test(value) ? `"${value.replace(/(["\\])/g, '\\$1')}"` : value;
34
+ }
35
+
29
36
  function assertTier(tier) {
30
37
  if (tier !== 1 && tier !== 2) throw new Error(`tier must be 1 or 2, got ${tier}`);
31
38
  }
@@ -74,7 +81,7 @@ export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePat
74
81
  const cliInvocationPath = tier === 1 && !isAbsolutePath ? `%h/${resolvedCliPath.replace(/^\.?\//, '')}` : resolvedCliPath;
75
82
  // %S is systemd's own state-directory root, so this lines up with StateDirectory= below rather
76
83
  // than hard-coding a path that has to be kept in sync with it by hand.
77
- const invocation = `${nodePath} ${cliInvocationPath} start --state-dir %S/agentsafe/%i --role ${role} --identity %i`;
84
+ const invocation = `${systemdArg(nodePath)} ${systemdArg(cliInvocationPath)} start --state-dir %S/agentsafe/%i --role ${role} --identity %i`;
78
85
  // No setpriv wrapper. An earlier pass wrapped this in `setpriv --dumpable 0 --` to deny ptrace
79
86
  // via PR_SET_DUMPABLE=0. That option does not exist: util-linux's setpriv has no --dumpable flag
80
87
  // (checked on util-linux 2.39.3 / Ubuntu 24.04 LTS — the string is absent from the binary