diff --git a/docs/app-key-epochs.md b/docs/app-key-epochs.md index d1be0d5c3..1a7bf73a0 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 @@ -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 @@ -28,6 +29,74 @@ 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. 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 + +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 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. + +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 +111,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/README.md b/dstack/kms/README.md index e38cc0ea2..b4b07d895 100644 --- a/dstack/kms/README.md +++ b/dstack/kms/README.md @@ -267,10 +267,13 @@ 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. 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 @@ -307,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/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..0e720f7f6 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -7,7 +7,8 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; -import "./IAppEpoch.sol"; +import "./AuthorizationOperation.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,7 @@ contract DstackApp is ERC165Upgradeable, IAppAuth, IAppAuthBasicManagement, - IAppEpoch + IAppEpochAuth { // Mapping of allowed compose hashes for this app mapping(bytes32 => bool) public allowedComposeHashes; @@ -41,6 +42,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 +141,7 @@ contract DstackApp is override(ERC165Upgradeable, IERC165) returns (bool) { - return interfaceId == type(IAppEpoch).interfaceId || interfaceId == 0x1e079198 // IAppAuth + return interfaceId == type(IAppEpochAuth).interfaceId || interfaceId == 0x1e079198 // IAppAuth || interfaceId == 0x8fd37527 // IAppAuthBasicManagement || super.supportsInterface(interfaceId); } @@ -148,26 +156,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 +221,69 @@ 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 _authorizeApp(bootInfo, currentAppEpoch); + } + + function authorizeApp( + IAppAuth.AppInfo calldata bootInfo, + 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); + } + + function _authorizeApp( + 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..d4c5b37c2 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -8,7 +8,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 +25,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 +94,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,53 +359,114 @@ 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. 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. @@ -437,8 +504,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 +526,47 @@ 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, request.operation); } } // 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,25 @@ 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, bytes32(0)); } - function _isAppAllowed(AppInfo memory bootInfo) private view returns (bool, string memory) { + function _isAppAllowed( + AppInfo memory bootInfo, + uint256 selectedRootEpoch, + uint256 appEpoch, + bytes32 operation + ) + 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 +610,12 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E return (false, "App not deployed or invalid address"); } + 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"); + // 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 +624,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/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 new file mode 100644 index 000000000..b83c6aa6d --- /dev/null +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +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, + bytes32 operation + ) + 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..68b818eae 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -409,4 +409,163 @@ 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_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("app_get_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); + app.addComposeHash(info.composeHash); + app.addDevice(info.deviceId); + app.advanceAppEpoch(0); + (bool allowed,) = app.isAppAllowed(info); + assertFalse(allowed); + (allowed,) = app.authorizeApp(info, 0, keccak256("app_get_key")); + 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.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, 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, keccak256("app_get_key")); + 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.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, 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, keccak256("app_get_key")); + 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.authorizeApp(info, 0, keccak256("app_get_key")); + 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(); + } + + 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 { + 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.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 c4d4b8f15..c5defef38 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -503,32 +503,30 @@ 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) + // 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(kmsEpochRequest(info, 1, 0)).isAllowed, "pending handover must be explicitly approved" ); - DstackKms.AuthorizationResult memory result = kms.authorizeKms(request); + assertFalse(kms.authorizeApp(epochRequest(info, 1, 0)).isAllowed, "apps cannot fetch pending keys"); + DstackKms.AuthorizationResult memory result = kms.authorizeKms(kmsEpochRequest(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(kmsEpochRequest(info, 0, 0)).isAllowed, "epoch one does not imply epoch zero"); + approveKmsAtEpoch(info, 0); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).isAllowed); + vm.stopPrank(); } function test_CheckAuthorization_UnregisteredEpochReturnsRejection() public view { @@ -587,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); } @@ -621,7 +632,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 +872,201 @@ 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, + 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); + 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(kmsEpochRequest(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(kmsEpochRequest(info, 0, 0)).isAllowed); + kms.addOsImageHash(info.osImageHash); + kms.removeKmsAggregatedMrAtEpoch(0, info.mrAggregated); + 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(kmsEpochRequest(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_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("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("app_get_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); + 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); + assertTrue(result.isAllowed); + assertEq(result.currentAppEpoch, 0); + result = kms.authorizeApp(epochRequest(info, 0, 1)); + assertFalse(result.isAllowed); + assertEq(result.rejectionReason, "Requested app epoch is not active"); + vm.clearMockedCalls(); + 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 4358e4ad0..e08d2422e 100644 --- a/dstack/kms/src/main_service.rs +++ b/dstack/kms/src/main_service.rs @@ -569,11 +569,16 @@ 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..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() { @@ -801,6 +816,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 +838,22 @@ pub(crate) mod tests { auth.authorize_kms(&info, Operation::KmsServe, None) .await .unwrap(); + assert!( + !auth + .authorize_kms(&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);