@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.
- package/AUDIT.md +21 -1
- package/README.md +36 -12
- package/deployments-base-sepolia.json +32 -1
- package/deployments-registries-base-sepolia.json +1 -0
- package/dist/abi/AgentAccount.json +23 -0
- package/dist/abi/AgentAccountFactory.json +5 -0
- package/dist/abi/AgentNameRegistry.json +5 -0
- package/dist/abi/AgenticGovernance.json +32 -0
- package/dist/abi/AgreementRegistry.json +0 -5
- package/dist/abi/CustodyPolicy.json +11 -0
- package/dist/abi/QuorumEnforcer.json +16 -0
- package/dist/abi/SmartAgentPaymaster.json +209 -0
- package/dist/deployments/base-sepolia.js +28 -28
- package/dist/deployments/index.d.ts +2 -1
- package/dist/deployments/index.js +3 -0
- package/dist/deployments/registries-base-sepolia.d.ts +9 -0
- package/dist/deployments/registries-base-sepolia.js +7 -0
- package/package.json +3 -1
- package/src/AgentAccount.sol +61 -2
- package/src/AgentAccountFactory.sol +32 -7
- package/src/SmartAgentPaymaster.sol +149 -12
- package/src/agency/DelegationManager.sol +17 -2
- package/src/agency/IDelegationManager.sol +3 -0
- package/src/agreement/AgreementRegistry.sol +46 -21
- package/src/attestation/AttestationRegistry.sol +86 -22
- package/src/custody/CustodyPolicy.sol +59 -6
- package/src/enforcers/QuorumEnforcer.sol +12 -0
- package/src/geo/GeoFeatureRegistry.sol +27 -20
- package/src/governance/AgenticGovernance.sol +26 -4
- package/src/libraries/SignatureSlotRecovery.sol +8 -3
- package/src/libraries/WebAuthnLib.sol +13 -0
- package/src/naming/AgentNameRegistry.sol +27 -2
- 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
|
-
|
|
50
|
-
|
|
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
|
|
89
|
-
|
|
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 —
|
|
157
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
//
|
|
142
|
-
|
|
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
|
-
|
|
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(
|
|
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 +
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|