@agenticprimitives/contracts 1.0.0-alpha.5 → 1.0.0-alpha.7

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.
Files changed (33) hide show
  1. package/AUDIT.md +21 -1
  2. package/README.md +36 -12
  3. package/deployments-base-sepolia.json +32 -1
  4. package/deployments-registries-base-sepolia.json +1 -0
  5. package/dist/abi/AgentAccount.json +23 -0
  6. package/dist/abi/AgentAccountFactory.json +5 -0
  7. package/dist/abi/AgentNameRegistry.json +5 -0
  8. package/dist/abi/AgenticGovernance.json +32 -0
  9. package/dist/abi/AgreementRegistry.json +0 -5
  10. package/dist/abi/CustodyPolicy.json +11 -0
  11. package/dist/abi/QuorumEnforcer.json +16 -0
  12. package/dist/abi/SmartAgentPaymaster.json +209 -0
  13. package/dist/deployments/base-sepolia.js +28 -28
  14. package/dist/deployments/index.d.ts +2 -1
  15. package/dist/deployments/index.js +3 -0
  16. package/dist/deployments/registries-base-sepolia.d.ts +9 -0
  17. package/dist/deployments/registries-base-sepolia.js +7 -0
  18. package/package.json +3 -1
  19. package/src/AgentAccount.sol +61 -2
  20. package/src/AgentAccountFactory.sol +32 -7
  21. package/src/SmartAgentPaymaster.sol +149 -12
  22. package/src/agency/DelegationManager.sol +17 -2
  23. package/src/agency/IDelegationManager.sol +3 -0
  24. package/src/agreement/AgreementRegistry.sol +46 -21
  25. package/src/attestation/AttestationRegistry.sol +86 -22
  26. package/src/custody/CustodyPolicy.sol +59 -6
  27. package/src/enforcers/QuorumEnforcer.sol +12 -0
  28. package/src/geo/GeoFeatureRegistry.sol +27 -20
  29. package/src/governance/AgenticGovernance.sol +26 -4
  30. package/src/libraries/SignatureSlotRecovery.sol +8 -3
  31. package/src/libraries/WebAuthnLib.sol +13 -0
  32. package/src/naming/AgentNameRegistry.sol +27 -2
  33. package/src/skills/SkillDefinitionRegistry.sol +21 -15
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.28;
3
3
 
4
4
  import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
5
6
 
