@metamynd/agentsafe-signer 0.19.2 → 0.20.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/daemon.mjs +34 -3
- package/package.json +1 -1
- package/policy-core.mjs +63 -0
package/daemon.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import net from 'node:net';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
|
-
import { buildAuthMessage, buildCheckpointAnchorMessage, buildLocalDecisionMessage } from './policy-core.mjs';
|
|
9
|
+
import { buildAgentSettleMessage, buildAuthMessage, buildCheckpointAnchorMessage, buildLocalDecisionMessage } from './policy-core.mjs';
|
|
10
10
|
import { envelopeHashFor } from './governance-envelope.mjs';
|
|
11
11
|
import { buildPayloadBindingMessage, buildPayloadRebindMessage, isPayloadDigest } from './payload-binding.mjs';
|
|
12
12
|
import { KeyStore, KeyStoreError } from './keystore.mjs';
|
|
@@ -18,7 +18,7 @@ export const MAX_LINE_BYTES = 64 * 1024;
|
|
|
18
18
|
export const FRESHNESS_MS = 5 * 60 * 1000;
|
|
19
19
|
export const CLOCK_SKEW_MS = 30 * 1000;
|
|
20
20
|
|
|
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', 'sign-service-call', 'get-identity', 'ping']);
|
|
21
|
+
const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-settle', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'sign-service-call', 'get-identity', 'ping']);
|
|
22
22
|
|
|
23
23
|
// sign-log-checkpoint is in BOTH role sets, unlike every other sign-* op: it authenticates the
|
|
24
24
|
// daemon's own log-tamper-evidence checkpoint (T11), which every daemon keeps regardless of
|
|
@@ -28,7 +28,7 @@ const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payloa
|
|
|
28
28
|
// (agentsafe-guard.mjs's reportLocalDecision()); a service-role daemon never evaluates a
|
|
29
29
|
// mandate locally, so it has nothing to report.
|
|
30
30
|
const SIGNING_OPS_BY_ROLE = {
|
|
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
|
+
agent: new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-settle', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
|
|
32
32
|
// sign-service-call is SERVICE-role only: a Service's own settlement-surface call (claim / capture / void / …,
|
|
33
33
|
// MAGP §8.7.6). An agent never claims or settles its own holds — an agent-role daemon signing one would let an
|
|
34
34
|
// agent impersonate the counterparty that executes for it.
|
|
@@ -40,6 +40,8 @@ const DEFAULT_RATE_LIMITS = {
|
|
|
40
40
|
'sign-envelope': { max: 20, windowMs: 10_000 },
|
|
41
41
|
// One per bound authorize, plus one per late binding (a modified hold): never more than sign-authorize itself.
|
|
42
42
|
'sign-payload': { max: 20, windowMs: 10_000 },
|
|
43
|
+
// An agent settling its OWN unclaimed hold (capture or void): at most one per authorize.
|
|
44
|
+
'sign-settle': { max: 20, windowMs: 10_000 },
|
|
43
45
|
'sign-handshake-nonce': { max: 10, windowMs: 60_000 },
|
|
44
46
|
'sign-key-control-challenge': { max: 3, windowMs: 60 * 60 * 1000 },
|
|
45
47
|
// Local decisions are reported for every locally-decided block/escalate/non-value-allow, not
|
|
@@ -56,6 +58,9 @@ const DEFAULT_RATE_LIMITS = {
|
|
|
56
58
|
'sign-service-call': { max: 120, windowMs: 10_000 },
|
|
57
59
|
};
|
|
58
60
|
|
|
61
|
+
// MAGP §8.7.4 — an agent's own unclaimed settlement: capture signs amountCharged, bookingRef, settlementTxHash; void, reason.
|
|
62
|
+
const SETTLE_FIELD_COUNTS = { capture: 3, void: 1 };
|
|
63
|
+
|
|
59
64
|
// MAGP §8.7.6 — the issuer's own layout (backend counterparty-auth.ts buildCounterpartyMessage):
|
|
60
65
|
// MAGP-SERVICE-v1 | action | authorizationId | ...fields | nonce | issuedAt (each field "\" and "|" escaped)
|
|
61
66
|
// The per-action field counts are the ones the issuer reconstructs the message from; anything else could
|
|
@@ -269,6 +274,8 @@ export class SignerDaemon {
|
|
|
269
274
|
return this.#handleSignEnvelope(params);
|
|
270
275
|
case 'sign-payload':
|
|
271
276
|
return this.#handleSignPayload(params);
|
|
277
|
+
case 'sign-settle':
|
|
278
|
+
return this.#handleSignSettle(params);
|
|
272
279
|
case 'sign-handshake-nonce':
|
|
273
280
|
return this.#handleSignHandshakeNonce(params);
|
|
274
281
|
case 'sign-key-control-challenge':
|
|
@@ -381,6 +388,30 @@ export class SignerDaemon {
|
|
|
381
388
|
return { signature: this.#sign(Buffer.from(message, 'utf8')) };
|
|
382
389
|
}
|
|
383
390
|
|
|
391
|
+
/**
|
|
392
|
+
* An agent settling its OWN hold that nobody has claimed (MAGP §8.7.4): capture it, or void it. The issuer accepts an
|
|
393
|
+
* unclaimed settlement only from that hold's agent, a counterparty the owner registered, or — for an open testnet owner —
|
|
394
|
+
* anyone. Built from validated fields under the fixed MAGP-SETTLE-v1 prefix, which no other message this daemon signs
|
|
395
|
+
* starts with; it names the agent, so it is never a counterparty's signature (sign-service-call, MAGP-SERVICE-v1), and an
|
|
396
|
+
* agent cannot use it to claim. Agent-role only.
|
|
397
|
+
*/
|
|
398
|
+
#handleSignSettle(params = {}) {
|
|
399
|
+
this.#assertRateLimit('sign-settle');
|
|
400
|
+
const { verb, agentDid, authorizationId, nonce, issuedAt, fields } = params;
|
|
401
|
+
if (!Object.hasOwn(SETTLE_FIELD_COUNTS, verb)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'verb must be capture|void');
|
|
402
|
+
if (typeof agentDid !== 'string' || typeof nonce !== 'string' || typeof issuedAt !== 'string') throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'missing required field');
|
|
403
|
+
if (nonce.length === 0 || nonce.length > 128) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'invalid nonce');
|
|
404
|
+
if (typeof authorizationId !== 'string' || !UUID_RE.test(authorizationId)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'authorizationId must be a UUID');
|
|
405
|
+
if (!Array.isArray(fields) || fields.length !== SETTLE_FIELD_COUNTS[verb] || !fields.every((v) => typeof v === 'string' && v.length <= 512)) {
|
|
406
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', `fields must be ${SETTLE_FIELD_COUNTS[verb]} string(s) for ${verb}`);
|
|
407
|
+
}
|
|
408
|
+
if (verb === 'capture' && !isFiniteNonNegative(Number(fields[0]))) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'amountCharged must be finite and non-negative');
|
|
409
|
+
this.#assertIdentity(agentDid);
|
|
410
|
+
if (!freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
|
|
411
|
+
const message = buildAgentSettleMessage({ verb, agentDid, authorizationId, nonce, issuedAt, fields });
|
|
412
|
+
return { signature: this.#sign(Buffer.from(message, 'utf8')) };
|
|
413
|
+
}
|
|
414
|
+
|
|
384
415
|
/**
|
|
385
416
|
* A Service's own settlement-surface call, signed as its identity (MAGP §8.7.6): the claim that makes it the one
|
|
386
417
|
* party allowed to settle the hold, and the capture / void / unknown / reconcile that follow. Without this a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-signer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|
package/policy-core.mjs
CHANGED
|
@@ -43,6 +43,57 @@ function riskFloorFor(mandate, target) {
|
|
|
43
43
|
}
|
|
44
44
|
return floor;
|
|
45
45
|
}
|
|
46
|
+
var DEFAULT_AMOUNT_SHARE_HIGH = 0.7;
|
|
47
|
+
function grantsFor(mandate, target) {
|
|
48
|
+
return (mandate?.permission ?? []).filter((p) => !!p && typeof p === "object" && (p.target ?? mandate?.target) === target);
|
|
49
|
+
}
|
|
50
|
+
function perTxnCapFor(mandate, target) {
|
|
51
|
+
let cap = null;
|
|
52
|
+
for (const p of grantsFor(mandate, target)) {
|
|
53
|
+
for (const c of p.constraint ?? []) {
|
|
54
|
+
if (c?.leftOperand !== "mm:payAmount") continue;
|
|
55
|
+
const n = Number(c.rightOperand);
|
|
56
|
+
if (Number.isFinite(n) && n > 0 && (cap === null || n < cap)) cap = n;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return cap;
|
|
60
|
+
}
|
|
61
|
+
function hasMerchantAllowList(mandate, target) {
|
|
62
|
+
return grantsFor(mandate, target).some((p) => (p.constraint ?? []).some((c) => c?.leftOperand === "mm:merchant"));
|
|
63
|
+
}
|
|
64
|
+
function riskSignalSettings(mandate, target) {
|
|
65
|
+
const grants = grantsFor(mandate, target);
|
|
66
|
+
if (grants.length === 0) return { amountShare: DEFAULT_AMOUNT_SHARE_HIGH, newMerchant: true };
|
|
67
|
+
let amountShare = null;
|
|
68
|
+
let newMerchant = false;
|
|
69
|
+
for (const p of grants) {
|
|
70
|
+
const s = p.riskSignals;
|
|
71
|
+
if (s === false) continue;
|
|
72
|
+
const cfg = s && typeof s === "object" ? s : {};
|
|
73
|
+
const share = cfg.amountShare === false ? null : cfg.amountShare === void 0 ? DEFAULT_AMOUNT_SHARE_HIGH : Number(cfg.amountShare);
|
|
74
|
+
if (share !== null && Number.isFinite(share) && share > 0 && share <= 1 && (amountShare === null || share < amountShare)) amountShare = share;
|
|
75
|
+
if (cfg.newMerchant !== false) newMerchant = true;
|
|
76
|
+
}
|
|
77
|
+
return { amountShare, newMerchant };
|
|
78
|
+
}
|
|
79
|
+
function riskSignalsFor(mandate, target, amount, opts = {}) {
|
|
80
|
+
const out = [];
|
|
81
|
+
const tier = riskFloorFor(mandate, target);
|
|
82
|
+
if (tier) out.push({ signal: "owner-tier", level: tier, detail: `the owner's risk tier for ${target}` });
|
|
83
|
+
const settings = riskSignalSettings(mandate, target);
|
|
84
|
+
const n = typeof amount === "number" ? amount : Number(amount);
|
|
85
|
+
const cap = perTxnCapFor(mandate, target);
|
|
86
|
+
if (settings.amountShare !== null && cap !== null && Number.isFinite(n) && n > 0 && n >= settings.amountShare * cap) {
|
|
87
|
+
out.push({ signal: "amount-share", level: "high", detail: `${Math.round(n / cap * 100)}% of the ${cap} per-transaction cap (review from ${Math.round(settings.amountShare * 100)}%)` });
|
|
88
|
+
}
|
|
89
|
+
if (opts.newMerchant === true && settings.newMerchant && !hasMerchantAllowList(mandate, target)) {
|
|
90
|
+
out.push({ signal: "new-merchant", level: "high", detail: "the first payment to this merchant" });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function effectiveRiskFloor(mandate, target, amount, opts = {}) {
|
|
95
|
+
return maxRisk(...riskSignalsFor(mandate, target, amount, opts).map((s) => s.level));
|
|
96
|
+
}
|
|
46
97
|
function requiresPayloadBindingFor(mandate, target) {
|
|
47
98
|
if (!mandate) return false;
|
|
48
99
|
for (const p of mandate.permission ?? []) {
|
|
@@ -732,6 +783,10 @@ function buildLegacyAuthMessageV1(f) {
|
|
|
732
783
|
function buildLocalDecisionMessage(f) {
|
|
733
784
|
return [f.agentDid, f.action, f.decision, f.reasonCode, f.nonce, f.issuedAt].map((v) => escapeField(String(v))).join("|");
|
|
734
785
|
}
|
|
786
|
+
var AGENT_SETTLE_PREFIX = "MAGP-SETTLE-v1";
|
|
787
|
+
function buildAgentSettleMessage(f) {
|
|
788
|
+
return [AGENT_SETTLE_PREFIX, f.verb, f.agentDid, f.authorizationId, f.nonce, f.issuedAt, ...f.fields].map((v) => escapeField(String(v))).join("|");
|
|
789
|
+
}
|
|
735
790
|
|
|
736
791
|
// src/policy-core/checkpoint-anchor.ts
|
|
737
792
|
function buildCheckpointAnchorMessage(f) {
|
|
@@ -782,12 +837,14 @@ function operatingModeGate(mode, ctx) {
|
|
|
782
837
|
}
|
|
783
838
|
}
|
|
784
839
|
export {
|
|
840
|
+
AGENT_SETTLE_PREFIX,
|
|
785
841
|
ATOM_DEFAULT_REQUIRED_CONTEXT,
|
|
786
842
|
ATOM_REGISTRY,
|
|
787
843
|
ATOM_SPECS,
|
|
788
844
|
AUTH_MESSAGE_V2_TAG,
|
|
789
845
|
CATALOGUED_ATOMS,
|
|
790
846
|
CONTEXT_UNVERIFIABLE,
|
|
847
|
+
DEFAULT_AMOUNT_SHARE_HIGH,
|
|
791
848
|
JURISDICTION_ATOM,
|
|
792
849
|
MODES_BY_RANK,
|
|
793
850
|
MODE_RANK,
|
|
@@ -801,6 +858,7 @@ export {
|
|
|
801
858
|
applySignedLast,
|
|
802
859
|
asOperatingMode,
|
|
803
860
|
authorityFailure,
|
|
861
|
+
buildAgentSettleMessage,
|
|
804
862
|
buildAuthMessage,
|
|
805
863
|
buildCheckpointAnchorMessage,
|
|
806
864
|
buildLegacyAuthMessageV1,
|
|
@@ -809,10 +867,12 @@ export {
|
|
|
809
867
|
canAuthorize,
|
|
810
868
|
contextFieldProblem,
|
|
811
869
|
documentEnforcesJurisdiction,
|
|
870
|
+
effectiveRiskFloor,
|
|
812
871
|
evaluate,
|
|
813
872
|
evaluateBoundStandards,
|
|
814
873
|
evaluateMandate,
|
|
815
874
|
evaluateStandardRules,
|
|
875
|
+
hasMerchantAllowList,
|
|
816
876
|
isAuthorityFailure,
|
|
817
877
|
isOperatingMode,
|
|
818
878
|
isProvenance,
|
|
@@ -823,6 +883,7 @@ export {
|
|
|
823
883
|
moreRestrictive,
|
|
824
884
|
normalizeRiskLevel,
|
|
825
885
|
operatingModeGate,
|
|
886
|
+
perTxnCapFor,
|
|
826
887
|
provenanceOf,
|
|
827
888
|
releaseHold,
|
|
828
889
|
remainingBudget,
|
|
@@ -830,6 +891,8 @@ export {
|
|
|
830
891
|
requiredContextOf,
|
|
831
892
|
requiresPayloadBindingFor,
|
|
832
893
|
riskFloorFor,
|
|
894
|
+
riskSignalSettings,
|
|
895
|
+
riskSignalsFor,
|
|
833
896
|
sumEventField,
|
|
834
897
|
validateMolecules
|
|
835
898
|
};
|