@metamynd/agentsafe-signer 0.14.0 → 0.15.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 +10 -1
- package/daemon.mjs +38 -2
- package/package.json +2 -1
- package/payload-binding.mjs +121 -0
- package/policy-core.mjs +10 -0
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
|
|
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.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -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",
|
|
@@ -791,6 +800,7 @@ export {
|
|
|
791
800
|
remainingBudget,
|
|
792
801
|
requiredContextFor,
|
|
793
802
|
requiredContextOf,
|
|
803
|
+
requiresPayloadBindingFor,
|
|
794
804
|
riskFloorFor,
|
|
795
805
|
sumEventField,
|
|
796
806
|
validateMolecules
|