6
7
  /**
7
8
  * @title AgreementRegistry
@@ -46,8 +47,20 @@ contract AgreementRegistry {
46
47
  /// digest model; the TS side derives the same constant (cross-stack
47
48
  /// typehash-equality gate). Plain struct hash (no domain separator),
48
49
  /// matching the attestation registry's JOINT_CONSENT_TYPEHASH.
49
- bytes32 internal constant TRANSITION_TYPEHASH =
50
- keccak256("AgreementTransition(bytes32 agreementCommitment,uint8 toStatus,bytes32 nullifier)");
50
+ /// AGR-1 (audit 2026-06-10): also bind chainId + this contract so a transition signature
51
+ /// cannot be replayed on another chain or a redeployed registry.
52
+ bytes32 internal constant TRANSITION_TYPEHASH = keccak256(
53
+ "AgreementTransition(bytes32 agreementCommitment,uint8 toStatus,bytes32 nullifier,uint256 chainId,address verifyingContract)"
54
+ );
55
+
56
+ /// @dev SC-1 (audit 2026-06-09): the issuer attestation digest is RECOMPUTED on-chain from the
57
+ /// agreement's contents + chain + this contract — it is NOT a caller-supplied free-form hash.
58
+ /// Binds (agreementCommitment, schemaHash, issuer, chainId, verifyingContract) so a signature
59
+ /// lifted from any other context cannot back an attacker-chosen commitment. The TS side derives
60
+ /// the same constant (cross-stack typehash-equality gate).
61
+ bytes32 internal constant AGREEMENT_ISSUER_TYPEHASH = keccak256(
62
+ "AgreementIssuerAttestation(bytes32 agreementCommitment,bytes32 schemaHash,address issuer,uint256 chainId,address verifyingContract)"
63
+ );
51
64
 
52
65
  // ─── Errors ─────────────────────────────────────────────────────────
53
66
 
@@ -85,9 +98,8 @@ contract AgreementRegistry {
85
98
  struct AgreementIssuancePayload {
86
99
  bytes32 schemaHash;
87
100
  address issuer;
88
- /// @dev EIP-712 attestation hash by the issuer (over agreementCommitment + schemaHash).
89
- bytes32 attestationStructHash;
90
- /// @dev issuer's signature over `attestationStructHash`.
101
+ /// @dev issuer's signature over the RECOMPUTED issuer-attestation digest (SC-1). The contract
102
+ /// derives the digest from the payload contents — no caller-supplied attestation hash.
91
103
  bytes issuerSignature;
92
104
  /// @dev The asserted canonical commitment hash; we verify recomputation.
93
105
  bytes32 agreementCommitment;
@@ -153,8 +165,20 @@ contract AgreementRegistry {
153
165
  revert AlreadyRegistered();
154
166
  }
155
167
 
156
- // AR-02 — verify issuer signature over the attestation hash
157
- if (!_isValidSignatureBool(p.issuer, p.attestationStructHash, p.issuerSignature)) {
168
+ // AR-02 / SC-1 — RECOMPUTE the issuer-attestation digest from the agreement contents (+ chain +
169
+ // this contract) and verify the issuer signed THAT. A signature over an arbitrary digest from
170
+ // any other context can no longer back an attacker-chosen commitment.
171
+ bytes32 issuerDigest = keccak256(
172
+ abi.encode(
173
+ AGREEMENT_ISSUER_TYPEHASH,
174
+ p.agreementCommitment,
175
+ p.schemaHash,
176
+ p.issuer,
177
+ block.chainid,
178
+ address(this)
179
+ )
180
+ );
181
+ if (!_isValidSignatureBool(p.issuer, issuerDigest, p.issuerSignature)) {
158
182
  revert InvalidIssuerSignature();
159
183
  }
160
184
 
@@ -222,7 +246,7 @@ contract AgreementRegistry {
222
246
  // caller-supplied hash. The signed payload is canonically bound to (commitment, toStatus,
223
247
  // nullifier) here, on chain.
224
248
  bytes32 transitionDigest = keccak256(
225
- abi.encode(TRANSITION_TYPEHASH, p.agreementCommitment, p.toStatus, p.nullifier)
249
+ abi.encode(TRANSITION_TYPEHASH, p.agreementCommitment, p.toStatus, p.nullifier, block.chainid, address(this))
226
250
  );
227
251
 
228
252
  bool bilateralRequired = (p.toStatus != STATUS_DISPUTED);
@@ -253,6 +277,15 @@ contract AgreementRegistry {
253
277
  /// that permits public joint assertion (currently any non-NONE
254
278
  /// status; revoked agreements can still be jointly disclosed as
255
279
  /// historical record).
280
+ ///
281
+ /// @dev NOT a consent or validity oracle (audit 2026-06-10). A `true`
282
+ /// result means only that `agreementCommitment` is REGISTERED — it is
283
+ /// an opaque back-reference, NOT proof that any particular party
284
+ /// consented, that the agreement is currently valid, or that a given
285
+ /// credential is bound to it. Bilateral consent for a joint assertion
286
+ /// is established separately by `AttestationRegistry.assertJointAgreement`,
287
+ /// which recomputes both parties' consent signatures. Consumers MUST
288
+ /// NOT treat the boolean here as authorization.
256
289
  function isAssertableCommitment(
257
290
  bytes32 agreementCommitment,
258
291
  address /* actor */
@@ -288,19 +321,11 @@ contract AgreementRegistry {
288
321
  return false;
289
322
  }
290
323
  }
324
+ // P2-2 (audit 2026-06-10): route EOA recovery through OpenZeppelin ECDSA.tryRecover instead of
325
+ // raw ecrecover — it rejects high-s (malleable) signatures and malformed lengths, matching the
326
+ // DelegationManager idiom so every signature-verifying contract shares one low-s-safe path.
291
327
  bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
292
- if (signature.length != 65) return false;
293
- bytes32 r;
294
- bytes32 s;
295
- uint8 v;
296
- // solhint-disable-next-line no-inline-assembly
297
- assembly {
298
- r := mload(add(signature, 0x20))
299
- s := mload(add(signature, 0x40))
300
- v := byte(0, mload(add(signature, 0x60)))
301
- }
302
- if (v < 27) v += 27;
303
- address recovered = ecrecover(ethHash, v, r, s);
304
- return recovered != address(0) && recovered == signer;
328
+ (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
329
+ return err == ECDSA.RecoverError.NoError && recovered == signer;
305
330
  }
306
331
  }
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.28;
3
3
 
4
4
  import "@openzeppelin/contracts/interfaces/IERC1271.sol";
5
+ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
5
6
 
