Skip to content

Commit e44681d

Browse files
authored
feat(contracts): TEE-attested service approval commitments (#117)
Workstream C from the Tangle Cloud app-store program plan (docs/blueprint-app-store-program-plan.md in dapp). Operators may now commit to a specific TEE backend + measurement + nonce-binding at approval time. Blueprints (or off-chain provisioning oracles) cross-check the live attestation against the committed value before accepting the provision result. Types (`src/libraries/Types.sol`) - `enum TeeBackend { Phala, AwsNitro, GcpConfidential, AzureSkr, DirectTdx }` — append-only; `DirectTdx` recognised but rejected at approval (mirror of ai-agent-sandbox-blueprint #50 policy). - `struct TeeAttestationCommitment { backend, expectedMeasurement, nonceBinding, expiresAt }`. Approval entrypoint (`src/core/ServicesApprovals.sol`) - `approveServiceWithTeeCommitments(requestId, AssetSecurityCommitment[], blsPubkey, popSignature, TeeAttestationCommitment[])` — validates each TEE commitment (rejects DirectTdx, rejects past expiries), stores the per-operator array in `_requestTeeCommitments`, and emits `TeeCommitmentRecorded`. Activation persistence (`src/facets/tangle/TangleServicesFacet.sol`) - `_activateService` calls `_persistTeeCommitments`, copying every approving operator's commitment list onto `_serviceTeeCommitments[serviceId]`. - New view `getTeeCommitment(serviceId, operator)` exposes the array for blueprint provisioning hooks. Selector list grows 7 → 9. Storage (`src/TangleStorage.sol`) - Two new mappings: `_requestTeeCommitments[requestId][operator]` and `_serviceTeeCommitments[serviceId][operator]`. `__gap` reduced 44 → 42. Errors (`src/libraries/Errors.sol`) - `DirectTdxNotPermitted`, `TeeCommitmentExpired(uint64,uint64)`, `MeasurementMismatch(bytes32,bytes32)`, `TeeCommitmentLengthMismatch(uint256,uint256)`. Interface (`src/interfaces/ITangleServices.sol`) - New entrypoint declaration. Tests (`test/tangle/TeeCommitmentApprovalTest.t.sol`, 9 cases) - Happy path: Nitro commitment recorded, fetched via view. - DirectTdx rejected at first slot and at later slot. - Past-expiry and at-current-timestamp expiry rejected. - Future expiry persists through activation. - Empty TEE array passes through with no persistence. - Multiple operators with distinct backends persisted per-operator. - Unknown-operator view returns empty array. Why this surface and not the existing `OperatorSecurityCommitment`: TEE commitments have a different shape (backend enum + 32-byte measurement + 32-byte nonce + uint64 expiry) and a different lifecycle (expiry checked at approval; cross-check against live attestation by blueprint). Folding into the existing AssetSecurityCommitment array would require a discriminant tag and would muddle the per-asset vs per-attestation storage layouts. A parallel storage slot keeps both clean.
1 parent 2d0d606 commit e44681d

8 files changed

Lines changed: 1008 additions & 11 deletions

File tree

src/TangleStorage.sol

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,24 @@ abstract contract TangleStorage {
369369
/// @notice Request ID => Resource requirements for this service request
370370
mapping(uint64 => Types.ResourceCommitment[]) internal _requestResourceRequirements;
371371

372+
// ═══════════════════════════════════════════════════════════════════════════
373+
// TEE ATTESTATION COMMITMENTS
374+
// ═══════════════════════════════════════════════════════════════════════════
375+
376+
/// @notice Request ID => Operator => TEE attestation commitments captured at approval.
377+
/// @dev Transferred to `_serviceTeeCommitments` on activation; not used afterwards.
378+
mapping(uint64 => mapping(address => Types.TeeAttestationCommitment[])) internal _requestTeeCommitments;
379+
380+
/// @notice Service ID => Operator => TEE attestation commitments persisted at activation.
381+
/// @dev Read by blueprint contracts during their provisioning hooks to cross-check the
382+
/// live TEE attestation against the operator's on-chain commitment.
383+
mapping(uint64 => mapping(address => Types.TeeAttestationCommitment[])) internal _serviceTeeCommitments;
384+
372385
// ═══════════════════════════════════════════════════════════════════════════
373386
// RESERVED STORAGE GAP
374387
// ═══════════════════════════════════════════════════════════════════════════
375388

376389
/// @dev Reserved storage slots for future upgrades
377390
/// @dev Standard gap size is 50 slots. When adding new storage, decrease this gap accordingly.
378-
uint256[44] private __gap;
391+
uint256[42] private __gap;
379392
}

src/core/ServicesApprovals.sol

Lines changed: 167 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ abstract contract ServicesApprovals is Base {
2222
event ServiceApproved(uint64 indexed requestId, address indexed operator);
2323
event ServiceRejected(uint64 indexed requestId, address indexed operator);
2424

25+
/// @notice Emitted when an operator's TEE attestation commitment is recorded at approval.
26+
/// @param requestId The service request ID being approved.
27+
/// @param operator The approving operator (msg.sender).
28+
/// @param backend Which TEE backend the operator commits to.
29+
/// @param expectedMeasurement Measurement the live attestation must match off-chain.
30+
event TeeCommitmentRecorded(
31+
uint64 indexed requestId, address indexed operator, Types.TeeBackend backend, bytes32 expectedMeasurement
32+
);
33+
2534
// ═══════════════════════════════════════════════════════════════════════════
2635
// SERVICE APPROVAL
2736
// ═══════════════════════════════════════════════════════════════════════════
@@ -169,6 +178,159 @@ abstract contract ServicesApprovals is Base {
169178
_approveServiceWithCommitmentsInternal(requestId, commitments, blsPubkey);
170179
}
171180

181+
/// @notice Approve a service request with both security commitments, BLS public key,
182+
/// and TEE attestation commitments. Each TEE commitment is stored per-operator
183+
/// so blueprints can cross-check it against the live attestation produced when
184+
/// the workload provisions.
185+
/// @dev `teeCommitments` may be empty if the request does not require a TEE workload;
186+
/// otherwise every entry MUST set a non-`DirectTdx` backend and (if `expiresAt != 0`)
187+
/// a future expiry. The list is interpreted as the operator's set of acceptable
188+
/// attestation profiles — any one matching at provisioning time satisfies the policy.
189+
/// @param requestId The service request ID
190+
/// @param commitments Per-asset security commitments (matches `approveServiceWithCommitments`)
191+
/// @param blsPubkey BLS G2 pubkey [x0, x1, y0, y1] (zero pubkey allowed if BLS not used)
192+
/// @param popSignature G1 proof-of-possession (only validated when blsPubkey is non-zero)
193+
/// @param teeCommitments TEE attestation commitments to record for `msg.sender`
194+
function approveServiceWithTeeCommitments(
195+
uint64 requestId,
196+
Types.AssetSecurityCommitment[] calldata commitments,
197+
uint256[4] calldata blsPubkey,
198+
uint256[2] calldata popSignature,
199+
Types.TeeAttestationCommitment[] calldata teeCommitments
200+
)
201+
external
202+
whenNotPaused
203+
nonReentrant
204+
{
205+
// Authorize FIRST so an unauthorized caller never reaches the per-commitment
206+
// SSTORE loop. Pure validation (no state change) of the commitment shape and
207+
// BLS PoP follows; storage writes happen only after both gates pass.
208+
_requireApprovingOperator(requestId);
209+
_validateTeeCommitments(requestId, teeCommitments);
210+
if (_isNonZeroBlsPubkey(blsPubkey)) {
211+
_requireBlsProofOfPossession(msg.sender, blsPubkey, popSignature);
212+
}
213+
// Store TEE commitments BEFORE the internal approval flow triggers activation.
214+
// `_approveServiceWithCommitmentsInternal` will call `_activateService` when this
215+
// is the final approval, and the activation hook copies `_requestTeeCommitments`
216+
// into `_serviceTeeCommitments`. Order matters: write to the request first.
217+
_storeRequestTeeCommitments(requestId, msg.sender, teeCommitments);
218+
_approveServiceWithCommitmentsInternal(requestId, commitments, blsPubkey);
219+
}
220+
221+
/// @notice Per-operator cap on TEE attestation commitments at approval.
222+
/// @dev Cold SSTORE per pushed entry is ~20K gas across 3 slots (~60K gas/entry).
223+
/// Without a cap, a malicious operator can submit a list large enough to
224+
/// gas-brick `_persistTeeCommitments` during the final operator's activation
225+
/// call, permanently stalling the service. 8 is well above any realistic
226+
/// number of acceptable backends an operator would commit to.
227+
uint256 internal constant MAX_TEE_COMMITMENTS_PER_OPERATOR = 8;
228+
229+
/// @notice Maximum TTL on an operator's TEE attestation commitment.
230+
/// @dev Without an upper bound, a commitment with `expiresAt = type(uint64).max`
231+
/// is effectively never-expiring, which defeats the "expiry forces
232+
/// re-attestation" intent of the field. 90 days is enough headroom for any
233+
/// realistic service lifetime; longer-lived services should re-commit on
234+
/// a renewal cadence rather than pin a single attestation indefinitely.
235+
uint64 internal constant MAX_TEE_COMMITMENT_TTL = 90 days;
236+
237+
/// @notice Pre-flight authorization for an operator approving a request.
238+
/// @dev Mirrors the auth gates inside `_approveServiceWithCommitmentsInternal`
239+
/// (request-not-rejected, operator-active, operator-in-request, not-already-approved).
240+
/// Hoisted up so storage-mutating paths in newer entrypoints can fail fast.
241+
function _requireApprovingOperator(uint64 requestId) internal view {
242+
Types.ServiceRequest storage req = _getServiceRequest(requestId);
243+
if (req.rejected) revert Errors.ServiceRequestAlreadyProcessed(requestId);
244+
if (!_staking.isOperatorActive(msg.sender)) revert Errors.OperatorNotActive(msg.sender);
245+
246+
bool isOperator = false;
247+
address[] storage ops = _requestOperators[requestId];
248+
for (uint256 i = 0; i < ops.length; i++) {
249+
if (ops[i] == msg.sender) {
250+
isOperator = true;
251+
break;
252+
}
253+
}
254+
if (!isOperator) revert Errors.Unauthorized();
255+
256+
if (_requestApprovals[requestId][msg.sender]) {
257+
revert Errors.AlreadyApproved(requestId, msg.sender);
258+
}
259+
}
260+
261+
/// @notice Validate operator-supplied TEE attestation commitments.
262+
/// @dev Reverts on: list too long; `Unset` or `DirectTdx` backend; wrong
263+
/// `nonceBinding` (not the request-derived value); zero expected-measurement;
264+
/// expiry in the past or further out than `MAX_TEE_COMMITMENT_TTL`. Empty
265+
/// array is allowed (operator opts out of TEE binding for this approval).
266+
/// @param requestId The service request being approved — used to derive the
267+
/// canonical nonce that every commitment must carry.
268+
/// @param teeCommitments Operator-supplied TEE attestation commitments.
269+
function _validateTeeCommitments(
270+
uint64 requestId,
271+
Types.TeeAttestationCommitment[] calldata teeCommitments
272+
)
273+
internal
274+
view
275+
{
276+
if (teeCommitments.length > MAX_TEE_COMMITMENTS_PER_OPERATOR) {
277+
revert Errors.TooManyTeeCommitments(teeCommitments.length, MAX_TEE_COMMITMENTS_PER_OPERATOR);
278+
}
279+
bytes32 expectedNonce = teeNonceFor(requestId);
280+
uint64 nowTs = uint64(block.timestamp);
281+
uint64 maxExpiresAt = nowTs + MAX_TEE_COMMITMENT_TTL;
282+
for (uint256 i = 0; i < teeCommitments.length; i++) {
283+
Types.TeeBackend backend = teeCommitments[i].backend;
284+
if (backend == Types.TeeBackend.Unset) revert Errors.UnsetTeeBackend();
285+
if (backend == Types.TeeBackend.DirectTdx) revert Errors.DirectTdxNotPermitted();
286+
// The attestation document the operator commits to must contain the
287+
// request-derived nonce. The contract has no off-chain verifier so it
288+
// enforces the binding directly: any other value (including zero) is
289+
// rejected, eliminating cross-request replay at the source.
290+
if (teeCommitments[i].nonceBinding != expectedNonce) {
291+
revert Errors.InvalidNonceBinding();
292+
}
293+
// Zero measurement is not a real hash output. Either always-fail or
294+
// always-trivially-pass under the off-chain comparator — reject the
295+
// ambiguity at approval rather than discover it at provision time.
296+
if (teeCommitments[i].expectedMeasurement == bytes32(0)) {
297+
revert Errors.InvalidExpectedMeasurement();
298+
}
299+
uint64 expiresAt = teeCommitments[i].expiresAt;
300+
if (expiresAt != 0) {
301+
if (expiresAt <= nowTs) revert Errors.TeeCommitmentExpired(expiresAt, nowTs);
302+
if (expiresAt > maxExpiresAt) revert Errors.TeeCommitmentExpiryTooFar(expiresAt, maxExpiresAt);
303+
}
304+
}
305+
}
306+
307+
/// @notice Canonical TEE nonce binding for `requestId` on this chain/contract.
308+
/// @dev Operators must use exactly this value as `nonceBinding` in any
309+
/// `TeeAttestationCommitment` for the request. Anyone (operator, client,
310+
/// verifier, indexer) can derive it deterministically.
311+
/// @param requestId The service request ID.
312+
/// @return The 32-byte nonce that uniquely binds an attestation to this
313+
/// request on this contract on this chain.
314+
function teeNonceFor(uint64 requestId) public view returns (bytes32) {
315+
return keccak256(abi.encode("tangle.tee.nonce", requestId, address(this), block.chainid));
316+
}
317+
318+
/// @notice Persist validated TEE commitments and emit recording events.
319+
function _storeRequestTeeCommitments(
320+
uint64 requestId,
321+
address operator,
322+
Types.TeeAttestationCommitment[] calldata teeCommitments
323+
)
324+
internal
325+
{
326+
for (uint256 i = 0; i < teeCommitments.length; i++) {
327+
_requestTeeCommitments[requestId][operator].push(teeCommitments[i]);
328+
emit TeeCommitmentRecorded(
329+
requestId, operator, teeCommitments[i].backend, teeCommitments[i].expectedMeasurement
330+
);
331+
}
332+
}
333+
172334
/// @notice Internal implementation for approving with commitments and optional BLS key
173335
function _approveServiceWithCommitmentsInternal(
174336
uint64 requestId,
@@ -242,13 +404,7 @@ abstract contract ServicesApprovals is Base {
242404
/// to register a public key. Bound to chainId + verifying contract + operator
243405
/// address so a PoP from one chain or operator cannot be replayed.
244406
function blsPopMessage(address operator, uint256[4] memory blsPubkey) public view returns (bytes memory) {
245-
return abi.encode(
246-
"TANGLE_BLS_POP_v1",
247-
block.chainid,
248-
address(this),
249-
operator,
250-
blsPubkey
251-
);
407+
return abi.encode("TANGLE_BLS_POP_v1", block.chainid, address(this), operator, blsPubkey);
252408
}
253409

254410
/// @dev Reverts unless `popSignature` is a valid BLS G1 signature over `blsPopMessage`
@@ -258,7 +414,10 @@ abstract contract ServicesApprovals is Base {
258414
address operator,
259415
uint256[4] memory blsPubkey,
260416
uint256[2] memory popSignature
261-
) internal view {
417+
)
418+
internal
419+
view
420+
{
262421
if (!_isNonZeroBlsPubkey(blsPubkey)) revert Errors.InvalidBLSSignature();
263422

264423
bool ok = BN254.verifyBls(

src/facets/tangle/TangleServicesFacet.sol

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ contract TangleServicesFacet is ServicesApprovals, IFacetSelectors {
1616
using EnumerableSet for EnumerableSet.AddressSet;
1717

1818
function selectors() external pure returns (bytes4[] memory selectorList) {
19-
selectorList = new bytes4[](7);
19+
selectorList = new bytes4[](10);
2020
selectorList[0] = this.approveService.selector;
2121
selectorList[1] = bytes4(keccak256("approveServiceWithCommitments(uint64,((uint8,address),uint16)[])"));
2222
selectorList[2] = this.rejectService.selector;
@@ -26,6 +26,33 @@ contract TangleServicesFacet is ServicesApprovals, IFacetSelectors {
2626
);
2727
selectorList[5] = this.getOperatorBlsPubkey.selector;
2828
selectorList[6] = this.blsPopMessage.selector;
29+
selectorList[7] = bytes4(
30+
keccak256(
31+
"approveServiceWithTeeCommitments(uint64,((uint8,address),uint16)[],uint256[4],uint256[2],(uint8,bytes32,bytes32,uint64)[])"
32+
)
33+
);
34+
selectorList[8] = this.getTeeCommitment.selector;
35+
selectorList[9] = this.teeNonceFor.selector;
36+
}
37+
38+
/// @notice Read the operator's TEE attestation commitments for a service.
39+
/// @dev Empty array if the operator approved without TEE commitments.
40+
/// @param serviceId The active service ID
41+
/// @param operator The operator whose commitments to read
42+
/// @return commitments Array of recorded TEE commitments (matches storage order)
43+
function getTeeCommitment(
44+
uint64 serviceId,
45+
address operator
46+
)
47+
external
48+
view
49+
returns (Types.TeeAttestationCommitment[] memory commitments)
50+
{
51+
Types.TeeAttestationCommitment[] storage stored = _serviceTeeCommitments[serviceId][operator];
52+
commitments = new Types.TeeAttestationCommitment[](stored.length);
53+
for (uint256 i = 0; i < stored.length; i++) {
54+
commitments[i] = stored[i];
55+
}
2956
}
3057

3158
/// @notice Get operator's BLS public key for a service
@@ -66,6 +93,9 @@ contract TangleServicesFacet is ServicesApprovals, IFacetSelectors {
6693
// Persist resource commitments from request to service (hash per operator)
6794
_persistResourceCommitments(serviceId, requestId);
6895

96+
// Persist TEE attestation commitments from request to service (per operator).
97+
_persistTeeCommitments(serviceId, requestId);
98+
6999
(uint16[] memory exposures, uint256 totalExposure) = _assignOperatorsFromRequest(serviceId, requestId);
70100

71101
_grantPermittedCallers(serviceId, requestId, req.requester);
@@ -301,4 +331,19 @@ contract TangleServicesFacet is ServicesApprovals, IFacetSelectors {
301331
}
302332
}
303333
}
334+
335+
/// @notice Copy TEE attestation commitments from request to service for every operator.
336+
/// @dev Skips operators that did not record commitments at approval time.
337+
function _persistTeeCommitments(uint64 serviceId, uint64 requestId) private {
338+
address[] storage requestOperators = _requestOperators[requestId];
339+
for (uint256 i = 0; i < requestOperators.length; i++) {
340+
address op = requestOperators[i];
341+
Types.TeeAttestationCommitment[] storage src = _requestTeeCommitments[requestId][op];
342+
uint256 len = src.length;
343+
if (len == 0) continue;
344+
for (uint256 j = 0; j < len; j++) {
345+
_serviceTeeCommitments[serviceId][op].push(src[j]);
346+
}
347+
}
348+
}
304349
}

src/interfaces/ITangleServices.sol

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ interface ITangleServices {
131131
uint8 stakingPercent,
132132
uint256[4] calldata blsPubkey,
133133
uint256[2] calldata popSignature
134-
) external;
134+
)
135+
external;
135136

136137
/// @notice Approve a service request with both security commitments and BLS public key
137138
/// @param requestId The service request ID
@@ -146,6 +147,38 @@ interface ITangleServices {
146147
)
147148
external;
148149

150+
/// @notice Approve a service request with security commitments, BLS pubkey, and TEE
151+
/// attestation commitments. Each commitment binds the operator to a specific
152+
/// backend + measurement that must match the live attestation off-chain.
153+
/// @param requestId The service request ID
154+
/// @param commitments Security commitments matching the request requirements
155+
/// @param blsPubkey The operator's BLS G2 public key (zero pubkey allowed if BLS unused)
156+
/// @param popSignature G1 proof-of-possession signature (only validated when blsPubkey != 0)
157+
/// @param teeCommitments TEE attestation commitments to record for the caller
158+
function approveServiceWithTeeCommitments(
159+
uint64 requestId,
160+
Types.AssetSecurityCommitment[] calldata commitments,
161+
uint256[4] calldata blsPubkey,
162+
uint256[2] calldata popSignature,
163+
Types.TeeAttestationCommitment[] calldata teeCommitments
164+
)
165+
external;
166+
167+
/// @notice Read recorded TEE commitments for an operator on an active service.
168+
function getTeeCommitment(
169+
uint64 serviceId,
170+
address operator
171+
)
172+
external
173+
view
174+
returns (Types.TeeAttestationCommitment[] memory);
175+
176+
/// @notice Canonical TEE attestation nonce binding for `requestId` on this
177+
/// contract on this chain. Operators MUST submit this exact value as
178+
/// `nonceBinding` in any `TeeAttestationCommitment` for the request —
179+
/// it eliminates cross-request attestation replay at approval time.
180+
function teeNonceFor(uint64 requestId) external view returns (bytes32);
181+
149182
/// @notice Build the canonical message an operator must sign with their BLS secret key
150183
/// to register a public key. Bound to chainId + verifying contract + operator.
151184
function blsPopMessage(address operator, uint256[4] memory blsPubkey) external view returns (bytes memory);

0 commit comments

Comments
 (0)