From 1ec0d4b3ceea5607a3d2620176003093ba809721 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 7 Nov 2026 08:11:52 -0800 Subject: [PATCH 1/8] feat(auth-eth): scope material allowlists to exact key epochs Signed-off-by: Kevin Wang --- docs/app-key-epochs.md | 75 ++++++- dstack/kms/auth-eth/README.md | 13 ++ dstack/kms/auth-eth/contracts/DstackApp.sol | 147 +++++++++++--- dstack/kms/auth-eth/contracts/DstackKms.sol | 181 +++++++++++++---- .../kms/auth-eth/contracts/IAppEpochAuth.sol | 20 ++ dstack/kms/auth-eth/script/Manage.s.sol | 66 +++++- dstack/kms/auth-eth/test/DstackApp.t.sol | 136 +++++++++++++ dstack/kms/auth-eth/test/DstackKms.t.sol | 190 ++++++++++++++++-- dstack/kms/src/main_service.rs | 3 +- .../kms/src/main_service/upgrade_authority.rs | 13 +- 10 files changed, 730 insertions(+), 114 deletions(-) create mode 100644 dstack/kms/auth-eth/contracts/IAppEpochAuth.sol diff --git a/docs/app-key-epochs.md b/docs/app-key-epochs.md index d1be0d5c3..fe3413fd9 100644 --- a/docs/app-key-epochs.md +++ b/docs/app-key-epochs.md @@ -6,11 +6,11 @@ keys or other apps. This extends SealedEnv (#1528); the PR dependency order is ## Authorization and rotation -`checkAppAuthorization` returns `currentAppEpoch` together with the current root +`authorizeApp` returns `currentAppEpoch` together with the current root epoch and its chain hash. `requestedRootEpoch` and `requestedAppEpoch` are -independent `OptionalEpoch` values: omitted selects current, explicit zero selects +independent `OptionalUint256` values: omitted selects current, explicit zero selects historical epoch zero. Future epochs are denied through `rejectionReason`. -`checkKmsAuthorization` remains separate and uses app epoch zero. +`authorizeKms` remains separate and uses app epoch zero. `DstackApp.currentAppEpoch` starts at zero. Its owner calls `advanceAppEpoch(expectedCurrentEpoch)` to increment it. The expected value @@ -28,6 +28,70 @@ value. Legacy boot authorization and legacy environment public-key issuance are rejected once the app epoch is nonzero, preventing old clients from silently continuing with epoch-zero keys. +## Epoch-scoped allowlists + +Material approvals are an exact `epoch × material` matrix, not a maximum epoch. +Authorization checks only the selected row, with O(1) add, removal and lookup: + +- `DstackKms`: OS images, KMS aggregated measurements and KMS device IDs are + indexed by **root epoch**. OS image limits also apply to app key requests. +- `DstackApp`: compose hashes, device IDs and `allowAnyDevice` are indexed by + **app epoch**. Each required material must be allowed in the selected row. + +There is no implicit access in either direction. Rotating keys starts an empty +row; approving a new row neither copies old approvals nor grants historical keys. +Root and app selectors remain independent. Omitted selectors select the current +epochs. For an image handover, explicitly approve the new image in the old row so +it can migrate old keys, approve it in the new row, and remove the old image from +the old row after the handover. The old image never needs access to the new row. + +Existing add/remove methods operate on the highest epoch: the latest **registered** +root epoch (including a pending epoch), or the current app epoch. Before any root +epoch is registered, they operate on epoch zero. The `*AtEpoch(epoch, material)` +methods select a particular registered root/current-or-historical app epoch; +removal affects only that row. `setAllowAnyDeviceAtEpoch(epoch, allowed)` is also +row-specific. Single-material add/remove scripts in `Manage.s.sol` accept optional +`ROOT_EPOCH` or `APP_EPOCH` variables, respectively, as does `SetAllowAnyDevice`. + +The `*AtEpoch` getters read the selected row, including legacy epoch zero. +`EpochPolicyChanged` events include the row and the resulting permission. Legacy +bool getters and events describe **epoch zero only**; changing a nonzero row does +not change its legacy bool or emit a misleading legacy material event. +Pending root epoch numbers may be popped and reused; policy rows belong to epoch +numbers, not key hashes. Review or clear a pending row before activating a +replacement epoch with the same number. + +### KMS keyring handover + +Keyring handover is deliberately different from app key access: approval in the +**highest exported root epoch** authorizes the entire keyring, including all older +roots. The KMS passes that epoch explicitly to `authorizeKms`, so approval in the +current row cannot accidentally authorize a newer pending keyring. A registered +pending root may be authorized for KMS handover before activation; app key requests +still cannot select it. KMS self-authorization without a selector continues to +check the active root epoch. This permits staged key distribution without changing +which epoch the KMS serves. + +### Upgrading existing proxies + +The original bool mappings and `allowAnyDevice` retain their storage locations and +are used directly as row zero. New nested mappings consume three storage-gap slots +in each contract; no entries are copied and no reinitializer is required. Existing +owners, pending owners, policy flags, root registries and app epoch counters are +preserved. Even if the pre-upgrade counter is nonzero, old global approvals grant +only row zero after upgrading: explicitly populate the intended nonzero rows. + +Upgrade the KMS policy before app policies, then approve the intended rows before +restarting guests or activating a new root. `DstackApp` advertises the optional +ERC-165 `IAppEpochAuth` capability so the KMS can pass the selected app epoch to +`isAppAllowedAtEpoch`. Policies without that capability can only authorize app +epoch zero; failures of an advertised method propagate instead of falling back. +Direct legacy app checks use the current app epoch; legacy KMS app authorization +still rejects nonzero app epochs. + +These material matrices are implemented by the Solidity policies. Mock/simple auth +backends retain their own policy model and may reject handover of pending epochs. + ## Derivation and lifetime Every key is identified by `(root_epoch, app_epoch)`. App epoch zero keeps existing derived keys and app-key signatures. Nonzero app epochs append @@ -42,8 +106,9 @@ returned root epoch. Epoch zero keeps the original `dstack-kms-issued` format. Restart CVMs after advancing the policy. A running CVM continues to use its boot key pair; omitted guest-agent GetKey selectors mean that CVM's boot epochs, not a fresh chain lookup. Explicit historical requests select each dimension -independently and cache by the complete pair. Historical access still requires -current app authorization. Supplied `trusted_ca` always pins the KMS connection, +independently and cache by the complete pair. Historical requests must be approved +in the selected root/app rows. Revoking one row does not revoke other rows or +keys already delivered. Supplied `trusted_ca` always pins the KMS connection, regardless of epoch selection; historical responses cannot replace that pin. The guest migrates disk headers and environment ciphertext if either dimension diff --git a/dstack/kms/auth-eth/README.md b/dstack/kms/auth-eth/README.md index 85eb6a52e..5a6fb78c9 100644 --- a/dstack/kms/auth-eth/README.md +++ b/dstack/kms/auth-eth/README.md @@ -192,3 +192,16 @@ remain available. [KMS authorization protocol](../README.md#authorization-protocol) for the names and subject semantics. Bootstrap alone may authorize before root registration; unknown operations are rejected. Legacy `/bootAuth/*` routes are unchanged. + + +### Epoch-scoped material approvals + +Material approvals use exact root/app epoch rows; neither older nor newer rows +are implicitly authorized. Existing add/remove methods default to the highest +registered root epoch or current app epoch. The `*AtEpoch` methods and optional +`ROOT_EPOCH` / `APP_EPOCH` variables on single-material management scripts select +an explicit row. Original bool mappings and events remain epoch-zero views after +upgrading; epoch-specific getters/events describe the new rows. KMS keyring +handover separately checks its highest exported root epoch, which authorizes the +whole keyring. See [epoch-scoped allowlists](../../../docs/app-key-epochs.md#epoch-scoped-allowlists) +for migration, upgrade order and compatibility. diff --git a/dstack/kms/auth-eth/contracts/DstackApp.sol b/dstack/kms/auth-eth/contracts/DstackApp.sol index 951fe7868..a8a9f63e9 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -8,6 +8,7 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; import "./IAppEpoch.sol"; +import "./IAppEpochAuth.sol"; import "./IAppAuthBasicManagement.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; @@ -21,7 +22,8 @@ contract DstackApp is ERC165Upgradeable, IAppAuth, IAppAuthBasicManagement, - IAppEpoch + IAppEpoch, + IAppEpochAuth { // Mapping of allowed compose hashes for this app mapping(bytes32 => bool) public allowedComposeHashes; @@ -41,6 +43,13 @@ contract DstackApp is uint256 public override currentAppEpoch; event AppEpochAdvanced(uint256 indexed epoch); + // Epoch zero continues to use the original fields above after an upgrade. + mapping(uint256 => mapping(bytes32 => bool)) private _composeHashesByEpoch; + mapping(uint256 => mapping(bytes32 => bool)) private _deviceIdsByEpoch; + mapping(uint256 => bool) private _allowAnyDeviceByEpoch; + + event EpochPolicyChanged(bytes32 indexed policy, bytes32 indexed value, uint256 indexed epoch, bool enabled); + // Additional events specific to DstackApp event UpgradesDisabled(); event AllowAnyDeviceSet(bool allowAny); @@ -133,7 +142,8 @@ contract DstackApp is override(ERC165Upgradeable, IERC165) returns (bool) { - return interfaceId == type(IAppEpoch).interfaceId || interfaceId == 0x1e079198 // IAppAuth + return interfaceId == type(IAppEpochAuth).interfaceId || interfaceId == type(IAppEpoch).interfaceId + || interfaceId == 0x1e079198 // IAppAuth || interfaceId == 0x8fd37527 // IAppAuthBasicManagement || super.supportsInterface(interfaceId); } @@ -148,26 +158,62 @@ contract DstackApp is emit PolicyChanged(msg.sender, keccak256(bytes(policy)), value, enabled); } - // Add a compose hash to allowed list - function addComposeHash(bytes32 composeHash) external onlyOwner { + function allowedComposeHashesAtEpoch(uint256 epoch, bytes32 composeHash) public view returns (bool) { + return epoch == 0 ? allowedComposeHashes[composeHash] : _composeHashesByEpoch[epoch][composeHash]; + } + + function allowedDeviceIdsAtEpoch(uint256 epoch, bytes32 deviceId) public view returns (bool) { + return epoch == 0 ? allowedDeviceIds[deviceId] : _deviceIdsByEpoch[epoch][deviceId]; + } + + function allowAnyDeviceAtEpoch(uint256 epoch) public view returns (bool) { + return epoch == 0 ? allowAnyDevice : _allowAnyDeviceByEpoch[epoch]; + } + + function addComposeHash(bytes32 composeHash) external { + addComposeHashAtEpoch(currentAppEpoch, composeHash); + } + + function addComposeHashAtEpoch(uint256 epoch, bytes32 composeHash) public onlyOwner { require(composeHash != bytes32(0), "invalid compose hash"); - allowedComposeHashes[composeHash] = true; - emit ComposeHashAdded(composeHash); - _emitPolicy("compose-hash", composeHash, true); + _setComposeHash(epoch, composeHash, true); } - // Remove a compose hash from allowed list - function removeComposeHash(bytes32 composeHash) external onlyOwner { - allowedComposeHashes[composeHash] = false; - emit ComposeHashRemoved(composeHash); - _emitPolicy("compose-hash", composeHash, false); + function removeComposeHash(bytes32 composeHash) external { + removeComposeHashAtEpoch(currentAppEpoch, composeHash); } - // Set whether any device is allowed to boot this app - function setAllowAnyDevice(bool _allowAnyDevice) external onlyOwner { - allowAnyDevice = _allowAnyDevice; - emit AllowAnyDeviceSet(_allowAnyDevice); - _emitPolicy("allow-any-device", bytes32(0), _allowAnyDevice); + function removeComposeHashAtEpoch(uint256 epoch, bytes32 composeHash) public onlyOwner { + _setComposeHash(epoch, composeHash, false); + } + + function _setComposeHash(uint256 epoch, bytes32 composeHash, bool allowed) private { + require(epoch <= currentAppEpoch, "Unknown app epoch"); + if (epoch == 0) { + allowedComposeHashes[composeHash] = allowed; + if (allowed) emit ComposeHashAdded(composeHash); + else emit ComposeHashRemoved(composeHash); + _emitPolicy("compose-hash", composeHash, allowed); + } else { + _composeHashesByEpoch[epoch][composeHash] = allowed; + } + emit EpochPolicyChanged(keccak256("compose-hash"), composeHash, epoch, allowed); + } + + function setAllowAnyDevice(bool allowed) external { + setAllowAnyDeviceAtEpoch(currentAppEpoch, allowed); + } + + function setAllowAnyDeviceAtEpoch(uint256 epoch, bool allowed) public onlyOwner { + require(epoch <= currentAppEpoch, "Unknown app epoch"); + if (epoch == 0) { + allowAnyDevice = allowed; + emit AllowAnyDeviceSet(allowed); + _emitPolicy("allow-any-device", bytes32(0), allowed); + } else { + _allowAnyDeviceByEpoch[epoch] = allowed; + } + emit EpochPolicyChanged(keccak256("allow-any-device"), bytes32(0), epoch, allowed); } // Set whether TCB status must be UpToDate to boot this app @@ -177,28 +223,67 @@ contract DstackApp is _emitPolicy("require-tcb-up-to-date", bytes32(0), _requireUpToDate); } - // Add a device ID to allowed list - function addDevice(bytes32 deviceId) external onlyOwner { + function addDevice(bytes32 deviceId) external { + addDeviceAtEpoch(currentAppEpoch, deviceId); + } + + function addDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { require(deviceId != bytes32(0), "invalid device ID"); - allowedDeviceIds[deviceId] = true; - emit DeviceAdded(deviceId); - _emitPolicy("device", deviceId, true); + _setDevice(epoch, deviceId, true); + } + + function removeDevice(bytes32 deviceId) external { + removeDeviceAtEpoch(currentAppEpoch, deviceId); + } + + function removeDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + _setDevice(epoch, deviceId, false); } - // Remove a device ID from allowed list - function removeDevice(bytes32 deviceId) external onlyOwner { - allowedDeviceIds[deviceId] = false; - emit DeviceRemoved(deviceId); - _emitPolicy("device", deviceId, false); + function _setDevice(uint256 epoch, bytes32 deviceId, bool allowed) private { + require(epoch <= currentAppEpoch, "Unknown app epoch"); + if (epoch == 0) { + allowedDeviceIds[deviceId] = allowed; + if (allowed) emit DeviceAdded(deviceId); + else emit DeviceRemoved(deviceId); + _emitPolicy("device", deviceId, allowed); + } else { + _deviceIdsByEpoch[epoch][deviceId] = allowed; + } + emit EpochPolicyChanged(keccak256("device"), deviceId, epoch, allowed); } - // Check if an app is allowed to boot + // Legacy callers authorize the current epoch, never an older policy implicitly. function isAppAllowed(IAppAuth.AppInfo calldata bootInfo) external view override returns (bool isAllowed, string memory reason) { + return _isAppAllowedAtEpoch(bootInfo, currentAppEpoch); + } + + function isAppAllowedAtEpoch( + IAppAuth.AppInfo calldata bootInfo, + uint256 appEpoch + ) + external + view + override + returns (bool isAllowed, string memory reason) + { + return _isAppAllowedAtEpoch(bootInfo, appEpoch); + } + + function _isAppAllowedAtEpoch( + IAppAuth.AppInfo calldata bootInfo, + uint256 appEpoch + ) + private + view + returns (bool, string memory) + { + if (appEpoch > currentAppEpoch) return (false, "Requested app epoch is not active"); // Optionally require TCB status to be up to date if ( requireTcbUpToDate @@ -208,12 +293,12 @@ contract DstackApp is } // Check if compose hash is allowed - if (!allowedComposeHashes[bootInfo.composeHash]) { + if (!allowedComposeHashesAtEpoch(appEpoch, bootInfo.composeHash)) { return (false, "Compose hash not allowed"); } // Check if device is allowed (when device restriction is enabled) - if (!allowAnyDevice && !allowedDeviceIds[bootInfo.deviceId]) { + if (!allowAnyDeviceAtEpoch(appEpoch) && !allowedDeviceIdsAtEpoch(appEpoch, bootInfo.deviceId)) { return (false, "Device not allowed"); } @@ -237,5 +322,5 @@ contract DstackApp is } // Add storage gap for upgradeable contracts - uint256[48] private __gap; + uint256[45] private __gap; } diff --git a/dstack/kms/auth-eth/contracts/DstackKms.sol b/dstack/kms/auth-eth/contracts/DstackKms.sol index 77b3e2fbc..0ca19e539 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -9,6 +9,7 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; import "./AuthorizationOperation.sol"; import "./IAppEpoch.sol"; +import "./IAppEpochAuth.sol"; import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; @@ -25,7 +26,6 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E struct AuthorizationRequest { AppInfo appInfo; bytes32 operation; - // Reserved for application key rotation; only zero is currently supported. OptionalUint256 requestedAppEpoch; OptionalUint256 requestedRootEpoch; } @@ -95,6 +95,13 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E // later epochs take effect only when activated, one at a time. uint256 private _currentRootEpoch; + // Epoch zero continues to use the original fields above after an upgrade. + mapping(uint256 => mapping(bytes32 => bool)) private _kmsAggregatedMrsByEpoch; + mapping(uint256 => mapping(bytes32 => bool)) private _kmsDeviceIdsByEpoch; + mapping(uint256 => mapping(bytes32 => bool)) private _osImagesByEpoch; + + event EpochPolicyChanged(bytes32 indexed policy, bytes32 indexed value, uint256 indexed epoch, bool enabled); + // Events // appId is unindexed for backward compatibility with deployed log indexers. // slither-disable-next-line unindexed-event-address @@ -353,46 +360,107 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E ); } - // Function to register an aggregated MR measurement - function addKmsAggregatedMr(bytes32 mrAggregated) external onlyOwner { - kmsAllowedAggregatedMrs[mrAggregated] = true; - emit KmsAggregatedMrAdded(mrAggregated); - _emitPolicy("kms-aggregated-mr", mrAggregated, true); + function kmsAllowedAggregatedMrsAtEpoch(uint256 epoch, bytes32 mrAggregated) public view returns (bool) { + return epoch == 0 ? kmsAllowedAggregatedMrs[mrAggregated] : _kmsAggregatedMrsByEpoch[epoch][mrAggregated]; + } + + function addKmsAggregatedMr(bytes32 mrAggregated) external { + addKmsAggregatedMrAtEpoch(_latestRootEpoch(), mrAggregated); + } + + function addKmsAggregatedMrAtEpoch(uint256 epoch, bytes32 mrAggregated) public onlyOwner { + _setKmsAggregatedMr(epoch, mrAggregated, true); + } + + function removeKmsAggregatedMr(bytes32 mrAggregated) external { + removeKmsAggregatedMrAtEpoch(_latestRootEpoch(), mrAggregated); + } + + function removeKmsAggregatedMrAtEpoch(uint256 epoch, bytes32 mrAggregated) public onlyOwner { + _setKmsAggregatedMr(epoch, mrAggregated, false); + } + + function _setKmsAggregatedMr(uint256 epoch, bytes32 mrAggregated, bool allowed) private { + require(epoch <= _latestRootEpoch(), "Unknown root epoch"); + if (epoch == 0) { + kmsAllowedAggregatedMrs[mrAggregated] = allowed; + if (allowed) emit KmsAggregatedMrAdded(mrAggregated); + else emit KmsAggregatedMrRemoved(mrAggregated); + _emitPolicy("kms-aggregated-mr", mrAggregated, allowed); + } else { + _kmsAggregatedMrsByEpoch[epoch][mrAggregated] = allowed; + } + emit EpochPolicyChanged(keccak256("kms-aggregated-mr"), mrAggregated, epoch, allowed); + } + + function kmsAllowedDeviceIdsAtEpoch(uint256 epoch, bytes32 deviceId) public view returns (bool) { + return epoch == 0 ? kmsAllowedDeviceIds[deviceId] : _kmsDeviceIdsByEpoch[epoch][deviceId]; + } + + function addKmsDevice(bytes32 deviceId) external { + addKmsDeviceAtEpoch(_latestRootEpoch(), deviceId); + } + + function addKmsDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + _setKmsDevice(epoch, deviceId, true); } - // Function to deregister an aggregated MR measurement - function removeKmsAggregatedMr(bytes32 mrAggregated) external onlyOwner { - kmsAllowedAggregatedMrs[mrAggregated] = false; - emit KmsAggregatedMrRemoved(mrAggregated); - _emitPolicy("kms-aggregated-mr", mrAggregated, false); + function removeKmsDevice(bytes32 deviceId) external { + removeKmsDeviceAtEpoch(_latestRootEpoch(), deviceId); } - // Function to register a KMS device ID - function addKmsDevice(bytes32 deviceId) external onlyOwner { - kmsAllowedDeviceIds[deviceId] = true; - emit KmsDeviceAdded(deviceId); - _emitPolicy("kms-device", deviceId, true); + function removeKmsDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + _setKmsDevice(epoch, deviceId, false); } - // Function to deregister a KMS device ID - function removeKmsDevice(bytes32 deviceId) external onlyOwner { - kmsAllowedDeviceIds[deviceId] = false; - emit KmsDeviceRemoved(deviceId); - _emitPolicy("kms-device", deviceId, false); + function _setKmsDevice(uint256 epoch, bytes32 deviceId, bool allowed) private { + require(epoch <= _latestRootEpoch(), "Unknown root epoch"); + if (epoch == 0) { + kmsAllowedDeviceIds[deviceId] = allowed; + if (allowed) emit KmsDeviceAdded(deviceId); + else emit KmsDeviceRemoved(deviceId); + _emitPolicy("kms-device", deviceId, allowed); + } else { + _kmsDeviceIdsByEpoch[epoch][deviceId] = allowed; + } + emit EpochPolicyChanged(keccak256("kms-device"), deviceId, epoch, allowed); } - // Function to register an image measurement - function addOsImageHash(bytes32 osImageHash) external onlyOwner { - allowedOsImages[osImageHash] = true; - emit OsImageHashAdded(osImageHash); - _emitPolicy("os-image", osImageHash, true); + function allowedOsImagesAtEpoch(uint256 epoch, bytes32 osImageHash) public view returns (bool) { + return epoch == 0 ? allowedOsImages[osImageHash] : _osImagesByEpoch[epoch][osImageHash]; } - // Function to deregister an image measurement - function removeOsImageHash(bytes32 osImageHash) external onlyOwner { - allowedOsImages[osImageHash] = false; - emit OsImageHashRemoved(osImageHash); - _emitPolicy("os-image", osImageHash, false); + function addOsImageHash(bytes32 osImageHash) external { + addOsImageHashAtEpoch(_latestRootEpoch(), osImageHash); + } + + function addOsImageHashAtEpoch(uint256 epoch, bytes32 osImageHash) public onlyOwner { + _setOsImageHash(epoch, osImageHash, true); + } + + function removeOsImageHash(bytes32 osImageHash) external { + removeOsImageHashAtEpoch(_latestRootEpoch(), osImageHash); + } + + function removeOsImageHashAtEpoch(uint256 epoch, bytes32 osImageHash) public onlyOwner { + _setOsImageHash(epoch, osImageHash, false); + } + + function _setOsImageHash(uint256 epoch, bytes32 osImageHash, bool allowed) private { + require(epoch <= _latestRootEpoch(), "Unknown root epoch"); + if (epoch == 0) { + allowedOsImages[osImageHash] = allowed; + if (allowed) emit OsImageHashAdded(osImageHash); + else emit OsImageHashRemoved(osImageHash); + _emitPolicy("os-image", osImageHash, allowed); + } else { + _osImagesByEpoch[epoch][osImageHash] = allowed; + } + emit EpochPolicyChanged(keccak256("os-image"), osImageHash, epoch, allowed); + } + + function _latestRootEpoch() private view returns (uint256) { + return _rootEpochs.length == 0 ? 0 : _rootEpochs.length - 1; } /// Legacy policies have epoch zero; an advertised epoch getter must succeed. @@ -437,8 +505,10 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E result.currentRootEpoch = _currentRootEpoch; result.currentRootEpochHash = _rootEpochs[_currentRootEpoch].chainHash; } - if (request.requestedRootEpoch.isSet && request.requestedRootEpoch.value > _currentRootEpoch) { - result.rejectionReason = "Requested root epoch is not active"; + uint256 latestAllowedRoot = isKms ? _latestRootEpoch() : _currentRootEpoch; + if (request.requestedRootEpoch.isSet && request.requestedRootEpoch.value > latestAllowedRoot) { + result.rejectionReason = + isKms ? "Requested root epoch is not registered" : "Requested root epoch is not active"; return result; } if (request.requestedAppEpoch.isSet && request.requestedAppEpoch.value > result.currentAppEpoch) { @@ -457,36 +527,46 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E tcbStatus: appInfo.tcbStatus, advisoryIds: appInfo.advisoryIds }); + uint256 selectedRootEpoch = + request.requestedRootEpoch.isSet ? request.requestedRootEpoch.value : _currentRootEpoch; + uint256 appEpoch = request.requestedAppEpoch.isSet ? request.requestedAppEpoch.value : result.currentAppEpoch; if (isKms) { - (result.isAllowed, result.rejectionReason) = _isKmsAllowed(bootInfo); + (result.isAllowed, result.rejectionReason) = _isKmsAllowed(bootInfo, selectedRootEpoch); } else { - (result.isAllowed, result.rejectionReason) = _isAppAllowed(bootInfo); + (result.isAllowed, result.rejectionReason) = _isAppAllowed(bootInfo, selectedRootEpoch, appEpoch); } } // Function to check if KMS is allowed to boot function isKmsAllowed(AppInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason) { - return _isKmsAllowed(bootInfo); + return _isKmsAllowed(bootInfo, _currentRootEpoch); } - function _isKmsAllowed(AppInfo memory bootInfo) private view returns (bool, string memory) { + function _isKmsAllowed( + AppInfo memory bootInfo, + uint256 selectedRootEpoch + ) + private + view + returns (bool, string memory) + { // Check if the TCB status is up to date if (keccak256(abi.encodePacked(bootInfo.tcbStatus)) != keccak256(abi.encodePacked("UpToDate"))) { return (false, "TCB status is not up to date"); } // Check if the OS image is allowed - if (!allowedOsImages[bootInfo.osImageHash]) { + if (!allowedOsImagesAtEpoch(selectedRootEpoch, bootInfo.osImageHash)) { return (false, "OS image is not allowed"); } // Check if the aggregated MR is allowed - if (!kmsAllowedAggregatedMrs[bootInfo.mrAggregated]) { + if (!kmsAllowedAggregatedMrsAtEpoch(selectedRootEpoch, bootInfo.mrAggregated)) { return (false, "Aggregated MR not allowed"); } // Check if the KMS device ID is allowed - if (!kmsAllowedDeviceIds[bootInfo.deviceId]) { + if (!kmsAllowedDeviceIdsAtEpoch(selectedRootEpoch, bootInfo.deviceId)) { return (false, "KMS is not allowed to boot on this device"); } @@ -503,16 +583,24 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E if (currentAppEpoch(bootInfo.appId) != 0) { return (false, "App epoch requires epoch-aware authorization"); } - return _isAppAllowed(bootInfo); + return _isAppAllowed(bootInfo, _currentRootEpoch, 0); } - function _isAppAllowed(AppInfo memory bootInfo) private view returns (bool, string memory) { + function _isAppAllowed( + AppInfo memory bootInfo, + uint256 selectedRootEpoch, + uint256 appEpoch + ) + private + view + returns (bool, string memory) + { // Check if app is registered if (!registeredApps[bootInfo.appId]) { return (false, "App not registered"); } // Check if the OS image is allowed - if (!allowedOsImages[bootInfo.osImageHash]) { + if (!allowedOsImagesAtEpoch(selectedRootEpoch, bootInfo.osImageHash)) { return (false, "OS image is not allowed"); } @@ -521,6 +609,11 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E return (false, "App not deployed or invalid address"); } + if (ERC165Checker.supportsInterface(bootInfo.appId, type(IAppEpochAuth).interfaceId)) { + return IAppEpochAuth(bootInfo.appId).isAppAllowedAtEpoch(bootInfo, appEpoch); + } + if (appEpoch != 0) return (false, "App policy does not support epoch authorization"); + // Call the app's isAppAllowed function. The tuple is forwarded as the // function's return value; the slither detector trips on the named // return + forward pattern but the value is actually used. @@ -529,7 +622,7 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E } // Add storage gap for upgradeable contracts - uint256[48] private __gap; + uint256[45] private __gap; } function isContract(address addr) view returns (bool) { diff --git a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol new file mode 100644 index 000000000..aa54d251b --- /dev/null +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "./IAppAuth.sol"; + +/// Optional epoch-aware policy capability. Legacy policies only authorize app epoch zero. +interface IAppEpochAuth { + function isAppAllowedAtEpoch( + IAppAuth.AppInfo calldata bootInfo, + uint256 appEpoch + ) + external + view + returns (bool isAllowed, string memory reason); +} diff --git a/dstack/kms/auth-eth/script/Manage.s.sol b/dstack/kms/auth-eth/script/Manage.s.sol index be924ecf2..86db680d6 100644 --- a/dstack/kms/auth-eth/script/Manage.s.sol +++ b/dstack/kms/auth-eth/script/Manage.s.sol @@ -33,7 +33,11 @@ contract AddKmsAggregatedMr is BaseScript { bytes32 mrAggregated = vm.envBytes32("MR_AGGREGATED"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.addKmsAggregatedMr(mrAggregated); + if (vm.envExists("ROOT_EPOCH")) { + kms.addKmsAggregatedMrAtEpoch(vm.envUint("ROOT_EPOCH"), mrAggregated); + } else { + kms.addKmsAggregatedMr(mrAggregated); + } vm.stopBroadcast(); console.log("Added KMS aggregated MR:", vm.toString(mrAggregated)); @@ -45,7 +49,11 @@ contract RemoveKmsAggregatedMr is BaseScript { bytes32 mrAggregated = vm.envBytes32("MR_AGGREGATED"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.removeKmsAggregatedMr(mrAggregated); + if (vm.envExists("ROOT_EPOCH")) { + kms.removeKmsAggregatedMrAtEpoch(vm.envUint("ROOT_EPOCH"), mrAggregated); + } else { + kms.removeKmsAggregatedMr(mrAggregated); + } vm.stopBroadcast(); console.log("Removed KMS aggregated MR:", vm.toString(mrAggregated)); @@ -57,7 +65,11 @@ contract AddOsImage is BaseScript { bytes32 imageHash = vm.envBytes32("OS_IMAGE_HASH"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.addOsImageHash(imageHash); + if (vm.envExists("ROOT_EPOCH")) { + kms.addOsImageHashAtEpoch(vm.envUint("ROOT_EPOCH"), imageHash); + } else { + kms.addOsImageHash(imageHash); + } vm.stopBroadcast(); console.log("Added OS image hash:", vm.toString(imageHash)); @@ -69,7 +81,11 @@ contract AddKmsDevice is BaseScript { bytes32 deviceId = vm.envBytes32("DEVICE_ID"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.addKmsDevice(deviceId); + if (vm.envExists("ROOT_EPOCH")) { + kms.addKmsDeviceAtEpoch(vm.envUint("ROOT_EPOCH"), deviceId); + } else { + kms.addKmsDevice(deviceId); + } vm.stopBroadcast(); console.log("Added KMS device:", vm.toString(deviceId)); @@ -148,7 +164,11 @@ contract AddComposeHash is BaseScript { bytes32 composeHash = vm.envBytes32("COMPOSE_HASH"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.addComposeHash(composeHash); + if (vm.envExists("APP_EPOCH")) { + app.addComposeHashAtEpoch(vm.envUint("APP_EPOCH"), composeHash); + } else { + app.addComposeHash(composeHash); + } vm.stopBroadcast(); console.log("Added compose hash:", vm.toString(composeHash)); @@ -161,7 +181,11 @@ contract AddDevice is BaseScript { bytes32 deviceId = vm.envBytes32("DEVICE_ID"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.addDevice(deviceId); + if (vm.envExists("APP_EPOCH")) { + app.addDeviceAtEpoch(vm.envUint("APP_EPOCH"), deviceId); + } else { + app.addDevice(deviceId); + } vm.stopBroadcast(); console.log("Added device:", vm.toString(deviceId)); @@ -174,7 +198,11 @@ contract SetAllowAnyDevice is BaseScript { bool allow = vm.envBool("ALLOW_ANY_DEVICE"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.setAllowAnyDevice(allow); + if (vm.envExists("APP_EPOCH")) { + app.setAllowAnyDeviceAtEpoch(vm.envUint("APP_EPOCH"), allow); + } else { + app.setAllowAnyDevice(allow); + } vm.stopBroadcast(); console.log("Set allowAnyDevice to:", allow); @@ -273,7 +301,11 @@ contract RemoveOsImage is BaseScript { bytes32 imageHash = vm.envBytes32("OS_IMAGE_HASH"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.removeOsImageHash(imageHash); + if (vm.envExists("ROOT_EPOCH")) { + kms.removeOsImageHashAtEpoch(vm.envUint("ROOT_EPOCH"), imageHash); + } else { + kms.removeOsImageHash(imageHash); + } vm.stopBroadcast(); console.log("Removed OS image hash:", vm.toString(imageHash)); @@ -285,7 +317,11 @@ contract RemoveKmsDevice is BaseScript { bytes32 deviceId = vm.envBytes32("DEVICE_ID"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.removeKmsDevice(deviceId); + if (vm.envExists("ROOT_EPOCH")) { + kms.removeKmsDeviceAtEpoch(vm.envUint("ROOT_EPOCH"), deviceId); + } else { + kms.removeKmsDevice(deviceId); + } vm.stopBroadcast(); console.log("Removed KMS device:", vm.toString(deviceId)); @@ -299,7 +335,11 @@ contract RemoveComposeHash is BaseScript { bytes32 composeHash = vm.envBytes32("COMPOSE_HASH"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.removeComposeHash(composeHash); + if (vm.envExists("APP_EPOCH")) { + app.removeComposeHashAtEpoch(vm.envUint("APP_EPOCH"), composeHash); + } else { + app.removeComposeHash(composeHash); + } vm.stopBroadcast(); console.log("Removed compose hash:", vm.toString(composeHash)); @@ -312,7 +352,11 @@ contract RemoveDevice is BaseScript { bytes32 deviceId = vm.envBytes32("DEVICE_ID"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.removeDevice(deviceId); + if (vm.envExists("APP_EPOCH")) { + app.removeDeviceAtEpoch(vm.envUint("APP_EPOCH"), deviceId); + } else { + app.removeDevice(deviceId); + } vm.stopBroadcast(); console.log("Removed device:", vm.toString(deviceId)); diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index 6776e0b2d..2c1e73650 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -409,4 +409,140 @@ contract DstackAppTest is Test { app.addDevice(bytes32(0)); vm.stopPrank(); } + + function epochInfo() internal view returns (IAppAuth.AppInfo memory info) { + info.appId = address(app); + info.composeHash = bytes32(uint256(1)); + info.deviceId = bytes32(uint256(2)); + info.tcbStatus = "UpToDate"; + } + + function test_AppEpochRowsAreIndependentAndDefaultsUseCurrentEpoch() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHash(info.composeHash); + app.addDevice(info.deviceId); + app.advanceAppEpoch(0); + (bool allowed,) = app.isAppAllowed(info); + assertFalse(allowed); + (allowed,) = app.isAppAllowedAtEpoch(info, 0); + assertTrue(allowed); + app.addComposeHash(info.composeHash); + (allowed,) = app.isAppAllowed(info); + assertFalse(allowed, "each material must be approved in the selected row"); + app.addDevice(info.deviceId); + (allowed,) = app.isAppAllowed(info); + assertTrue(allowed); + app.removeComposeHash(info.composeHash); + assertFalse(app.allowedComposeHashesAtEpoch(1, info.composeHash)); + assertTrue(app.allowedComposeHashes(info.composeHash)); + (allowed,) = app.isAppAllowedAtEpoch(info, 0); + assertTrue(allowed); + app.addComposeHash(info.composeHash); + app.removeDeviceAtEpoch(0, info.deviceId); + assertFalse(app.allowedDeviceIds(info.deviceId)); + (allowed,) = app.isAppAllowedAtEpoch(info, 0); + assertFalse(allowed); + (allowed,) = app.isAppAllowed(info); + assertTrue(allowed, "revoking epoch zero must not revoke epoch one"); + (allowed,) = app.isAppAllowedAtEpoch(info, 2); + assertFalse(allowed); + vm.stopPrank(); + } + + function test_NewImageCanBeExplicitlyAddedToOldRowForHandover() public { + IAppAuth.AppInfo memory oldInfo = epochInfo(); + IAppAuth.AppInfo memory newInfo = epochInfo(); + newInfo.composeHash = bytes32(uint256(3)); + vm.startPrank(owner); + app.addComposeHash(oldInfo.composeHash); + app.addDevice(oldInfo.deviceId); + app.advanceAppEpoch(0); + app.addComposeHash(newInfo.composeHash); + app.addDevice(newInfo.deviceId); + (bool allowed,) = app.isAppAllowedAtEpoch(newInfo, 0); + assertFalse(allowed, "new epoch approval must not imply historical access"); + app.addComposeHashAtEpoch(0, newInfo.composeHash); + (allowed,) = app.isAppAllowedAtEpoch(newInfo, 0); + assertTrue(allowed); + (allowed,) = app.isAppAllowed(oldInfo); + assertFalse(allowed, "old image cannot read new keys"); + app.removeComposeHashAtEpoch(0, oldInfo.composeHash); + (allowed,) = app.isAppAllowedAtEpoch(oldInfo, 0); + assertFalse(allowed); + (allowed,) = app.isAppAllowed(newInfo); + assertTrue(allowed); + vm.stopPrank(); + } + + function test_AllowAnyDeviceIsScopedToExactlyOneEpoch() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHash(info.composeHash); + app.setAllowAnyDevice(true); + app.advanceAppEpoch(0); + app.addComposeHash(info.composeHash); + (bool allowed,) = app.isAppAllowed(info); + assertFalse(allowed); + app.addDevice(info.deviceId); + (allowed,) = app.isAppAllowed(info); + assertTrue(allowed, "explicit device can replace wildcard in a newer row"); + app.removeDevice(info.deviceId); + app.setAllowAnyDevice(true); + app.setAllowAnyDeviceAtEpoch(0, false); + assertFalse(app.allowAnyDevice()); + assertTrue(app.allowAnyDeviceAtEpoch(1)); + (allowed,) = app.isAppAllowedAtEpoch(info, 0); + assertFalse(allowed); + (allowed,) = app.isAppAllowed(info); + assertTrue(allowed); + app.setAllowAnyDevice(false); + (allowed,) = app.isAppAllowed(info); + assertFalse(allowed); + vm.stopPrank(); + } + + function test_ExplicitAppEpochSettersRequireOwnerAndKnownEpoch() public { + vm.startPrank(owner); + vm.expectRevert("Unknown app epoch"); + app.addComposeHashAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.removeComposeHashAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.addDeviceAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.removeDeviceAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.setAllowAnyDeviceAtEpoch(1, true); + vm.stopPrank(); + bytes memory unauthorized = abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user); + vm.startPrank(user); + vm.expectRevert(unauthorized); + app.addComposeHashAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + app.removeComposeHashAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + app.addDeviceAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + app.removeDeviceAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + app.setAllowAnyDeviceAtEpoch(0, true); + vm.stopPrank(); + assertTrue(app.supportsInterface(type(IAppEpochAuth).interfaceId)); + } + + function testFuzz_AppAuthorizationMatchesOnlyTheSelectedRow(uint8 approved, uint8 requested) public { + uint256 row = bound(approved, 0, 4); + uint256 selected = bound(requested, 0, 6); + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + for (uint256 i; i < 5; i++) { + app.advanceAppEpoch(i); + } + app.addComposeHashAtEpoch(row, info.composeHash); + app.addDeviceAtEpoch(row, info.deviceId); + vm.stopPrank(); + (bool allowed,) = app.isAppAllowedAtEpoch(info, selected); + assertEq(allowed, selected == row); + } } diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index c4d4b8f15..f324c359f 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -503,32 +503,28 @@ contract DstackKmsTest is Test { function test_CheckAuthorization_OptionalUint256DistinguishesZeroFromDefault() public { IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); vm.startPrank(owner); - kms.registerRootEpoch(ca(0xa0), k256(0xb0), "https://example.com/epoch-0"); - kms.registerRootEpoch(ca(0xa1), k256(0xb1), "https://example.com/epoch-1"); + registerNextRootEpoch(); + registerNextRootEpoch(); kms.addOsImageHash(info.osImageHash); kms.addKmsDevice(info.deviceId); kms.addKmsAggregatedMr(info.mrAggregated); - vm.stopPrank(); - - DstackKms.AuthorizationRequest memory request = DstackKms.AuthorizationRequest( - info, keccak256("kms_serve"), DstackKms.OptionalUint256(false, 0), DstackKms.OptionalUint256(true, 1) - ); - DstackKms.AuthorizationResult memory result = kms.authorizeKms(request); + // Default additions use the highest registered row, not the active row. + assertFalse(kms.allowedOsImages(info.osImageHash)); + assertTrue(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); + assertFalse(authorize(info, true).isAllowed); + assertTrue(kms.authorizeKms(epochRequest(info, 1, 0)).isAllowed, "pending handover must be explicitly approved"); + assertFalse(kms.authorizeApp(epochRequest(info, 1, 0)).isAllowed, "apps cannot fetch pending keys"); + DstackKms.AuthorizationResult memory result = kms.authorizeKms(epochRequest(info, 2, 0)); assertFalse(result.isAllowed); - assertEq(result.rejectionReason, "Requested root epoch is not active"); - bytes32 h1 = chainHash(chainHash(bytes32(0), ca(0xa0), k256(0xb0)), ca(0xa1), k256(0xb1)); - vm.prank(owner); - kms.activateRootEpoch(1, h1); - assertTrue(kms.authorizeKms(request).isAllowed); - request.requestedRootEpoch.value = 0; - assertTrue(kms.authorizeKms(request).isAllowed); - request.requestedRootEpoch.value = type(uint256).max; - assertFalse(kms.authorizeKms(request).isAllowed); - request.requestedRootEpoch.isSet = false; - result = kms.authorizeKms(request); + assertEq(result.rejectionReason, "Requested root epoch is not registered"); + activateNextRootEpoch(); + result = authorize(info, true); assertTrue(result.isAllowed); assertEq(result.currentRootEpoch, 1); - assertEq(result.currentRootEpochHash, h1); + assertFalse(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed, "epoch one does not imply epoch zero"); + approveKmsAtEpoch(info, 0); + assertTrue(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + vm.stopPrank(); } function test_CheckAuthorization_UnregisteredEpochReturnsRejection() public view { @@ -621,7 +617,8 @@ contract DstackKmsTest is Test { info, keccak256("app_get_key"), DstackKms.OptionalUint256(false, 0), DstackKms.OptionalUint256(false, 0) ); DstackKms.AuthorizationResult memory result = kms.authorizeApp(request); - assertTrue(result.isAllowed); + assertFalse(result.isAllowed); + assertEq(result.rejectionReason, "Compose hash not allowed"); assertEq(result.currentAppEpoch, 1); request.requestedAppEpoch = DstackKms.OptionalUint256(true, 0); assertTrue(kms.authorizeApp(request).isAllowed); @@ -860,4 +857,155 @@ contract DstackKmsTest is Test { kms.addKmsAggregatedMr(mr); assertTrue(kms.kmsAllowedAggregatedMrs(mr)); } + + function registerNextRootEpoch() internal { + uint256 epoch = kms.rootEpochCount(); + kms.registerRootEpoch(ca(uint8(0xa0 + epoch)), k256(uint8(0xb0 + epoch)), "ipfs://epoch"); + } + + function activateNextRootEpoch() internal { + (uint256 current,) = kms.currentRootEpoch(); + (,,, bytes32 hash) = kms.rootEpoch(current + 1); + kms.activateRootEpoch(current + 1, hash); + } + + function epochRequest( + IAppAuth.AppInfo memory info, + uint256 root, + uint256 appEpoch + ) + internal + pure + returns (DstackKms.AuthorizationRequest memory) + { + return DstackKms.AuthorizationRequest( + info, DstackKms.OptionalUint256(true, appEpoch), DstackKms.OptionalUint256(true, root) + ); + } + + function approveKmsAtEpoch(IAppAuth.AppInfo memory info, uint256 epoch) internal { + kms.addOsImageHashAtEpoch(epoch, info.osImageHash); + kms.addKmsAggregatedMrAtEpoch(epoch, info.mrAggregated); + kms.addKmsDeviceAtEpoch(epoch, info.deviceId); + } + + function test_KmsEpochRowsDoNotInheritAndCanBeRevokedIndependently() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + registerNextRootEpoch(); + approveKmsAtEpoch(info, 0); + registerNextRootEpoch(); + activateNextRootEpoch(); + assertTrue(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertFalse(authorize(info, true).isAllowed); + (bool allowed,) = kms.isKmsAllowed(info); + assertFalse(allowed); + kms.addOsImageHash(info.osImageHash); + assertFalse(authorize(info, true).isAllowed); + kms.addKmsAggregatedMr(info.mrAggregated); + assertFalse(authorize(info, true).isAllowed); + kms.addKmsDevice(info.deviceId); + assertTrue(authorize(info, true).isAllowed); + kms.removeOsImageHash(info.osImageHash); + assertFalse(authorize(info, true).isAllowed); + assertTrue(kms.allowedOsImages(info.osImageHash)); + assertTrue(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + kms.addOsImageHash(info.osImageHash); + kms.removeKmsAggregatedMrAtEpoch(0, info.mrAggregated); + assertFalse(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertTrue(authorize(info, true).isAllowed); + kms.addKmsAggregatedMrAtEpoch(0, info.mrAggregated); + kms.removeKmsDeviceAtEpoch(0, info.deviceId); + assertFalse(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertTrue(authorize(info, true).isAllowed); + vm.stopPrank(); + } + + function test_AppAndRootRowsAreSelectedIndependently() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHash(info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + registerNextRootEpoch(); + activateNextRootEpoch(); + vm.stopPrank(); + DstackApp policy = DstackApp(info.appId); + vm.prank(user); + policy.advanceAppEpoch(0); + assertFalse(kms.authorizeApp(epochRequest(info, 0, 1)).isAllowed); + assertFalse(kms.authorizeApp(epochRequest(info, 1, 0)).isAllowed); + assertTrue(kms.authorizeApp(epochRequest(info, 0, 0)).isAllowed); + vm.startPrank(user); + policy.addComposeHash(info.composeHash); + policy.addDevice(info.deviceId); + vm.stopPrank(); + assertTrue(kms.authorizeApp(epochRequest(info, 0, 1)).isAllowed); + assertFalse(authorize(info, false).isAllowed); + vm.prank(owner); + kms.addOsImageHash(info.osImageHash); + assertTrue(authorize(info, false).isAllowed); + assertTrue(kms.authorizeApp(epochRequest(info, 1, 0)).isAllowed); + vm.prank(owner); + kms.removeOsImageHashAtEpoch(0, info.osImageHash); + assertFalse(kms.authorizeApp(epochRequest(info, 0, 0)).isAllowed); + assertTrue(authorize(info, false).isAllowed); + } + + function test_ExplicitRootEpochSettersRequireOwnerAndRegisteredEpoch() public { + vm.startPrank(owner); + vm.expectRevert("Unknown root epoch"); + kms.addOsImageHashAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown root epoch"); + kms.removeOsImageHashAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown root epoch"); + kms.addKmsAggregatedMrAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown root epoch"); + kms.removeKmsAggregatedMrAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown root epoch"); + kms.addKmsDeviceAtEpoch(1, bytes32(uint256(1))); + vm.expectRevert("Unknown root epoch"); + kms.removeKmsDeviceAtEpoch(1, bytes32(uint256(1))); + vm.stopPrank(); + bytes memory unauthorized = abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user); + vm.startPrank(user); + vm.expectRevert(unauthorized); + kms.addOsImageHashAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + kms.removeOsImageHashAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + kms.addKmsAggregatedMrAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + kms.removeKmsAggregatedMrAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + kms.addKmsDeviceAtEpoch(0, bytes32(uint256(1))); + vm.expectRevert(unauthorized); + kms.removeKmsDeviceAtEpoch(0, bytes32(uint256(1))); + vm.stopPrank(); + } + + function test_LegacyAppPolicyCannotAuthorizeNonzeroAppEpochs() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHash(info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + vm.prank(user); + DstackApp(info.appId).advanceAppEpoch(0); + vm.mockCall( + info.appId, abi.encodeCall(IERC165.supportsInterface, (type(IAppEpochAuth).interfaceId)), abi.encode(false) + ); + vm.mockCall(info.appId, abi.encodeCall(IAppAuth.isAppAllowed, (info)), abi.encode(true, "")); + assertTrue(kms.authorizeApp(epochRequest(info, 0, 0)).isAllowed); + DstackKms.AuthorizationResult memory result = authorize(info, false); + assertFalse(result.isAllowed); + assertEq(result.rejectionReason, "App policy does not support epoch authorization"); + vm.clearMockedCalls(); + vm.mockCallRevert( + info.appId, abi.encodeCall(IAppEpochAuth.isAppAllowedAtEpoch, (info, 0)), "policy unavailable" + ); + vm.expectRevert(bytes("policy unavailable")); + kms.authorizeApp(epochRequest(info, 0, 0)); + } } diff --git a/dstack/kms/src/main_service.rs b/dstack/kms/src/main_service.rs index 4358e4ad0..5c1e98122 100644 --- a/dstack/kms/src/main_service.rs +++ b/dstack/kms/src/main_service.rs @@ -569,11 +569,12 @@ impl RpcHandler { async fn ensure_kms_allowed(&self, vm_config: &str) -> Result { let att = self.ensure_attested()?; let boot_info = build_boot_info_for_attestation(att, false, vm_config)?; + // Handover approval covers the highest exported epoch and all older roots. let response = self .state .config .auth_api - .authorize_kms(&boot_info, Operation::KmsImportKeys, None) + .authorize_kms(&boot_info, Operation::KmsImportKeys, Some(self.state.keyring.latest())) .await?; self.state.authorized_epoch(&response, None)?; self.ensure_attestation_policy(att, vm_config, &boot_info, &response) diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index c75dc9f25..726e38880 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -801,6 +801,7 @@ pub(crate) mod tests { response(false, 1), response(true, 1), response(true, 1), + response(false, 1), ]); let auth = webhook(url); let info = boot_info(50); @@ -822,12 +823,22 @@ pub(crate) mod tests { auth.authorize_kms(&info, Operation::KmsServe, None) .await .unwrap(); + assert!( + !auth + .check_kms_authorization(&info, Operation::KmsImportKeys, Some(2)) + .await + .unwrap() + .is_allowed + ); let requests = server.join().unwrap(); - assert_eq!(requests.len(), 4); + assert_eq!(requests.len(), 5); assert!(requests[..3] .iter() .all(|(path, _)| path == "/authorizeApp")); assert_eq!(requests[3].0, "/authorizeKms"); + assert_eq!(requests[4].0, "/authorizeKms"); + assert_eq!(requests[4].1["requestedRootEpoch"], 2); + assert!(requests[4].1.get("requestedAppEpoch").is_none()); let mut expected = serde_json::to_value(info).unwrap(); expected["operation"] = "app_get_key".into(); assert_eq!(requests[0].1, expected); From c8d8b96e7ea80701697a1fd35e6715fc27f7c373 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 7 Nov 2026 08:28:52 -0800 Subject: [PATCH 2/8] refactor(auth-eth): name epoch-aware app authorization consistently Signed-off-by: Kevin Wang --- docs/app-key-epochs.md | 2 +- dstack/kms/auth-eth/contracts/DstackApp.sol | 8 ++++---- dstack/kms/auth-eth/contracts/DstackKms.sol | 2 +- .../kms/auth-eth/contracts/IAppEpochAuth.sol | 2 +- dstack/kms/auth-eth/test/DstackApp.t.sol | 18 +++++++++--------- dstack/kms/auth-eth/test/DstackKms.t.sol | 4 +--- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/app-key-epochs.md b/docs/app-key-epochs.md index fe3413fd9..1704e72fe 100644 --- a/docs/app-key-epochs.md +++ b/docs/app-key-epochs.md @@ -84,7 +84,7 @@ only row zero after upgrading: explicitly populate the intended nonzero rows. Upgrade the KMS policy before app policies, then approve the intended rows before restarting guests or activating a new root. `DstackApp` advertises the optional ERC-165 `IAppEpochAuth` capability so the KMS can pass the selected app epoch to -`isAppAllowedAtEpoch`. Policies without that capability can only authorize app +`authorizeApp`. Policies without that capability can only authorize app epoch zero; failures of an advertised method propagate instead of falling back. Direct legacy app checks use the current app epoch; legacy KMS app authorization still rejects nonzero app epochs. diff --git a/dstack/kms/auth-eth/contracts/DstackApp.sol b/dstack/kms/auth-eth/contracts/DstackApp.sol index a8a9f63e9..644839877 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -260,10 +260,10 @@ contract DstackApp is override returns (bool isAllowed, string memory reason) { - return _isAppAllowedAtEpoch(bootInfo, currentAppEpoch); + return _authorizeApp(bootInfo, currentAppEpoch); } - function isAppAllowedAtEpoch( + function authorizeApp( IAppAuth.AppInfo calldata bootInfo, uint256 appEpoch ) @@ -272,10 +272,10 @@ contract DstackApp is override returns (bool isAllowed, string memory reason) { - return _isAppAllowedAtEpoch(bootInfo, appEpoch); + return _authorizeApp(bootInfo, appEpoch); } - function _isAppAllowedAtEpoch( + function _authorizeApp( IAppAuth.AppInfo calldata bootInfo, uint256 appEpoch ) diff --git a/dstack/kms/auth-eth/contracts/DstackKms.sol b/dstack/kms/auth-eth/contracts/DstackKms.sol index 0ca19e539..4bb2cd370 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -610,7 +610,7 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E } if (ERC165Checker.supportsInterface(bootInfo.appId, type(IAppEpochAuth).interfaceId)) { - return IAppEpochAuth(bootInfo.appId).isAppAllowedAtEpoch(bootInfo, appEpoch); + return IAppEpochAuth(bootInfo.appId).authorizeApp(bootInfo, appEpoch); } if (appEpoch != 0) return (false, "App policy does not support epoch authorization"); diff --git a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol index aa54d251b..3ea7754c6 100644 --- a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -10,7 +10,7 @@ import "./IAppAuth.sol"; /// Optional epoch-aware policy capability. Legacy policies only authorize app epoch zero. interface IAppEpochAuth { - function isAppAllowedAtEpoch( + function authorizeApp( IAppAuth.AppInfo calldata bootInfo, uint256 appEpoch ) diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index 2c1e73650..bfd96f131 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -425,7 +425,7 @@ contract DstackAppTest is Test { app.advanceAppEpoch(0); (bool allowed,) = app.isAppAllowed(info); assertFalse(allowed); - (allowed,) = app.isAppAllowedAtEpoch(info, 0); + (allowed,) = app.authorizeApp(info, 0); assertTrue(allowed); app.addComposeHash(info.composeHash); (allowed,) = app.isAppAllowed(info); @@ -436,16 +436,16 @@ contract DstackAppTest is Test { app.removeComposeHash(info.composeHash); assertFalse(app.allowedComposeHashesAtEpoch(1, info.composeHash)); assertTrue(app.allowedComposeHashes(info.composeHash)); - (allowed,) = app.isAppAllowedAtEpoch(info, 0); + (allowed,) = app.authorizeApp(info, 0); assertTrue(allowed); app.addComposeHash(info.composeHash); app.removeDeviceAtEpoch(0, info.deviceId); assertFalse(app.allowedDeviceIds(info.deviceId)); - (allowed,) = app.isAppAllowedAtEpoch(info, 0); + (allowed,) = app.authorizeApp(info, 0); assertFalse(allowed); (allowed,) = app.isAppAllowed(info); assertTrue(allowed, "revoking epoch zero must not revoke epoch one"); - (allowed,) = app.isAppAllowedAtEpoch(info, 2); + (allowed,) = app.authorizeApp(info, 2); assertFalse(allowed); vm.stopPrank(); } @@ -460,15 +460,15 @@ contract DstackAppTest is Test { app.advanceAppEpoch(0); app.addComposeHash(newInfo.composeHash); app.addDevice(newInfo.deviceId); - (bool allowed,) = app.isAppAllowedAtEpoch(newInfo, 0); + (bool allowed,) = app.authorizeApp(newInfo, 0); assertFalse(allowed, "new epoch approval must not imply historical access"); app.addComposeHashAtEpoch(0, newInfo.composeHash); - (allowed,) = app.isAppAllowedAtEpoch(newInfo, 0); + (allowed,) = app.authorizeApp(newInfo, 0); assertTrue(allowed); (allowed,) = app.isAppAllowed(oldInfo); assertFalse(allowed, "old image cannot read new keys"); app.removeComposeHashAtEpoch(0, oldInfo.composeHash); - (allowed,) = app.isAppAllowedAtEpoch(oldInfo, 0); + (allowed,) = app.authorizeApp(oldInfo, 0); assertFalse(allowed); (allowed,) = app.isAppAllowed(newInfo); assertTrue(allowed); @@ -492,7 +492,7 @@ contract DstackAppTest is Test { app.setAllowAnyDeviceAtEpoch(0, false); assertFalse(app.allowAnyDevice()); assertTrue(app.allowAnyDeviceAtEpoch(1)); - (allowed,) = app.isAppAllowedAtEpoch(info, 0); + (allowed,) = app.authorizeApp(info, 0); assertFalse(allowed); (allowed,) = app.isAppAllowed(info); assertTrue(allowed); @@ -542,7 +542,7 @@ contract DstackAppTest is Test { app.addComposeHashAtEpoch(row, info.composeHash); app.addDeviceAtEpoch(row, info.deviceId); vm.stopPrank(); - (bool allowed,) = app.isAppAllowedAtEpoch(info, selected); + (bool allowed,) = app.authorizeApp(info, selected); assertEq(allowed, selected == row); } } diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index f324c359f..921a5016c 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -1002,9 +1002,7 @@ contract DstackKmsTest is Test { assertFalse(result.isAllowed); assertEq(result.rejectionReason, "App policy does not support epoch authorization"); vm.clearMockedCalls(); - vm.mockCallRevert( - info.appId, abi.encodeCall(IAppEpochAuth.isAppAllowedAtEpoch, (info, 0)), "policy unavailable" - ); + vm.mockCallRevert(info.appId, abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0)), "policy unavailable"); vm.expectRevert(bytes("policy unavailable")); kms.authorizeApp(epochRequest(info, 0, 0)); } From e8198a12e8dd37ee55d8de155f5068bd2c78ae18 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 7 Nov 2026 18:16:32 -0800 Subject: [PATCH 3/8] docs(kms): describe highest-epoch handover authorization Signed-off-by: Kevin Wang --- dstack/kms/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dstack/kms/README.md b/dstack/kms/README.md index e38cc0ea2..b01c7bf4a 100644 --- a/dstack/kms/README.md +++ b/dstack/kms/README.md @@ -267,10 +267,12 @@ Rejection responses still carry the current epochs and root chain hash. See `requestedRootEpoch` is optional in the JSON request. Omit it to use the epoch in this authorization result; an explicit `0` selects epoch 0. Solidity represents it as `OptionalUint256 { isSet, value }` alongside `AppInfo` in the request. The -contract rejects a requested epoch newer than the current one; historical -epochs remain available for migration. No serving epoch or serving hash is sent. -KMS-to-KMS handover transfers the complete keyring. The receiver explicitly -authorizes the source to export the highest received epoch before importing it. +contract rejects future root epochs for application requests; historical epochs +remain available subject to the selected policy row. No serving epoch or serving +hash is sent. KMS-to-KMS handover explicitly selects the highest exported root +epoch, including a registered pending epoch. Approval in that row authorizes the +complete keyring, including older roots. KMS self-authorization omits the selector +and checks the current root epoch. Every request includes a required `operation` describing what the attested subject is authorized to do. JSON uses `snake_case`; the contract receives From b05d34d4489324cbd688e9f0eb97399e3f4f292f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 7 Nov 2026 18:53:29 -0800 Subject: [PATCH 4/8] feat(auth-eth): forward operations to epoch-scoped app policies Signed-off-by: Kevin Wang --- docs/app-key-epochs.md | 7 +- dstack/kms/README.md | 7 +- dstack/kms/auth-eth/contracts/DstackApp.sol | 5 +- dstack/kms/auth-eth/contracts/DstackKms.sol | 13 ++-- .../kms/auth-eth/contracts/IAppEpochAuth.sol | 5 +- dstack/kms/auth-eth/test/DstackApp.t.sol | 34 +++++++--- dstack/kms/auth-eth/test/DstackKms.t.sol | 67 ++++++++++++++++--- dstack/kms/src/main_service.rs | 6 +- 8 files changed, 110 insertions(+), 34 deletions(-) diff --git a/docs/app-key-epochs.md b/docs/app-key-epochs.md index 1704e72fe..1a17e41e5 100644 --- a/docs/app-key-epochs.md +++ b/docs/app-key-epochs.md @@ -69,8 +69,9 @@ roots. The KMS passes that epoch explicitly to `authorizeKms`, so approval in th current row cannot accidentally authorize a newer pending keyring. A registered pending root may be authorized for KMS handover before activation; app key requests still cannot select it. KMS self-authorization without a selector continues to -check the active root epoch. This permits staged key distribution without changing -which epoch the KMS serves. +check the active root epoch. The source checks `kms_import_keys` for the recipient, and the recipient +checks `kms_export_keys` for the source, both at the highest transferred epoch. +This permits staged key distribution without changing which epoch the KMS serves. ### Upgrading existing proxies @@ -83,7 +84,7 @@ only row zero after upgrading: explicitly populate the intended nonzero rows. Upgrade the KMS policy before app policies, then approve the intended rows before restarting guests or activating a new root. `DstackApp` advertises the optional -ERC-165 `IAppEpochAuth` capability so the KMS can pass the selected app epoch to +ERC-165 `IAppEpochAuth` capability so the KMS can pass the selected app epoch and operation to `authorizeApp`. Policies without that capability can only authorize app epoch zero; failures of an advertised method propagate instead of falling back. Direct legacy app checks use the current app epoch; legacy KMS app authorization diff --git a/dstack/kms/README.md b/dstack/kms/README.md index b01c7bf4a..b4b07d895 100644 --- a/dstack/kms/README.md +++ b/dstack/kms/README.md @@ -272,7 +272,8 @@ remain available subject to the selected policy row. No serving epoch or serving hash is sent. KMS-to-KMS handover explicitly selects the highest exported root epoch, including a registered pending epoch. Approval in that row authorizes the complete keyring, including older roots. KMS self-authorization omits the selector -and checks the current root epoch. +and checks the current root epoch. The recipient separately authorizes the +source to `kms_export_keys` at the highest received epoch before importing it. Every request includes a required `operation` describing what the attested subject is authorized to do. JSON uses `snake_case`; the contract receives @@ -309,8 +310,8 @@ The response is an `AuthorizationResult`: ``` Both successful and rejected decisions include the current epoch and chain hash -from the same contract call or auth-simple configuration snapshot. With no -registered epoch, the result is `isAllowed: false`, +from the same contract call or auth-simple configuration snapshot. Except for `kms_bootstrap`, with no +registered epoch the result is `isAllowed: false`, `rejectionReason: "Root epoch not registered"`, epoch 0 and a zero hash. A zero hash never authorizes key release or requests an epoch switch. diff --git a/dstack/kms/auth-eth/contracts/DstackApp.sol b/dstack/kms/auth-eth/contracts/DstackApp.sol index 644839877..8bd73de14 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -7,6 +7,7 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; +import "./AuthorizationOperation.sol"; import "./IAppEpoch.sol"; import "./IAppEpochAuth.sol"; import "./IAppAuthBasicManagement.sol"; @@ -265,13 +266,15 @@ contract DstackApp is function authorizeApp( IAppAuth.AppInfo calldata bootInfo, - uint256 appEpoch + uint256 appEpoch, + bytes32 operation ) external view override returns (bool isAllowed, string memory reason) { + if (!AuthorizationOperation.isApp(operation)) return (false, "Invalid authorization operation"); return _authorizeApp(bootInfo, appEpoch); } diff --git a/dstack/kms/auth-eth/contracts/DstackKms.sol b/dstack/kms/auth-eth/contracts/DstackKms.sol index 4bb2cd370..88ce49c4c 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -533,7 +533,8 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E if (isKms) { (result.isAllowed, result.rejectionReason) = _isKmsAllowed(bootInfo, selectedRootEpoch); } else { - (result.isAllowed, result.rejectionReason) = _isAppAllowed(bootInfo, selectedRootEpoch, appEpoch); + (result.isAllowed, result.rejectionReason) = + _isAppAllowed(bootInfo, selectedRootEpoch, appEpoch, request.operation); } } @@ -583,13 +584,14 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E if (currentAppEpoch(bootInfo.appId) != 0) { return (false, "App epoch requires epoch-aware authorization"); } - return _isAppAllowed(bootInfo, _currentRootEpoch, 0); + return _isAppAllowed(bootInfo, _currentRootEpoch, 0, bytes32(0)); } function _isAppAllowed( AppInfo memory bootInfo, uint256 selectedRootEpoch, - uint256 appEpoch + uint256 appEpoch, + bytes32 operation ) private view @@ -609,8 +611,9 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E return (false, "App not deployed or invalid address"); } - if (ERC165Checker.supportsInterface(bootInfo.appId, type(IAppEpochAuth).interfaceId)) { - return IAppEpochAuth(bootInfo.appId).authorizeApp(bootInfo, appEpoch); + if (operation != bytes32(0) && ERC165Checker.supportsInterface(bootInfo.appId, type(IAppEpochAuth).interfaceId)) + { + return IAppEpochAuth(bootInfo.appId).authorizeApp(bootInfo, appEpoch, operation); } if (appEpoch != 0) return (false, "App policy does not support epoch authorization"); diff --git a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol index 3ea7754c6..4df1bc439 100644 --- a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -8,11 +8,12 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; -/// Optional epoch-aware policy capability. Legacy policies only authorize app epoch zero. +/// Optional epoch- and operation-aware policy capability. Legacy policies only authorize app epoch zero. interface IAppEpochAuth { function authorizeApp( IAppAuth.AppInfo calldata bootInfo, - uint256 appEpoch + uint256 appEpoch, + bytes32 operation ) external view diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index bfd96f131..7dcbeb915 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -417,6 +417,22 @@ contract DstackAppTest is Test { info.tcbStatus = "UpToDate"; } + function test_AppOperationsSharePolicyButRejectUnknownOrKmsOperations() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHash(info.composeHash); + app.addDevice(info.deviceId); + vm.stopPrank(); + (bool allowed,) = app.authorizeApp(info, 0, keccak256("sign_cert")); + assertTrue(allowed); + (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + (allowed,) = app.authorizeApp(info, 0, keccak256("kms_import_keys")); + assertFalse(allowed); + (allowed,) = app.authorizeApp(info, 0, bytes32(0)); + assertFalse(allowed); + } + function test_AppEpochRowsAreIndependentAndDefaultsUseCurrentEpoch() public { IAppAuth.AppInfo memory info = epochInfo(); vm.startPrank(owner); @@ -425,7 +441,7 @@ contract DstackAppTest is Test { app.advanceAppEpoch(0); (bool allowed,) = app.isAppAllowed(info); assertFalse(allowed); - (allowed,) = app.authorizeApp(info, 0); + (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); assertTrue(allowed); app.addComposeHash(info.composeHash); (allowed,) = app.isAppAllowed(info); @@ -436,16 +452,16 @@ contract DstackAppTest is Test { app.removeComposeHash(info.composeHash); assertFalse(app.allowedComposeHashesAtEpoch(1, info.composeHash)); assertTrue(app.allowedComposeHashes(info.composeHash)); - (allowed,) = app.authorizeApp(info, 0); + (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); assertTrue(allowed); app.addComposeHash(info.composeHash); app.removeDeviceAtEpoch(0, info.deviceId); assertFalse(app.allowedDeviceIds(info.deviceId)); - (allowed,) = app.authorizeApp(info, 0); + (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); assertFalse(allowed); (allowed,) = app.isAppAllowed(info); assertTrue(allowed, "revoking epoch zero must not revoke epoch one"); - (allowed,) = app.authorizeApp(info, 2); + (allowed,) = app.authorizeApp(info, 2, keccak256("app_get_key")); assertFalse(allowed); vm.stopPrank(); } @@ -460,15 +476,15 @@ contract DstackAppTest is Test { app.advanceAppEpoch(0); app.addComposeHash(newInfo.composeHash); app.addDevice(newInfo.deviceId); - (bool allowed,) = app.authorizeApp(newInfo, 0); + (bool allowed,) = app.authorizeApp(newInfo, 0, keccak256("app_get_key")); assertFalse(allowed, "new epoch approval must not imply historical access"); app.addComposeHashAtEpoch(0, newInfo.composeHash); - (allowed,) = app.authorizeApp(newInfo, 0); + (allowed,) = app.authorizeApp(newInfo, 0, keccak256("app_get_key")); assertTrue(allowed); (allowed,) = app.isAppAllowed(oldInfo); assertFalse(allowed, "old image cannot read new keys"); app.removeComposeHashAtEpoch(0, oldInfo.composeHash); - (allowed,) = app.authorizeApp(oldInfo, 0); + (allowed,) = app.authorizeApp(oldInfo, 0, keccak256("app_get_key")); assertFalse(allowed); (allowed,) = app.isAppAllowed(newInfo); assertTrue(allowed); @@ -492,7 +508,7 @@ contract DstackAppTest is Test { app.setAllowAnyDeviceAtEpoch(0, false); assertFalse(app.allowAnyDevice()); assertTrue(app.allowAnyDeviceAtEpoch(1)); - (allowed,) = app.authorizeApp(info, 0); + (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); assertFalse(allowed); (allowed,) = app.isAppAllowed(info); assertTrue(allowed); @@ -542,7 +558,7 @@ contract DstackAppTest is Test { app.addComposeHashAtEpoch(row, info.composeHash); app.addDeviceAtEpoch(row, info.deviceId); vm.stopPrank(); - (bool allowed,) = app.authorizeApp(info, selected); + (bool allowed,) = app.authorizeApp(info, selected, keccak256("app_get_key")); assertEq(allowed, selected == row); } } diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index 921a5016c..55f4f88ef 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -512,18 +512,20 @@ contract DstackKmsTest is Test { assertFalse(kms.allowedOsImages(info.osImageHash)); assertTrue(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); assertFalse(authorize(info, true).isAllowed); - assertTrue(kms.authorizeKms(epochRequest(info, 1, 0)).isAllowed, "pending handover must be explicitly approved"); + assertTrue( + kms.authorizeKms(kmsEpochRequest(info, 1, 0)).isAllowed, "pending handover must be explicitly approved" + ); assertFalse(kms.authorizeApp(epochRequest(info, 1, 0)).isAllowed, "apps cannot fetch pending keys"); - DstackKms.AuthorizationResult memory result = kms.authorizeKms(epochRequest(info, 2, 0)); + DstackKms.AuthorizationResult memory result = kms.authorizeKms(kmsEpochRequest(info, 2, 0)); assertFalse(result.isAllowed); assertEq(result.rejectionReason, "Requested root epoch is not registered"); activateNextRootEpoch(); result = authorize(info, true); assertTrue(result.isAllowed); assertEq(result.currentRootEpoch, 1); - assertFalse(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed, "epoch one does not imply epoch zero"); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed, "epoch one does not imply epoch zero"); approveKmsAtEpoch(info, 0); - assertTrue(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed); vm.stopPrank(); } @@ -879,10 +881,26 @@ contract DstackKmsTest is Test { returns (DstackKms.AuthorizationRequest memory) { return DstackKms.AuthorizationRequest( - info, DstackKms.OptionalUint256(true, appEpoch), DstackKms.OptionalUint256(true, root) + info, + keccak256("app_get_key"), + DstackKms.OptionalUint256(true, appEpoch), + DstackKms.OptionalUint256(true, root) ); } + function kmsEpochRequest( + IAppAuth.AppInfo memory info, + uint256 root, + uint256 appEpoch + ) + internal + pure + returns (DstackKms.AuthorizationRequest memory request) + { + request = epochRequest(info, root, appEpoch); + request.operation = keccak256("kms_import_keys"); + } + function approveKmsAtEpoch(IAppAuth.AppInfo memory info, uint256 epoch) internal { kms.addOsImageHashAtEpoch(epoch, info.osImageHash); kms.addKmsAggregatedMrAtEpoch(epoch, info.mrAggregated); @@ -896,7 +914,7 @@ contract DstackKmsTest is Test { approveKmsAtEpoch(info, 0); registerNextRootEpoch(); activateNextRootEpoch(); - assertTrue(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed); assertFalse(authorize(info, true).isAllowed); (bool allowed,) = kms.isKmsAllowed(info); assertFalse(allowed); @@ -909,14 +927,14 @@ contract DstackKmsTest is Test { kms.removeOsImageHash(info.osImageHash); assertFalse(authorize(info, true).isAllowed); assertTrue(kms.allowedOsImages(info.osImageHash)); - assertTrue(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed); kms.addOsImageHash(info.osImageHash); kms.removeKmsAggregatedMrAtEpoch(0, info.mrAggregated); - assertFalse(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed); assertTrue(authorize(info, true).isAllowed); kms.addKmsAggregatedMrAtEpoch(0, info.mrAggregated); kms.removeKmsDeviceAtEpoch(0, info.deviceId); - assertFalse(kms.authorizeKms(epochRequest(info, 0, 0)).isAllowed); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed); assertTrue(authorize(info, true).isAllowed); vm.stopPrank(); } @@ -984,6 +1002,31 @@ contract DstackKmsTest is Test { vm.stopPrank(); } + function test_AppOperationIsForwardedAndLegacyAuthorizationStaysAvailable() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHash(info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + vm.mockCall( + info.appId, + abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("app_get_key"))), + abi.encode(false, "key retrieval denied") + ); + vm.mockCall( + info.appId, + abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("sign_cert"))), + abi.encode(true, "") + ); + DstackKms.AuthorizationRequest memory request = epochRequest(info, 0, 0); + assertEq(kms.authorizeApp(request).rejectionReason, "key retrieval denied"); + request.operation = keccak256("sign_cert"); + assertTrue(kms.authorizeApp(request).isAllowed); + (bool allowed,) = kms.isAppAllowed(info); + assertTrue(allowed); + } + function test_LegacyAppPolicyCannotAuthorizeNonzeroAppEpochs() public { IAppAuth.AppInfo memory info = authorizationInfo(address(0)); vm.startPrank(owner); @@ -1002,7 +1045,11 @@ contract DstackKmsTest is Test { assertFalse(result.isAllowed); assertEq(result.rejectionReason, "App policy does not support epoch authorization"); vm.clearMockedCalls(); - vm.mockCallRevert(info.appId, abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0)), "policy unavailable"); + vm.mockCallRevert( + info.appId, + abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("app_get_key"))), + "policy unavailable" + ); vm.expectRevert(bytes("policy unavailable")); kms.authorizeApp(epochRequest(info, 0, 0)); } diff --git a/dstack/kms/src/main_service.rs b/dstack/kms/src/main_service.rs index 5c1e98122..e08d2422e 100644 --- a/dstack/kms/src/main_service.rs +++ b/dstack/kms/src/main_service.rs @@ -574,7 +574,11 @@ impl RpcHandler { .state .config .auth_api - .authorize_kms(&boot_info, Operation::KmsImportKeys, Some(self.state.keyring.latest())) + .authorize_kms( + &boot_info, + Operation::KmsImportKeys, + Some(self.state.keyring.latest()), + ) .await?; self.state.authorized_epoch(&response, None)?; self.ensure_attestation_policy(att, vm_config, &boot_info, &response) From 276e44a4db381292b16637a1452b3a3e69b5e052 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 7 Nov 2026 19:30:49 -0800 Subject: [PATCH 5/8] test(auth): use the app certificate retrieval operation Signed-off-by: Kevin Wang --- dstack/kms/auth-eth/test/DstackApp.t.sol | 2 +- dstack/kms/auth-eth/test/DstackKms.t.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index 7dcbeb915..1bc5de0f0 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -423,7 +423,7 @@ contract DstackAppTest is Test { app.addComposeHash(info.composeHash); app.addDevice(info.deviceId); vm.stopPrank(); - (bool allowed,) = app.authorizeApp(info, 0, keccak256("sign_cert")); + (bool allowed,) = app.authorizeApp(info, 0, keccak256("get_app_cert")); assertTrue(allowed); (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); assertTrue(allowed); diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index 55f4f88ef..8808380fb 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -1016,12 +1016,12 @@ contract DstackKmsTest is Test { ); vm.mockCall( info.appId, - abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("sign_cert"))), + abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("get_app_cert"))), abi.encode(true, "") ); DstackKms.AuthorizationRequest memory request = epochRequest(info, 0, 0); assertEq(kms.authorizeApp(request).rejectionReason, "key retrieval denied"); - request.operation = keccak256("sign_cert"); + request.operation = keccak256("get_app_cert"); assertTrue(kms.authorizeApp(request).isAllowed); (bool allowed,) = kms.isAppAllowed(info); assertTrue(allowed); From 8945cb6285e2c4d5915a463f2702d21f9911a3d2 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 8 Nov 2026 01:17:02 -0800 Subject: [PATCH 6/8] test(auth): use scoped app certificate operation Signed-off-by: Kevin Wang --- dstack/kms/auth-eth/test/DstackApp.t.sol | 2 +- dstack/kms/auth-eth/test/DstackKms.t.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index 1bc5de0f0..a0e11094e 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -423,7 +423,7 @@ contract DstackAppTest is Test { app.addComposeHash(info.composeHash); app.addDevice(info.deviceId); vm.stopPrank(); - (bool allowed,) = app.authorizeApp(info, 0, keccak256("get_app_cert")); + (bool allowed,) = app.authorizeApp(info, 0, keccak256("app_get_cert")); assertTrue(allowed); (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); assertTrue(allowed); diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index 8808380fb..ba1c1859a 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -1016,12 +1016,12 @@ contract DstackKmsTest is Test { ); vm.mockCall( info.appId, - abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("get_app_cert"))), + abi.encodeCall(IAppEpochAuth.authorizeApp, (info, 0, keccak256("app_get_cert"))), abi.encode(true, "") ); DstackKms.AuthorizationRequest memory request = epochRequest(info, 0, 0); assertEq(kms.authorizeApp(request).rejectionReason, "key retrieval denied"); - request.operation = keccak256("get_app_cert"); + request.operation = keccak256("app_get_cert"); assertTrue(kms.authorizeApp(request).isAllowed); (bool allowed,) = kms.isAppAllowed(info); assertTrue(allowed); From e29f743584175359a49355431555c0c96f22b6af Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 8 Nov 2026 01:10:05 -0800 Subject: [PATCH 7/8] refactor(kms): consolidate app epoch policy interface Signed-off-by: Kevin Wang --- docs/app-key-epochs.md | 14 +++++++----- dstack/kms/auth-eth/contracts/DstackApp.sol | 5 +---- dstack/kms/auth-eth/contracts/DstackKms.sol | 5 ++--- dstack/kms/auth-eth/contracts/IAppEpoch.sol | 11 ---------- .../kms/auth-eth/contracts/IAppEpochAuth.sol | 2 ++ dstack/kms/auth-eth/test/DstackApp.t.sol | 9 +++++++- dstack/kms/auth-eth/test/DstackKms.t.sol | 22 ++++++++++++++++--- 7 files changed, 41 insertions(+), 27 deletions(-) delete mode 100644 dstack/kms/auth-eth/contracts/IAppEpoch.sol diff --git a/docs/app-key-epochs.md b/docs/app-key-epochs.md index 1a17e41e5..1a7bf73a0 100644 --- a/docs/app-key-epochs.md +++ b/docs/app-key-epochs.md @@ -18,8 +18,9 @@ prevents a stale or retried transaction from advancing twice. Epochs cannot be rolled back and are limited to uint32. `AdvanceAppEpoch` in `Manage.s.sol` exposes this operation; `GetAppEpoch` in `Query.s.sol` reads it. Upgrade the app policy implementation before advancing, and upgrade the KMS/auth backend and guests -before using nonzero epochs. Legacy policies without the ERC-165 `IAppEpoch` -capability remain at zero; failures of an advertised getter propagate. +before using nonzero epochs. The ERC-165 `IAppEpochAuth` capability combines +`currentAppEpoch()` and `authorizeApp(...)` in one interface. Legacy policies +without this capability remain at zero; failures of an advertised getter propagate. The Ethereum auth backends query the policy through the KMS contract. The simple backend reads `apps[appId].currentAppEpoch` (default zero); operators must preserve @@ -84,9 +85,12 @@ only row zero after upgrading: explicitly populate the intended nonzero rows. Upgrade the KMS policy before app policies, then approve the intended rows before restarting guests or activating a new root. `DstackApp` advertises the optional -ERC-165 `IAppEpochAuth` capability so the KMS can pass the selected app epoch and operation to -`authorizeApp`. Policies without that capability can only authorize app -epoch zero; failures of an advertised method propagate instead of falling back. +ERC-165 `IAppEpochAuth` capability so the KMS can discover the current epoch and +pass the selected app epoch and operation to `authorizeApp`. Its interface ID is +the XOR of both method selectors; policies advertising only either former +single-method interface must upgrade to the combined interface. Policies without +that capability can only authorize app epoch zero; failures of an advertised +method propagate instead of falling back. Direct legacy app checks use the current app epoch; legacy KMS app authorization still rejects nonzero app epochs. diff --git a/dstack/kms/auth-eth/contracts/DstackApp.sol b/dstack/kms/auth-eth/contracts/DstackApp.sol index 8bd73de14..0e720f7f6 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -8,7 +8,6 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; import "./AuthorizationOperation.sol"; -import "./IAppEpoch.sol"; import "./IAppEpochAuth.sol"; import "./IAppAuthBasicManagement.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -23,7 +22,6 @@ contract DstackApp is ERC165Upgradeable, IAppAuth, IAppAuthBasicManagement, - IAppEpoch, IAppEpochAuth { // Mapping of allowed compose hashes for this app @@ -143,8 +141,7 @@ contract DstackApp is override(ERC165Upgradeable, IERC165) returns (bool) { - return interfaceId == type(IAppEpochAuth).interfaceId || interfaceId == type(IAppEpoch).interfaceId - || interfaceId == 0x1e079198 // IAppAuth + return interfaceId == type(IAppEpochAuth).interfaceId || interfaceId == 0x1e079198 // IAppAuth || interfaceId == 0x8fd37527 // IAppAuthBasicManagement || super.supportsInterface(interfaceId); } diff --git a/dstack/kms/auth-eth/contracts/DstackKms.sol b/dstack/kms/auth-eth/contracts/DstackKms.sol index 88ce49c4c..d4c5b37c2 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -8,7 +8,6 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; import "./AuthorizationOperation.sol"; -import "./IAppEpoch.sol"; import "./IAppEpochAuth.sol"; import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -466,8 +465,8 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E /// Legacy policies have epoch zero; an advertised epoch getter must succeed. function currentAppEpoch(address appId) public view returns (uint256) { if (!registeredApps[appId] || appId.code.length == 0) return 0; - if (!ERC165Checker.supportsInterface(appId, type(IAppEpoch).interfaceId)) return 0; - return IAppEpoch(appId).currentAppEpoch(); + if (!ERC165Checker.supportsInterface(appId, type(IAppEpochAuth).interfaceId)) return 0; + return IAppEpochAuth(appId).currentAppEpoch(); } /// Authorize an attested identity and return the root epoch from the same state. diff --git a/dstack/kms/auth-eth/contracts/IAppEpoch.sol b/dstack/kms/auth-eth/contracts/IAppEpoch.sol deleted file mode 100644 index 546c0e26a..000000000 --- a/dstack/kms/auth-eth/contracts/IAppEpoch.sol +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: © 2026 Phala Network - * - * SPDX-License-Identifier: Apache-2.0 - */ - -pragma solidity ^0.8.24; - -interface IAppEpoch { - function currentAppEpoch() external view returns (uint256); -} diff --git a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol index 4df1bc439..b83c6aa6d 100644 --- a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -10,6 +10,8 @@ import "./IAppAuth.sol"; /// Optional epoch- and operation-aware policy capability. Legacy policies only authorize app epoch zero. interface IAppEpochAuth { + function currentAppEpoch() external view returns (uint256); + function authorizeApp( IAppAuth.AppInfo calldata bootInfo, uint256 appEpoch, diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index a0e11094e..68b818eae 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -544,7 +544,14 @@ contract DstackAppTest is Test { vm.expectRevert(unauthorized); app.setAllowAnyDeviceAtEpoch(0, true); vm.stopPrank(); - assertTrue(app.supportsInterface(type(IAppEpochAuth).interfaceId)); + } + + function test_epoch_auth_interface_combines_discovery_and_authorization() public view { + bytes4 combined = IAppEpochAuth.currentAppEpoch.selector ^ IAppEpochAuth.authorizeApp.selector; + assertEq(bytes32(type(IAppEpochAuth).interfaceId), bytes32(combined)); + assertTrue(app.supportsInterface(combined)); + assertFalse(app.supportsInterface(IAppEpochAuth.currentAppEpoch.selector)); + assertFalse(app.supportsInterface(IAppEpochAuth.authorizeApp.selector)); } function testFuzz_AppAuthorizationMatchesOnlyTheSelectedRow(uint8 approved, uint8 requested) public { diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index ba1c1859a..c5defef38 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -585,10 +585,23 @@ contract DstackKmsTest is Test { function test_AppEpochCapabilityIsOptionalButGetterFailuresPropagate() public { address policy = address(new DstackApp()); kms.registerApp(policy); - vm.mockCall(policy, abi.encodeCall(IERC165.supportsInterface, (type(IAppEpoch).interfaceId)), abi.encode(false)); + vm.mockCall(policy, abi.encodeCall(IAppEpochAuth.currentAppEpoch, ()), abi.encode(uint256(7))); + assertEq(kms.currentAppEpoch(policy), 7); + vm.mockCall( + policy, abi.encodeCall(IERC165.supportsInterface, (type(IAppEpochAuth).interfaceId)), abi.encode(false) + ); + // Advertising either old single-method interface does not opt into the combined capability. + vm.mockCall( + policy, + abi.encodeCall(IERC165.supportsInterface, (IAppEpochAuth.currentAppEpoch.selector)), + abi.encode(true) + ); + vm.mockCall( + policy, abi.encodeCall(IERC165.supportsInterface, (IAppEpochAuth.authorizeApp.selector)), abi.encode(true) + ); assertEq(kms.currentAppEpoch(policy), 0); vm.clearMockedCalls(); - vm.mockCallRevert(policy, abi.encodeCall(IAppEpoch.currentAppEpoch, ()), "epoch unavailable"); + vm.mockCallRevert(policy, abi.encodeCall(IAppEpochAuth.currentAppEpoch, ()), "epoch unavailable"); vm.expectRevert("epoch unavailable"); kms.currentAppEpoch(policy); } @@ -1042,8 +1055,11 @@ contract DstackKmsTest is Test { vm.mockCall(info.appId, abi.encodeCall(IAppAuth.isAppAllowed, (info)), abi.encode(true, "")); assertTrue(kms.authorizeApp(epochRequest(info, 0, 0)).isAllowed); DstackKms.AuthorizationResult memory result = authorize(info, false); + assertTrue(result.isAllowed); + assertEq(result.currentAppEpoch, 0); + result = kms.authorizeApp(epochRequest(info, 0, 1)); assertFalse(result.isAllowed); - assertEq(result.rejectionReason, "App policy does not support epoch authorization"); + assertEq(result.rejectionReason, "Requested app epoch is not active"); vm.clearMockedCalls(); vm.mockCallRevert( info.appId, From 08b3f262e12e361ab4ae388863de2eed1e528133 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 8 Nov 2026 01:17:30 -0800 Subject: [PATCH 8/8] test(auth): verify scoped operations on the HTTP wire Signed-off-by: Kevin Wang --- .../kms/src/main_service/upgrade_authority.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index 726e38880..5734efd59 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -771,7 +771,22 @@ pub(crate) mod tests { .unwrap(); } } - for ((route, body), operation) in server.join().unwrap().into_iter().zip(operations) { + let wire_names = [ + "kms_bootstrap", + "kms_serve", + "kms_export_keys", + "kms_import_keys", + "app_get_key", + "app_get_cert", + ]; + for (((route, body), operation), name) in server + .join() + .unwrap() + .into_iter() + .zip(operations) + .zip(wire_names) + { + assert_eq!(body["operation"], name); assert_eq!( route, if operation.is_kms() { @@ -825,7 +840,7 @@ pub(crate) mod tests { .unwrap(); assert!( !auth - .check_kms_authorization(&info, Operation::KmsImportKeys, Some(2)) + .authorize_kms(&info, Operation::KmsImportKeys, Some(2)) .await .unwrap() .is_allowed