6
7
  /**
7
8
  * @title AttestationRegistry
@@ -17,9 +18,19 @@ import "@openzeppelin/contracts/interfaces/IERC1271.sol";
17
18
  * ─── Key invariants (AR-*) ───
18
19
  *
19
20
  * AR-01 deterministic UID = keccak256(subject, issuer, credentialType, credentialHash, refUID, salt)
20
- * AR-02 issuer signature verified via ERC-1271 against the named issuer SA
21
- * AR-03 schemaId MUST point to a registered ShapeRegistry shape (governance-gated)
22
- * AR-04 bilateral-consent verified via DelegationManager.verifyAuthorization
21
+ * AR-02 issuer signature verified (ERC-1271 for SAs, low-s ECDSA for EOAs) over a CANONICAL TYPED
22
+ * digest recomputed on-chain — ASSOCIATION_ATTESTATION_TYPEHASH (unilateral) /
23
+ * JOINT_ISSUER_TYPEHASH (joint), each binding the parties/subject + schema + credentialType +
24
+ * credentialHash + chainId + this contract (SC-2 / ATT-1). A bare credentialHash is never trusted.
25
+ * AR-03 schemaId is an OPAQUE tag stored on the attestation. This registry does NOT dereference
26
+ * ShapeRegistry on-chain; shape/governance validation of schemaId is an off-chain/consumer
27
+ * concern. (Earlier drafts claimed on-chain ShapeRegistry enforcement — that is not implemented
28
+ * here and consumers MUST NOT assume it.)
29
+ * AR-04 bilateral consent is VERIFIED on-chain by recomputing JOINT_CONSENT_TYPEHASH (party1, party2,
30
+ * agreementCommitment, credentialHash, chainId, this contract) and requiring BOTH parties'
31
+ * signatures over it (ERC-1271 / low-s ECDSA). This entrypoint uses the direct two-party-signature
32
+ * form; the DelegationManager.verifyAuthorization predicate path is spec 249's alternative (b),
33
+ * NOT this contract's direct joint path.
23
34
  * AR-05 holder-only revocation for unilateral; either-party for joint
24
35
  * AR-06 NO issuerRevoke(...) entrypoint — by static analysis
25
36
  * AR-07 epoch-bucket timestamps; raw block.timestamp never stored
@@ -48,8 +59,28 @@ contract AttestationRegistry {
48
59
  /// @dev RW1-1 (ADR-0027) — the canonical consent payload each party signs to consent to a
49
60
  /// joint agreement. The contract RECOMPUTES this digest from calldata; a stored/supplied
50
61
  /// reference is not consent. Binds the consent to the exact parties + agreement + credential.
62
+ /// ATT-3 (audit 2026-06-10): also binds chainId + this registry, mirroring the issuer side
63
+ /// (ATT-1) and the association path (SC-2). Without it a party's consent signature could be
64
+ /// replayed on another chain or a redeployed registry (the attestation map is per-instance
65
+ /// storage, so a cross-instance replay anchors a fresh spoofed attestation).
51
66
  bytes32 internal constant JOINT_CONSENT_TYPEHASH = keccak256(
52
- "JointAgreementConsent(address party1,address party2,bytes32 agreementCommitment,bytes32 credentialHash)"
67
+ "JointAgreementConsent(address party1,address party2,bytes32 agreementCommitment,bytes32 credentialHash,uint256 chainId,address verifyingContract)"
68
+ );
69
+
70
+ /// @dev ATT-1 (audit 2026-06-10): the SC-2 bug class lived on in the JOINT issuer signature — it was
71
+ /// verified over a bare `credentialHash`, so a known issuer signature could be reused to anchor a
72
+ /// spoofed issuer-backed joint attestation with different parties/schema. The issuer now signs a
73
+ /// digest binding parties + schema + credential type + commitment + chain + this contract.
74
+ bytes32 internal constant JOINT_ISSUER_TYPEHASH = keccak256(
75
+ "JointAgreementIssuerAttestation(address party1,address party2,address issuer,bytes32 schemaId,bytes32 credentialType,bytes32 credentialHash,bytes32 agreementCommitment,uint256 chainId,address verifyingContract)"
76
+ );
77
+
78
+ /// @dev SC-2 (audit 2026-06-09): the issuer attestation digest binds the SUBJECT (and schema /
79
+ /// credential type / chain / this contract) — not just `credentialHash`. Previously the issuer
80
+ /// signed only the credentialHash, so anyone who learned that public hash could anchor it on
81
+ /// chain against a DIFFERENT subject. Recomputed on-chain; the TS side derives the same constant.
82
+ bytes32 internal constant ASSOCIATION_ATTESTATION_TYPEHASH = keccak256(
83
+ "AssociationAttestation(address subject,address issuer,bytes32 schemaId,bytes32 credentialType,bytes32 credentialHash,uint256 chainId,address verifyingContract)"
53
84
  );
54
85
 
55
86
  // ─── Storage ────────────────────────────────────────────────────────
@@ -138,8 +169,22 @@ contract AttestationRegistry {
138
169
  function assertAssociation(AssociationAttestationRequest calldata req) external returns (bytes32) {
139
170
  if (req.credentialHash == bytes32(0)) revert EmptyCredentialHash();
140
171
 
141
- // AR-02: verify issuer signature over credentialHash
142
- if (!_isValidSignatureBool(req.issuer, req.credentialHash, req.issuerSignature)) {
172
+ // SC-2: RECOMPUTE the issuer-attestation digest binding the SUBJECT (+ schema / type / chain /
173
+ // this contract) and verify the issuer signed THAT. A known credentialHash can no longer be
174
+ // anchored against an attacker-chosen subject.
175
+ bytes32 issuerDigest = keccak256(
176
+ abi.encode(
177
+ ASSOCIATION_ATTESTATION_TYPEHASH,
178
+ req.subject,
179
+ req.issuer,
180
+ req.schemaId,
181
+ req.credentialType,
182
+ req.credentialHash,
183
+ block.chainid,
184
+ address(this)
185
+ )
186
+ );
187
+ if (!_isValidSignatureBool(req.issuer, issuerDigest, req.issuerSignature)) {
143
188
  revert InvalidIssuerSignature();
144
189
  }
145
190
 
@@ -192,13 +237,40 @@ contract AttestationRegistry {
192
237
  if (req.party1 == address(0) || req.party2 == address(0)) revert InvalidParties();
193
238
  if (req.party1 == req.party2) revert InvalidParties();
194
239
 
195
- if (!_isValidSignatureBool(req.issuer, req.credentialHash, req.issuerSignature)) {
240
+ // ATT-1 (audit 2026-06-10): the issuer signs a FULL typed digest binding the parties, schema,
241
+ // credential type/hash, the agreement commitment (refUID), chain, and this registry — not a bare
242
+ // `credentialHash`. A leaked/observed issuer signature can no longer be replayed to anchor an
243
+ // issuer-backed joint attestation for different parties/schema/chain.
244
+ bytes32 issuerDigest = keccak256(
245
+ abi.encode(
246
+ JOINT_ISSUER_TYPEHASH,
247
+ req.party1,
248
+ req.party2,
249
+ req.issuer,
250
+ req.schemaId,
251
+ req.credentialType,
252
+ req.credentialHash,
253
+ req.refUID,
254
+ block.chainid,
255
+ address(this)
256
+ )
257
+ );
258
+ if (!_isValidSignatureBool(req.issuer, issuerDigest, req.issuerSignature)) {
196
259
  revert InvalidIssuerSignature();
197
260
  }
198
261
 
199
262
  // VERIFY bilateral consent: recompute the digest, require both parties' signatures over it.
263
+ // ATT-3: chainId + address(this) bound (anti cross-chain / redeployed-registry replay).
200
264
  bytes32 consentDigest = keccak256(
201
- abi.encode(JOINT_CONSENT_TYPEHASH, req.party1, req.party2, req.refUID, req.credentialHash)
265
+ abi.encode(
266
+ JOINT_CONSENT_TYPEHASH,
267
+ req.party1,
268
+ req.party2,
269
+ req.refUID,
270
+ req.credentialHash,
271
+ block.chainid,
272
+ address(this)
273
+ )
202
274
  );
203
275
  if (!_isValidSignatureBool(req.party1, consentDigest, req.party1Signature)) revert InvalidPartyConsent();
204
276
  if (!_isValidSignatureBool(req.party2, consentDigest, req.party2Signature)) revert InvalidPartyConsent();
@@ -301,20 +373,12 @@ contract AttestationRegistry {
301
373
  return false;
302
374
  }
303
375
  }
304
- // EOA fallback via eth-signed message hash + ecrecover
376
+ // EOA fallback via eth-signed message hash + malleability-safe ECDSA.
377
+ // P2-2 (audit 2026-06-10): route EOA recovery through OpenZeppelin ECDSA.tryRecover instead of
378
+ // raw ecrecover — it rejects high-s (malleable) signatures and malformed lengths, matching the
379
+ // DelegationManager idiom so every signature-verifying contract shares one low-s-safe path.
305
380
  bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
306
- if (signature.length != 65) return false;
307
- bytes32 r;
308
- bytes32 s;
309
- uint8 v;
310
- // solhint-disable-next-line no-inline-assembly
311
- assembly {
312
- r := mload(add(signature, 0x20))
313
- s := mload(add(signature, 0x40))
314
- v := byte(0, mload(add(signature, 0x60)))
315
- }
316
- if (v < 27) v += 27;
317
- address recovered = ecrecover(ethHash, v, r, s);
318
- return recovered != address(0) && recovered == signer;
381
+ (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(ethHash, signature);
382
+ return err == ECDSA.RecoverError.NoError && recovered == signer;
319
383
  }
320
384
  }
@@ -313,6 +313,9 @@ contract CustodyPolicy {
313
313
  error TrusteeDoesNotExist(address guardian);
314
314
  error InvalidMode(uint8 mode_);
315
315
  error InvalidThresholdValue(uint8 thr);
316
+ /// @dev CP-1: a tier that must be configured (T4/T5 at install, T6 at read)
317
+ /// was left unset — refuse rather than fall to the 1-of-n read fallback.
318
+ error UnconfiguredTier(uint8 tier);
316
319
  error RecoveryRequiresGuardians();
317
320
  error EmptyOwnerSet();
318
321
  error CustodyActionNotYetImplemented(uint8 action);
@@ -370,11 +373,7 @@ contract CustodyPolicy {
370
373
  c.t3HighValueCeiling = t3Ceiling;
371
374
  c.approvedHashRegistry = approvedHashReg;
372
375
 
373
- for (uint8 t = 1; t <= 6; t++) {
374
- if (thresholds[t] > 0) c.approvalsRequiredByTier[t] = thresholds[t];
375
- if (timelocks[t] > 0) c.safetyDelayByTier[t] = timelocks[t];
376
- }
377
-
376
+ // Trustees first so `trusteeCount` is known for the CP-2 recovery check.
378
377
  for (uint256 i; i < trustees.length; i++) {
379
378
  address g = trustees[i];
380
379
  if (g == address(0)) revert ZeroAddress();
@@ -383,6 +382,51 @@ contract CustodyPolicy {
383
382
  c.trusteeCount += 1;
384
383
  }
385
384
 
385
+ // CP-2: a recovery threshold the trustee set can NEVER satisfy bricks the
386
+ // T6 recovery lifeline from birth. Validate at install exactly as
387
+ // `_applySetRecoveryThreshold` does at runtime. `recoveryApprovals == 0`
388
+ // is a legitimate state (trustee recovery simply disabled); a positive
389
+ // threshold MUST be reachable by the configured trustees.
390
+ if (recThr > c.trusteeCount) revert RecoveryRequiresGuardians();
391
+
392
+ // CP-1: floor each unset tier to the spec default-approvals matrix so a
393
+ // direct onInstall that bypasses the factory with a zeroed/partial
394
+ // threshold array can't collapse the admin/critical tiers to the 1-of-n
395
+ // read fallback in `_approvalsValue`. T1–T3 legitimately stay at the
396
+ // 1-of-n floor (read/write/value are single-signer tiers by design);
397
+ // T4/T5 get the council default. T6 (recovery) lives in
398
+ // `recoveryApprovals`, not this map.
399
+ //
400
+ // The custodian count is read best-effort from the installing account
401
+ // (the ERC-7579 flow guarantees `msg.sender` is the deployed account).
402
+ // If it can't be determined (the account isn't yet/at all an
403
+ // `IAgentAccount`), `nCustodians` stays 0 so no flooring happens — and
404
+ // the hard-revert below then REQUIRES the installer to have passed
405
+ // explicit T4/T5 thresholds. Either way the admin/critical tiers can
406
+ // never silently resolve to 1-of-n; the floor is a convenience, the
407
+ // hard-revert is the security invariant.
408
+ uint8 nCustodians;
409
+ if (account.code.length > 0) {
410
+ try IAgentAccount(account).custodianCount() returns (uint256 cnt) {
411
+ nCustodians = cnt > 255 ? 255 : uint8(cnt);
412
+ } catch {}
413
+ }
414
+ for (uint8 t = 1; t <= 6; t++) {
415
+ uint8 thr = thresholds[t];
416
+ if (thr == 0) thr = _defaultApprovals(nCustodians, t);
417
+ if (thr > 0) c.approvalsRequiredByTier[t] = thr;
418
+ if (timelocks[t] > 0) c.safetyDelayByTier[t] = timelocks[t];
419
+ }
420
+
421
+ // CP-1 hard-revert: a non-single account MUST have the admin (T4) +
422
+ // critical (T5) custody quorum configured (explicitly or via the floor
423
+ // above) — never the 1-of-n read fallback. Mode 0 (single signer) has no
424
+ // custody council, so thresholds are moot and this is skipped.
425
+ if (modeVal >= 1) {
426
+ if (c.approvalsRequiredByTier[4] == 0) revert UnconfiguredTier(4);
427
+ if (c.approvalsRequiredByTier[5] == 0) revert UnconfiguredTier(5);
428
+ }
429
+
386
430
  emit CustodyPolicyInstalled(account, modeVal, recThr);
387
431
  }
388
432
 
@@ -633,7 +677,16 @@ contract CustodyPolicy {
633
677
  function _approvalsValue(Config storage c, uint8 tier) internal view returns (uint8) {
634
678
  if (tier == 0 || tier > 6) revert InvalidTier(tier);
635
679
  uint8 v = c.approvalsRequiredByTier[tier];
636
- return v == 0 ? 1 : v;
680
+ if (v == 0) {
681
+ // CP-1 defense-in-depth: the T6 (recovery) quorum lives in
682
+ // `recoveryApprovals`, never in this map — a T6 read here is a
683
+ // mis-wire, so fail closed instead of returning the 1-of-n fallback.
684
+ // T1–T5 keep the 1-of-n floor (T4/T5 are install-floored, so the
685
+ // fallback only ever applies to the low single-signer tiers).
686
+ if (tier == 6) revert UnconfiguredTier(6);
687
+ return 1;
688
+ }
689
+ return v;
637
690
  }
638
691
 
639
692
  function _safetyDelayValue(Config storage c, uint8 tier) internal view returns (uint32) {
@@ -82,6 +82,9 @@ contract QuorumEnforcer is ICaveatEnforcer {
82
82
  error UnauthorizedSigner(address signer);
83
83
  error DuplicateOrUnsortedSigner(address signer);
84
84
  error PayloadHashMismatch(bytes32 expected, bytes32 supplied);
85
+ /// @notice EN-11: a degenerate quorum caveat (threshold 0, or a threshold the
86
+ /// signer set can never satisfy) must fail closed, not pass with zero sigs.
87
+ error InvalidThreshold(uint8 threshold, uint256 signerSetSize);
85
88
 
86
89
  /// @notice Typehash for the quorum-action binding (contract audit C-4).
87
90
  /// @dev Bound to (chainId, enforcer, delegationHash, delegator, redeemer,
@@ -135,6 +138,15 @@ contract QuorumEnforcer is ICaveatEnforcer {
135
138
  abi.decode(terms, (address[], uint8, address));
136
139
  (bytes32 suppliedPayloadHash, bytes memory signatures) = abi.decode(args, (bytes32, bytes));
137
140
 
141
+ // EN-11: fail closed on a degenerate quorum. threshold == 0 would skip the
142
+ // verification loop entirely AND make `signatures.length < 0` unreachable, so
143
+ // the caveat would pass with ZERO signatures. A threshold larger than the
144
+ // signer set is unsatisfiable; reject it up front rather than running out of
145
+ // slots mid-loop.
146
+ if (threshold == 0 || threshold > signerSet.length) {
147
+ revert InvalidThreshold(threshold, signerSet.length);
148
+ }
149
+
138
150
  // Contract audit C-4: bind the signed payload to the actual
139
151
  // execution context. Previously the redeemer chose payloadHash
140
152
  // freely, so a quorum signed off-chain to "approve a transfer
@@ -121,29 +121,36 @@ contract GeoFeatureRegistry {
121
121
  }
122
122
 
123
123
  version = prevVer + 1;
124
- _records[p.featureId][version] = FeatureRecord({
125
- featureId: p.featureId,
126
- version: version,
127
- stewardAccount: p.stewardAccount,
128
- featureKind: p.featureKind,
129
- geometryHash: p.geometryHash,
130
- coverageRoot: p.coverageRoot,
131
- sourceSetRoot: p.sourceSetRoot,
132
- metadataURI: p.metadataURI,
133
- centroidLat: p.centroidLat,
134
- centroidLon: p.centroidLon,
135
- bboxMinLat: p.bboxMinLat,
136
- bboxMinLon: p.bboxMinLon,
137
- bboxMaxLat: p.bboxMaxLat,
138
- bboxMaxLon: p.bboxMaxLon,
139
- validAfter: p.validAfter,
140
- validUntil: p.validUntil,
141
- active: true,
142
- registeredAt: uint64(block.timestamp)
143
- });
144
124
  latestVersion[p.featureId] = version;
145
125
  if (prevVer == 0) _allFeatures.push(p.featureId);
126
+ // The heavy record write + event live in a helper so `publish`'s own stack stays shallow.
127
+ _storeAndEmit(p, version);
128
+ }
146
129
 
130
+ /// @dev Writes the record FIELD-BY-FIELD to storage (no in-memory struct literal) and emits.
131
+ /// Behaviour is identical to the prior `_records[...] = FeatureRecord({...})` literal; the
132
+ /// split exists only to keep `publish` within solc's stack under `forge coverage --ir-minimum`
133
+ /// instrumentation (the 18-field memory literal + a dynamic `string` overflowed it by one slot).
134
+ function _storeAndEmit(PublishInput calldata p, uint64 version) internal {
135
+ FeatureRecord storage r = _records[p.featureId][version];
136
+ r.featureId = p.featureId;
137
+ r.version = version;
138
+ r.stewardAccount = p.stewardAccount;
139
+ r.featureKind = p.featureKind;
140
+ r.geometryHash = p.geometryHash;
141
+ r.coverageRoot = p.coverageRoot;
142
+ r.sourceSetRoot = p.sourceSetRoot;
143
+ r.metadataURI = p.metadataURI;
144
+ r.centroidLat = p.centroidLat;
145
+ r.centroidLon = p.centroidLon;
146
+ r.bboxMinLat = p.bboxMinLat;
147
+ r.bboxMinLon = p.bboxMinLon;
148
+ r.bboxMaxLat = p.bboxMaxLat;
149
+ r.bboxMaxLon = p.bboxMaxLon;
150
+ r.validAfter = p.validAfter;
151
+ r.validUntil = p.validUntil;
152
+ r.active = true;
153
+ r.registeredAt = uint64(block.timestamp);
147
154
  emit FeaturePublished(p.featureId, version, p.stewardAccount, p.featureKind, p.geometryHash, p.coverageRoot, p.sourceSetRoot, p.metadataURI);
148
155
  }
149
156
 
@@ -42,15 +42,21 @@ contract AgenticGovernance is IGovernanceView {
42
42
  /// construction; rotate by redeploying.
43
43
  address public immutable timelock;
44
44
 
45
- /// @notice Fast-path emergency-pauser role. Can pause without delay
46
- /// but CANNOT unpause (unpause requires the timelock).
47
- address public immutable guardian;
48
-
49
45
  // ─── State ────────────────────────────────────────────────────
50
46
 
51
47
  bool private _paused;
52
48
  mapping(address => bool) private _signers;
53
49
 
50
+ /// @notice Fast-path emergency-pauser role. Can pause without delay but
51
+ /// CANNOT unpause (unpause requires the timelock). GOV-1: this is a
52
+ /// STORAGE var (not immutable) so the timelock can ROTATE it — a
53
+ /// guardian whose key is compromised and grief-pauses the system can
54
+ /// be replaced WITHOUT a full governance redeploy, bounding the
55
+ /// re-pause DoS to one timelock window instead of "permanent". Set
56
+ /// (`setGuardian`) only by the timelock. Declared after the existing
57
+ /// storage vars so their slot assignments don't shift.
58
+ address public guardian;
59
+
54
60
  // ─── Errors ───────────────────────────────────────────────────
55
61
 
56
62
  error NotTimelock(address caller);
@@ -64,6 +70,8 @@ contract AgenticGovernance is IGovernanceView {
64
70
  event Unpaused(address indexed by);
65
71
  event SignerSet(address indexed who, bool isSigner);
66
72
  event Executed(address indexed target, uint256 value, bytes data);
73
+ /// @notice GOV-1 — the guardian (fast-pause) role was rotated by the timelock.
74
+ event GuardianRotated(address indexed oldGuardian, address indexed newGuardian);
67
75
 
68
76
  // ─── Constructor ──────────────────────────────────────────────
69
77
 
@@ -134,6 +142,20 @@ contract AgenticGovernance is IGovernanceView {
134
142
  emit SignerSet(who, sig);
135
143
  }
136
144
 
145
+ /// @notice GOV-1 — rotate the guardian (fast-pause) role. Timelock-only, so a
146
+ /// compromised guardian that is perpetually re-pausing the system can
147
+ /// be replaced after one timelock window WITHOUT a governance redeploy.
148
+ /// To retire the fast-pause capability entirely, rotate to a burn
149
+ /// address. Cannot be the zero address (would make `pause()` an
150
+ /// accidental no-op while looking live).
151
+ function setGuardian(address newGuardian) external {
152
+ if (msg.sender != timelock) revert NotTimelock(msg.sender);
153
+ if (newGuardian == address(0)) revert ZeroAddress();
154
+ address old = guardian;
155
+ guardian = newGuardian;
156
+ emit GuardianRotated(old, newGuardian);
157
+ }
158
+
137
159
  // ─── Receive ETH ──────────────────────────────────────────────
138
160
 
139
161
  receive() external payable {}
@@ -173,9 +173,14 @@ library SignatureSlotRecovery {
173
173
  abi.decode(dyn, (uint256, uint256, bytes32, WebAuthnLib.Assertion));
174
174
  address derived = address(uint160(uint256(keccak256(abi.encode(pubX, pubY)))));
175
175
  if (derived != signer) revert PasskeyPubKeyMismatch(signer, derived);
176
- // requireUv=false at the slot level; account policy can layer UV
177
- // requirement via a future policy module.
178
- if (!WebAuthnLib.verify(assertion, payloadHash, pubX, pubY, rpIdHash, false)) {
176
+ // WA-2: require UV at the slot level. This path verifies custody-COUNCIL
177
+ // passkey signatures (CustodyPolicy quorum: trustees/custodians authorizing
178
+ // a recovery or admin change) — the highest-assurance signatures in the
179
+ // system. Consistent with the native ERC-1271 path (AgentAccount, R8.2):
180
+ // a UP-only assertion is rejected; browser ceremonies for custody signers
181
+ // MUST use `userVerification: 'required'`. A lower-assurance flow that needs
182
+ // UP-only must install a separate validator module that relaxes this.
183
+ if (!WebAuthnLib.verify(assertion, payloadHash, pubX, pubY, rpIdHash, true)) {
179
184
  revert PasskeySigInvalid(signer);
180
185
  }
181
186
  } else if (v == 27 || v == 28) {
@@ -36,6 +36,13 @@ import "./P256Verifier.sol";
36
36
  * PasskeyValidator (for external 1271-style verification).
37
37
  */
38
38
  library WebAuthnLib {
39
+ /// @dev secp256r1 (P-256) curve order n ÷ 2 — the low-s malleability bound (WA-1).
40
+ /// RIP-7212 accepts BOTH (r, s) and (r, n−s) for the same message, so without
41
+ /// this bound a second valid signature always exists. Any path that treats a
42
+ /// signature as unique (quorum slot recovery, dedup) would be malleable.
43
+ uint256 internal constant P256_N_DIV_2 =
44
+ 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8;
45
+
39
46
  /// @notice A WebAuthn-wrapped P-256 signature bundle.
40
47
  struct Assertion {
41
48
  bytes authenticatorData;
@@ -74,6 +81,12 @@ library WebAuthnLib {
74
81
  if (!_checkClientData(assertion.clientDataJSON, assertion.typeIndex, assertion.challengeIndex, expectedChallengeHash)) {
75
82
  return false;
76
83
  }
84
+ // WA-1: enforce low-s. RIP-7212 verifies both (r, s) and (r, n−s); rejecting
85
+ // the high-s half makes the assertion signature canonical, closing P-256
86
+ // malleability for every caller (native ERC-1271 + custody-quorum slot recovery).
87
+ if (assertion.s > P256_N_DIV_2) {
88
+ return false;
89
+ }
77
90
  bytes32 cdjHash = sha256(bytes(assertion.clientDataJSON));
78
91
  bytes32 signingHash = sha256(abi.encodePacked(assertion.authenticatorData, cdjHash));
79
92
  return P256Verifier.verify(signingHash, assertion.r, assertion.s, pubX, pubY);
@@ -77,6 +77,12 @@ contract AgentNameRegistry is GovernanceManaged {
77
77
  error NameExpired();
78
78
  error RootAlreadyInitialized();
79
79
  error EmptyLabel();
80
+ /// @notice AN-1-ONCHAIN (audit 2026-06-10) — the label is not in canonical form. The off-chain
81
+ /// SDK `normalizeLabel` (NFC + lowercase + `[a-z0-9-]`, 1..63, no leading/trailing `-`) was
82
+ /// bypassable by any DIRECT contract caller, allowing homoglyph / mixed-case / zero-width /
83
+ /// embedded-dot squatting of names. The charset is now enforced ON CHAIN; because a valid
84
+ /// label is already lowercase ASCII, raw-byte hashing equals hashing the normalized label.
85
+ error InvalidLabel();
80
86
  error ZeroOwner();
81
87
  /// @notice H7-C.4 / CON-NAMING-001 — `initializeRoot` may only be called
82
88
  /// by the immutable `initializer` set at construction. Closes the
@@ -160,6 +166,25 @@ contract AgentNameRegistry is GovernanceManaged {
160
166
  return keccak256(abi.encodePacked(bytes32(0), keccak256(bytes("agent"))));
161
167
  }
162
168
 
169
+ /// @notice AN-1-ONCHAIN — enforce the canonical single-label charset on chain. Mirrors the SDK
170
+ /// `normalizeLabel`: a single label of 1..63 bytes drawn from `[a-z0-9-]`, with no leading
171
+ /// or trailing `-`. Reverts `EmptyLabel` / `InvalidLabel`. Reusable from `register` +
172
+ /// `initializeRoot` so NO write path can anchor a non-canonical (squattable) label.
173
+ function _validateLabel(bytes memory b) internal pure {
174
+ uint256 len = b.length;
175
+ if (len == 0) revert EmptyLabel();
176
+ if (len > 63) revert InvalidLabel();
177
+ // no leading / trailing hyphen (0x2d)
178
+ if (b[0] == 0x2d || b[len - 1] == 0x2d) revert InvalidLabel();
179
+ for (uint256 i = 0; i < len; i++) {
180
+ uint8 c = uint8(b[i]);
181
+ bool ok = (c >= 0x61 && c <= 0x7a) // a-z
182
+ || (c >= 0x30 && c <= 0x39) // 0-9
183
+ || c == 0x2d; // -
184
+ if (!ok) revert InvalidLabel();
185
+ }
186
+ }
187
+
163
188
  // ─── Root Initialization (multi-root) ───────────────────────────
164
189
 
165
190
  /**
@@ -179,7 +204,7 @@ contract AgentNameRegistry is GovernanceManaged {
179
204
  // TLD. The deploy script bundles deploy+init in one transaction so no
180
205
  // frontrun window exists.
181
206
  if (msg.sender != initializer) revert NotInitializer(msg.sender, initializer);
182
- if (bytes(label).length == 0) revert EmptyLabel();
207
+ _validateLabel(bytes(label)); // AN-1-ONCHAIN: canonical charset on chain
183
208
  if (rootOwner == address(0)) revert ZeroOwner();
184
209
  rootNode = namehashRoot(label);
185
210
  if (_records[rootNode].registeredAt != 0) revert RootAlreadyInitialized();
@@ -229,7 +254,7 @@ contract AgentNameRegistry is GovernanceManaged {
229
254
  address resolverContract,
230
255
  uint64 expiry
231
256
  ) external whenNotPaused returns (bytes32 childNode) {
232
- if (bytes(label).length == 0) revert EmptyLabel();
257
+ _validateLabel(bytes(label)); // AN-1-ONCHAIN: canonical charset on chain
233
258
  if (newOwner == address(0)) revert ZeroOwner();
234
259
  _requireParentAuth(parentNode);
235
260
  _requireNotExpired(parentNode);