From 450d93a635ea8556e6e2bc8d2491a19ca3e8d401 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 13 Nov 2026 22:39:17 -0800 Subject: [PATCH 1/9] feat(auth-eth): gate app boots and KMS key imports on launch tickets Signed-off-by: Kevin Wang --- dstack/kms/README.md | 32 ++++++ dstack/kms/auth-eth/contracts/DstackApp.sol | 57 ++++++++-- dstack/kms/auth-eth/contracts/DstackKms.sol | 51 ++++++++- .../kms/auth-eth/contracts/IAppEpochAuth.sol | 5 + dstack/kms/auth-eth/script/Manage.s.sol | 57 ++++++++++ dstack/kms/auth-eth/script/README.md | 38 +++++++ .../auth-eth/test/DstackApp.symbolic.t.sol | 6 ++ dstack/kms/auth-eth/test/DstackApp.t.sol | 52 ++++++++- .../test/DstackAppEpoch.symbolic.t.sol | 10 +- .../auth-eth/test/DstackKms.symbolic.t.sol | 6 ++ dstack/kms/auth-eth/test/DstackKms.t.sol | 100 ++++++++++++++++-- .../test/DstackKmsEpoch.symbolic.t.sol | 6 +- .../kms/auth-eth/test/LegacyAppCompat.t.sol | 7 +- 13 files changed, 406 insertions(+), 21 deletions(-) diff --git a/dstack/kms/README.md b/dstack/kms/README.md index 1c89cc615..f28fd8ee2 100644 --- a/dstack/kms/README.md +++ b/dstack/kms/README.md @@ -333,6 +333,38 @@ Self-authorization caches are keyed by operation. Legacy authorization endpoints retain their existing semantics for old KMS binaries; fine-grained operation policies cannot protect calls still served by those old binaries. +### Launch tickets + +A launch ticket stops a party without it from launching an app, or onboarding a +KMS instance, even on an allowed OS image and device. The CVM presents the +ticket to the KMS, and the KMS sends its hash as `launchTicketHash`, zero when +none was presented: + +| Endpoint | Hash | Checked for | +| --- | --- | --- | +| `authorizeApp` | `sha256("dstack-launch-ticket/v1:" \|\| ticket)` | `app_get_key`, `app_get_cert` | +| `authorizeKms` | `sha256("dstack-kms-launch-ticket/v1:" \|\| ticket)` | `kms_import_keys` | + +Only the key import hands a joining KMS the root keys, so the other KMS +operations take no ticket. `DstackApp.setRequireLaunchTicket` and +`DstackKms.setRequireKmsLaunchTicket` turn the check on; `addLaunchTicket` and +`addKmsLaunchTicket` allow a hash, listed by `launchTickets()` and +`kmsLaunchTickets()`; their public label is only logged in the added event. The allowlists cover every app and +root epoch. A required ticket refuses the legacy `isAppAllowed` and +`isKmsAllowed`, whose callers cannot present one. + +The ticket is a bearer secret: the host launching the CVM holds it. Use a fresh +random ticket per deployment, and remove it once the host stops serving it; a +KMS ticket is needed only while onboarding, so remove it once the instance has +joined. A guest sends its ticket before it has authenticated the KMS, so its +secrecy also rests on the network path between the CVM and the KMS. + +App keys are deterministic, so a release before the requirement is set cannot +be taken back. Deploy the app contract without a compose hash, call +`setRequireLaunchTicket(true)` and `addLaunchTicket`, and only then add the +compose hash. Turning the requirement on for a running app leaves its CVMs +running, but their certificate requests fail until they restart with a ticket. + The response is an `AuthorizationResult`: ```json diff --git a/dstack/kms/auth-eth/contracts/DstackApp.sol b/dstack/kms/auth-eth/contracts/DstackApp.sol index 6266d77f1..ea816828b 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -15,6 +15,7 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; contract DstackApp is Initializable, @@ -25,6 +26,8 @@ contract DstackApp is IAppEpochAuth, IAppEpochManagement { + using EnumerableSet for EnumerableSet.Bytes32Set; + /// @notice Allowed compose hashes for this app. Epoch 0 only; for other epochs use the *AtEpoch getters and /// EpochPolicyChanged. mapping(bytes32 => bool) public allowedComposeHashes; @@ -52,6 +55,11 @@ contract DstackApp is // The TEE platforms this app may run on. TeeVariantPolicy.Policy private _teeVariants; + /// @notice Whether a boot must present an allowed launch ticket, at every app epoch. + bool public requireLaunchTicket; + // Allowed launch tickets, by sha256("dstack-launch-ticket/v1:" || ticket), at every app epoch. + EnumerableSet.Bytes32Set private _launchTickets; + event EpochPolicyChanged(bytes32 indexed policy, bytes32 indexed value, uint256 indexed epoch, bool enabled); /// @notice Legacy material events. Epoch 0 only; for other epochs use the *AtEpoch getters and EpochPolicyChanged. @@ -65,6 +73,9 @@ contract DstackApp is event AllowAnyTeeVariantSet(bool allowAny); event AllowedTeeVariantsSet(string[] teeVariants); event RequireTcbUpToDateSet(bool requireUpToDate); + event RequireLaunchTicketSet(bool required); + event LaunchTicketAdded(bytes32 indexed ticketHash, string label); + event LaunchTicketRemoved(bytes32 indexed ticketHash); /// @notice Additive audit event for reconstructing authorization policy. event PolicyChanged(address indexed actor, bytes32 indexed policy, bytes32 indexed value, bool enabled); @@ -268,6 +279,31 @@ contract DstackApp is _emitPolicy("require-tcb-up-to-date", bytes32(0), _requireUpToDate); } + function setRequireLaunchTicket(bool required) external onlyOwner { + requireLaunchTicket = required; + emit RequireLaunchTicketSet(required); + _emitPolicy("require-launch-ticket", bytes32(0), required); + } + + /// @notice Allow a launch ticket. The label is public and only logged. + function addLaunchTicket(bytes32 ticketHash, string calldata label) external onlyOwner { + require(ticketHash != bytes32(0), "invalid launch ticket hash"); + _launchTickets.add(ticketHash); + emit LaunchTicketAdded(ticketHash, label); + _emitPolicy("launch-ticket", ticketHash, true); + } + + function removeLaunchTicket(bytes32 ticketHash) external onlyOwner { + _launchTickets.remove(ticketHash); + emit LaunchTicketRemoved(ticketHash); + _emitPolicy("launch-ticket", ticketHash, false); + } + + /// @notice The allowed launch tickets; their labels are in {LaunchTicketAdded}. + function launchTickets() external view returns (bytes32[] memory) { + return _launchTickets.values(); + } + function addDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { require(deviceId != bytes32(0), "invalid device ID"); _setDevice(epoch, deviceId, true); @@ -308,8 +344,9 @@ contract DstackApp is returns (bool isAllowed, string memory reason) { if (currentAppEpoch != 0) return (false, "App epoch requires epoch-aware authorization"); - // Legacy callers report no platform, so only an unrestricted policy admits them. - return _authorizeApp(bootInfo, allowAnyTeeVariant(), 0); + // Legacy callers report no platform and no launch ticket, so only a policy + // restricting neither admits them. + return _authorizeApp(bootInfo, allowAnyTeeVariant(), 0, bytes32(0)); } /// Decide the request at the selected app epoch; the KMS fills gateway and root fields. @@ -325,19 +362,27 @@ contract DstackApp is return result; } uint256 appEpoch = request.requestedAppEpoch.isSet ? request.requestedAppEpoch.value : currentAppEpoch; - (result.authorized, result.rejectionReason) = - _authorizeApp(request.appInfo, TeeVariantPolicy.admits(_teeVariants, request.teeVariant), appEpoch); + (result.authorized, result.rejectionReason) = _authorizeApp( + request.appInfo, + TeeVariantPolicy.admits(_teeVariants, request.teeVariant), + appEpoch, + request.launchTicketHash + ); } function _authorizeApp( IAppAuth.AppInfo calldata bootInfo, bool teeVariantAllowed, - uint256 appEpoch + uint256 appEpoch, + bytes32 launchTicketHash ) private view returns (bool, string memory) { + if (requireLaunchTicket && !_launchTickets.contains(launchTicketHash)) { + return (false, "Invalid launch ticket"); + } if (appEpoch > currentAppEpoch) return (false, "Requested app epoch is not active"); // Optionally require TCB status to be up to date if ( @@ -381,5 +426,5 @@ contract DstackApp is } // Add storage gap for upgradeable contracts - uint256[45] private __gap; + uint256[42] private __gap; } diff --git a/dstack/kms/auth-eth/contracts/DstackKms.sol b/dstack/kms/auth-eth/contracts/DstackKms.sol index 2eb798ddc..cda6196a4 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -16,8 +16,11 @@ import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, ERC165Upgradeable, IAppAuth { + using EnumerableSet for EnumerableSet.Bytes32Set; + // Struct for KMS information struct KmsInfo { bytes k256Pubkey; @@ -93,6 +96,12 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E // The TEE platforms KMS instances and apps may run on. TeeVariantPolicy.Policy private _teeVariants; + /// @notice Whether a KMS instance must present an allowed launch ticket to + /// receive the root keys, at every root epoch. + bool public requireKmsLaunchTicket; + // Allowed KMS launch tickets, by sha256("dstack-kms-launch-ticket/v1:" || ticket). + EnumerableSet.Bytes32Set private _kmsLaunchTickets; + event EpochPolicyChanged(bytes32 indexed policy, bytes32 indexed value, uint256 indexed epoch, bool enabled); error InvalidEpochCaPubkey(); @@ -111,6 +120,7 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E error InvalidRootEpochRange(); error AppNotRegistered(); error AppNotDeployed(); + error InvalidLaunchTicketHash(); // Events // appId is unindexed for backward compatibility with deployed log indexers. @@ -127,6 +137,9 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E event GatewayAppIdSet(string gatewayAppId); event AllowAnyTeeVariantSet(bool allowAny); event AllowedTeeVariantsSet(string[] teeVariants); + event RequireKmsLaunchTicketSet(bool required); + event KmsLaunchTicketAdded(bytes32 indexed ticketHash, string label); + event KmsLaunchTicketRemoved(bytes32 indexed ticketHash); // slither-disable-next-line unindexed-event-address event AppImplementationSet(address implementation); event RootEpochRegistered( @@ -313,6 +326,31 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E _emitPolicy(keccak256("allowed-tee-variants"), bytes32(uint256(mask)), true); } + function setRequireKmsLaunchTicket(bool required) external onlyOwner { + requireKmsLaunchTicket = required; + emit RequireKmsLaunchTicketSet(required); + _emitPolicy(keccak256("require-kms-launch-ticket"), bytes32(0), required); + } + + /// @notice Allow a KMS launch ticket. The label is public and only logged. + function addKmsLaunchTicket(bytes32 ticketHash, string calldata label) external onlyOwner { + if (ticketHash == bytes32(0)) revert InvalidLaunchTicketHash(); + _kmsLaunchTickets.add(ticketHash); + emit KmsLaunchTicketAdded(ticketHash, label); + _emitPolicy(keccak256("kms-launch-ticket"), ticketHash, true); + } + + function removeKmsLaunchTicket(bytes32 ticketHash) external onlyOwner { + _kmsLaunchTickets.remove(ticketHash); + emit KmsLaunchTicketRemoved(ticketHash); + _emitPolicy(keccak256("kms-launch-ticket"), ticketHash, false); + } + + /// @notice The allowed KMS launch tickets; their labels are in {KmsLaunchTicketAdded}. + function kmsLaunchTickets() external view returns (bytes32[] memory) { + return _kmsLaunchTickets.values(); + } + // Function to set trusted Gateway App ID function setGatewayAppId(string memory appId) external onlyOwner { gatewayAppId = appId; @@ -603,6 +641,15 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E result.rejectionReason = "Invalid authorization operation"; return result; } + // Only the key import hands a joining instance the root keys; an + // instance that bootstraps or serves without them holds no registered root. + if ( + request.operation == AuthorizationOperation.KMS_IMPORT_KEYS && requireKmsLaunchTicket + && !_kmsLaunchTickets.contains(request.launchTicketHash) + ) { + result.rejectionReason = "Invalid launch ticket"; + return result; + } if (_rootEpochs.length == 0 && request.operation != AuthorizationOperation.KMS_BOOTSTRAP) { result.rejectionReason = "Root epoch not registered"; return result; @@ -633,6 +680,8 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E // Legacy entry point for pre-epoch KMS binaries: reads epoch 0, and refuses // once a later root epoch is current. function isKmsAllowed(AppInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason) { + // Legacy callers present no launch ticket and cannot say which operation they authorize. + if (requireKmsLaunchTicket) return (false, "Invalid launch ticket"); if (_currentRootEpoch != 0) { return (false, "Root epoch requires epoch-aware authorization"); } @@ -701,7 +750,7 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E } // Add storage gap for upgradeable contracts - uint256[43] private __gap; + uint256[40] 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 index 4134f7a0f..bba9d698d 100644 --- a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -23,6 +23,11 @@ struct AuthorizationRequest { /// Measurements, device IDs and TCB status are only meaningful per platform, /// and one OS image may boot on several. string teeVariant; + /// The launch ticket the attested subject presented, as + /// sha256("dstack-launch-ticket/v1:" || ticket) for app operations and + /// sha256("dstack-kms-launch-ticket/v1:" || ticket) for KMS ones; zero when + /// it presented none. + bytes32 launchTicketHash; } struct AuthorizationResult { diff --git a/dstack/kms/auth-eth/script/Manage.s.sol b/dstack/kms/auth-eth/script/Manage.s.sol index ac32439c5..ba9ba9a6f 100644 --- a/dstack/kms/auth-eth/script/Manage.s.sol +++ b/dstack/kms/auth-eth/script/Manage.s.sol @@ -154,6 +154,33 @@ contract SetKmsTeeVariants is BaseScript { } } +contract RequireKmsLaunchTicket is BaseScript { + function run() external { + bool required = vm.envBool("REQUIRE_LAUNCH_TICKET"); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + kms.setRequireKmsLaunchTicket(required); + console.log("Set requireKmsLaunchTicket to:", required); + } +} + +contract AddKmsLaunchTicket is BaseScript { + function run() external { + bytes32 ticketHash = vm.envBytes32("LAUNCH_TICKET_HASH"); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + kms.addKmsLaunchTicket(ticketHash, vm.envString("LAUNCH_TICKET_LABEL")); + console.log("Added KMS launch ticket:", vm.toString(ticketHash)); + } +} + +contract RemoveKmsLaunchTicket is BaseScript { + function run() external { + bytes32 ticketHash = vm.envBytes32("LAUNCH_TICKET_HASH"); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + kms.removeKmsLaunchTicket(ticketHash); + console.log("Removed KMS launch ticket:", vm.toString(ticketHash)); + } +} + contract SetGatewayAppId is BaseScript { function run() external { string memory gatewayId = vm.envString("GATEWAY_APP_ID"); @@ -288,6 +315,36 @@ contract SetTeeVariants is BaseScript { } } +contract RequireLaunchTicket is BaseScript { + function run() external { + bool required = vm.envBool("REQUIRE_LAUNCH_TICKET"); + require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.setRequireLaunchTicket(required); + console.log("Set requireLaunchTicket to:", required); + } +} + +contract AddLaunchTicket is BaseScript { + function run() external { + bytes32 ticketHash = vm.envBytes32("LAUNCH_TICKET_HASH"); + require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.addLaunchTicket(ticketHash, vm.envString("LAUNCH_TICKET_LABEL")); + console.log("Added launch ticket:", vm.toString(ticketHash)); + } +} + +contract RemoveLaunchTicket is BaseScript { + function run() external { + bytes32 ticketHash = vm.envBytes32("LAUNCH_TICKET_HASH"); + require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.removeLaunchTicket(ticketHash); + console.log("Removed launch ticket:", vm.toString(ticketHash)); + } +} + // Factory Deployment contract DeployApp is BaseScript { function run() external returns (address) { diff --git a/dstack/kms/auth-eth/script/README.md b/dstack/kms/auth-eth/script/README.md index 9ace1fef4..8fe9451e8 100644 --- a/dstack/kms/auth-eth/script/README.md +++ b/dstack/kms/auth-eth/script/README.md @@ -62,6 +62,24 @@ KMS_CONTRACT_ADDR=0x... TEE_VARIANTS=dstack-tdx \ forge script script/Manage.s.sol:SetKmsTeeVariants --broadcast --rpc-url $RPC_URL ``` +#### Require KMS Launch Tickets +A KMS instance must then present an allowed launch ticket to import the root keys. +```bash +KMS_CONTRACT_ADDR=0x... REQUIRE_LAUNCH_TICKET=true \ +forge script script/Manage.s.sol:RequireKmsLaunchTicket --broadcast --rpc-url $RPC_URL +``` + +#### Add or Remove a KMS Launch Ticket +`LAUNCH_TICKET_HASH` is `sha256("dstack-kms-launch-ticket/v1:" || ticket)`; the +label is public. Remove the ticket once the instance has joined. +```bash +LAUNCH_TICKET_HASH=0x$(printf 'dstack-kms-launch-ticket/v1:%s' "$TICKET" | sha256sum | cut -d' ' -f1) +KMS_CONTRACT_ADDR=0x... LAUNCH_TICKET_HASH=$LAUNCH_TICKET_HASH LAUNCH_TICKET_LABEL=kms-node-2 \ +forge script script/Manage.s.sol:AddKmsLaunchTicket --broadcast --rpc-url $RPC_URL +KMS_CONTRACT_ADDR=0x... LAUNCH_TICKET_HASH=$LAUNCH_TICKET_HASH \ +forge script script/Manage.s.sol:RemoveKmsLaunchTicket --broadcast --rpc-url $RPC_URL +``` + #### Set Gateway App ID ```bash KMS_CONTRACT_ADDR=0x... GATEWAY_APP_ID="my-gateway" \ @@ -188,6 +206,26 @@ KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... TEE_VARIANTS=dstack-tdx,dstack-g forge script script/Manage.s.sol:SetTeeVariants --broadcast --rpc-url $RPC_URL ``` +#### Require Launch Tickets +The app must then present an allowed launch ticket, at every app epoch. For a new +app, require the ticket and add it before adding the compose hash: a key released +in between cannot be taken back. +```bash +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... REQUIRE_LAUNCH_TICKET=true \ +forge script script/Manage.s.sol:RequireLaunchTicket --broadcast --rpc-url $RPC_URL +``` + +#### Add or Remove a Launch Ticket +`LAUNCH_TICKET_HASH` is `sha256("dstack-launch-ticket/v1:" || ticket)`; the label +is public. +```bash +LAUNCH_TICKET_HASH=0x$(printf 'dstack-launch-ticket/v1:%s' "$TICKET" | sha256sum | cut -d' ' -f1) +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... LAUNCH_TICKET_HASH=$LAUNCH_TICKET_HASH LAUNCH_TICKET_LABEL=prod \ +forge script script/Manage.s.sol:AddLaunchTicket --broadcast --rpc-url $RPC_URL +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... LAUNCH_TICKET_HASH=$LAUNCH_TICKET_HASH \ +forge script script/Manage.s.sol:RemoveLaunchTicket --broadcast --rpc-url $RPC_URL +``` + #### Remove Compose Hash ```bash KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... APP_EPOCH=0 COMPOSE_HASH=0x1234... \ diff --git a/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol index 1b3bb1244..d6541b054 100644 --- a/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol @@ -133,6 +133,12 @@ contract DstackAppSymbolicTest is Test { data = abi.encodeWithSelector(Ownable2StepUpgradeable.acceptOwnership.selector); } else if (which == 10) { data = abi.encodeWithSelector(OwnableUpgradeable.renounceOwnership.selector); + } else if (which == 11) { + data = abi.encodeWithSelector(DstackApp.setRequireLaunchTicket.selector, flag); + } else if (which == 12) { + data = abi.encodeWithSelector(DstackApp.addLaunchTicket.selector, word, ""); + } else if (which == 13) { + data = abi.encodeWithSelector(DstackApp.removeLaunchTicket.selector, word); } else { return; // outside the enumerated mutating surface } diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index 30edd6bad..2f797718d 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -426,7 +426,7 @@ contract DstackAppTest is Test { pure returns (AuthorizationRequest memory) { - return AuthorizationRequest(info, operation, appEpoch, OptionalUint256(false, 0), "dstack-tdx"); + return AuthorizationRequest(info, operation, appEpoch, OptionalUint256(false, 0), "dstack-tdx", bytes32(0)); } function authorizeAt( @@ -498,6 +498,56 @@ contract DstackAppTest is Test { vm.stopPrank(); } + function test_LaunchTicketGatesEveryEpochOnceRequired() public { + IAppAuth.AppInfo memory info = epochInfo(); + bytes32 ticket = sha256("dstack-launch-ticket/v1:prod"); + vm.startPrank(owner); + app.addComposeHashAtEpochs(0, 1, info.composeHash); + app.addDeviceAtEpochs(0, 1, info.deviceId); + vm.stopPrank(); + AuthorizationRequest memory request = appRequest(info, OptionalUint256(true, 0), keccak256("app_get_key")); + // Not required: any ticket, or none, is admitted. + assertTrue(app.authorizeApp(request).authorized); + + vm.startPrank(owner); + app.setRequireLaunchTicket(true); + vm.expectRevert("invalid launch ticket hash"); + app.addLaunchTicket(bytes32(0), "zero"); + app.addLaunchTicket(ticket, "prod"); + vm.stopPrank(); + assertEq(app.launchTickets().length, 1); + assertEq(app.launchTickets()[0], ticket); + assertEq(app.authorizeApp(request).rejectionReason, "Invalid launch ticket"); + (bool allowed, string memory reason) = app.isAppAllowed(info); + assertFalse(allowed); + assertEq(reason, "Invalid launch ticket"); + + request.launchTicketHash = ticket; + assertTrue(app.authorizeApp(request).authorized); + request.operation = keccak256("app_get_cert"); + assertTrue(app.authorizeApp(request).authorized); + // The allowlist covers every app epoch. + vm.prank(owner); + app.advanceAppEpoch(0); + request.requestedAppEpoch = OptionalUint256(true, 1); + assertTrue(app.authorizeApp(request).authorized); + + vm.prank(owner); + app.removeLaunchTicket(ticket); + assertEq(app.launchTickets().length, 0); + assertEq(app.authorizeApp(request).rejectionReason, "Invalid launch ticket"); + + vm.startPrank(user); + bytes memory unauthorized = abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user); + vm.expectRevert(unauthorized); + app.setRequireLaunchTicket(false); + vm.expectRevert(unauthorized); + app.addLaunchTicket(ticket, "prod"); + vm.expectRevert(unauthorized); + app.removeLaunchTicket(ticket); + vm.stopPrank(); + } + function test_AuthorizeAppOwnsOperationScopeAndAppEpoch() public { IAppAuth.AppInfo memory info = epochInfo(); vm.startPrank(owner); diff --git a/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol index e5b8514cb..928224711 100644 --- a/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol +++ b/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol @@ -21,7 +21,9 @@ function _appAuth( returns (bool, string memory) { AuthorizationResult memory result = target.authorizeApp( - AuthorizationRequest(info, operation, OptionalUint256(true, epoch), OptionalUint256(false, 0), "dstack-tdx") + AuthorizationRequest( + info, operation, OptionalUint256(true, epoch), OptionalUint256(false, 0), "dstack-tdx", bytes32(0) + ) ); return (result.authorized, result.rejectionReason); } @@ -142,6 +144,9 @@ contract DstackAppEpochSymbolicTest is Test { else if (which == 13) data = abi.encodeCall(Ownable2StepUpgradeable.transferOwnership, (addr)); else if (which == 14) data = abi.encodeCall(Ownable2StepUpgradeable.acceptOwnership, ()); else if (which == 15) data = abi.encodeCall(OwnableUpgradeable.renounceOwnership, ()); + else if (which == 16) data = abi.encodeCall(DstackApp.setRequireLaunchTicket, (flag)); + else if (which == 17) data = abi.encodeCall(DstackApp.addLaunchTicket, (word, "")); + else if (which == 18) data = abi.encodeCall(DstackApp.removeLaunchTicket, (word)); else return; vm.prank(caller); @@ -307,7 +312,8 @@ contract DstackAppEpochSymbolicTest is Test { AuthorizationOperation.APP_GET_KEY, OptionalUint256(true, epoch), OptionalUint256(false, 0), - listed ? "dstack-tdx" : "dstack-amd-sev-snp" + listed ? "dstack-tdx" : "dstack-amd-sev-snp", + bytes32(0) ) ); assert(result.authorized == (unrestricted && (!restrict || listed))); diff --git a/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol index 20887ef26..67648e18b 100644 --- a/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol @@ -371,6 +371,12 @@ contract DstackKmsSymbolicTest is Test { ); } else if (which == 11) { data = abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, addr, ""); + } else if (which == 12) { + data = abi.encodeWithSelector(DstackKms.setRequireKmsLaunchTicket.selector, flag); + } else if (which == 13) { + data = abi.encodeWithSelector(DstackKms.addKmsLaunchTicket.selector, word, ""); + } else if (which == 14) { + data = abi.encodeWithSelector(DstackKms.removeKmsLaunchTicket.selector, word); } else { return; // outside the enumerated KMS-own mutating surface } diff --git a/dstack/kms/auth-eth/test/DstackKms.t.sol b/dstack/kms/auth-eth/test/DstackKms.t.sol index ea8b08407..33be6019a 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -498,7 +498,8 @@ contract DstackKmsTest is Test { isKms ? keccak256("kms_serve") : keccak256("app_get_key"), OptionalUint256(false, 0), OptionalUint256(false, 0), - "dstack-tdx" + "dstack-tdx", + bytes32(0) ); return isKms ? kms.authorizeKms(request) : kms.authorizeApp(request); } @@ -524,7 +525,7 @@ contract DstackKmsTest is Test { assertFalse(allowed); assertEq(reason, "TEE variant not allowed"); AuthorizationRequest memory request = AuthorizationRequest( - info, keccak256("kms_serve"), OptionalUint256(false, 0), OptionalUint256(false, 0), "dstack-tdx" + info, keccak256("kms_serve"), OptionalUint256(false, 0), OptionalUint256(false, 0), "dstack-tdx", bytes32(0) ); request.teeVariant = "dstack-gcp-tdx"; assertTrue(kms.authorizeKms(request).authorized); @@ -540,7 +541,12 @@ contract DstackKmsTest is Test { assertFalse(allowed); assertEq(reason, "TEE variant not allowed"); request = AuthorizationRequest( - info, keccak256("app_get_key"), OptionalUint256(false, 0), OptionalUint256(false, 0), "dstack-tdx" + info, + keccak256("app_get_key"), + OptionalUint256(false, 0), + OptionalUint256(false, 0), + "dstack-tdx", + bytes32(0) ); request.teeVariant = "dstack-amd-sev-snp"; assertEq(kms.authorizeApp(request).rejectionReason, "TEE variant not allowed"); @@ -577,7 +583,12 @@ contract DstackKmsTest is Test { kms.addKmsAggregatedMrAtEpochs(0, 0, info.mrAggregated); vm.stopPrank(); AuthorizationRequest memory request = AuthorizationRequest( - info, keccak256("kms_bootstrap"), OptionalUint256(false, 0), OptionalUint256(false, 0), "dstack-tdx" + info, + keccak256("kms_bootstrap"), + OptionalUint256(false, 0), + OptionalUint256(false, 0), + "dstack-tdx", + bytes32(0) ); assertTrue(kms.authorizeKms(request).authorized); assertEq(kms.authorizeKms(request).currentRootEpochHash, bytes32(0)); @@ -596,6 +607,63 @@ contract DstackKmsTest is Test { assertEq(kms.authorizeApp(request).rejectionReason, "Invalid authorization operation"); } + function test_KmsLaunchTicketGatesOnlyTheKeyImport() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + bytes32 ticket = sha256("dstack-kms-launch-ticket/v1:replica"); + vm.startPrank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "https://example.com/epoch-0"); + kms.addOsImageHashAtEpochs(0, 0, info.osImageHash); + kms.addKmsDeviceAtEpochs(0, 0, info.deviceId); + kms.addKmsAggregatedMrAtEpochs(0, 0, info.mrAggregated); + kms.setRequireKmsLaunchTicket(true); + vm.expectRevert(DstackKms.InvalidLaunchTicketHash.selector); + kms.addKmsLaunchTicket(bytes32(0), "zero"); + kms.addKmsLaunchTicket(ticket, "replica"); + vm.stopPrank(); + assertEq(kms.kmsLaunchTickets()[0], ticket); + + AuthorizationRequest memory request = AuthorizationRequest( + info, + keccak256("kms_import_keys"), + OptionalUint256(false, 0), + OptionalUint256(false, 0), + "dstack-tdx", + bytes32(0) + ); + assertEq(kms.authorizeKms(request).rejectionReason, "Invalid launch ticket"); + request.launchTicketHash = ticket; + assertTrue(kms.authorizeKms(request).authorized); + // The other operations hand out no root keys and need no ticket. + request.launchTicketHash = bytes32(0); + request.operation = keccak256("kms_export_keys"); + assertTrue(kms.authorizeKms(request).authorized); + request.operation = keccak256("kms_serve"); + assertTrue(kms.authorizeKms(request).authorized); + request.operation = keccak256("kms_bootstrap"); + assertTrue(kms.authorizeKms(request).authorized); + // Legacy callers cannot present one. + (bool allowed, string memory reason) = kms.isKmsAllowed(info); + assertFalse(allowed); + assertEq(reason, "Invalid launch ticket"); + + vm.prank(owner); + kms.removeKmsLaunchTicket(ticket); + assertEq(kms.kmsLaunchTickets().length, 0); + request.operation = keccak256("kms_import_keys"); + request.launchTicketHash = ticket; + assertEq(kms.authorizeKms(request).rejectionReason, "Invalid launch ticket"); + + vm.startPrank(user); + bytes memory unauthorized = abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", user); + vm.expectRevert(unauthorized); + kms.setRequireKmsLaunchTicket(false); + vm.expectRevert(unauthorized); + kms.addKmsLaunchTicket(ticket, "replica"); + vm.expectRevert(unauthorized); + kms.removeKmsLaunchTicket(ticket); + vm.stopPrank(); + } + function test_CheckAuthorization_OptionalUint256DistinguishesZeroFromDefault() public { IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); vm.startPrank(owner); @@ -660,7 +728,12 @@ contract DstackKmsTest is Test { assertFalse(result.authorized); assertEq(result.rejectionReason, "Aggregated MR not allowed"); AuthorizationRequest memory appRequest = AuthorizationRequest( - info, keccak256("app_get_key"), OptionalUint256(true, 1), OptionalUint256(false, 0), "dstack-tdx" + info, + keccak256("app_get_key"), + OptionalUint256(true, 1), + OptionalUint256(false, 0), + "dstack-tdx", + bytes32(0) ); result = kms.authorizeApp(appRequest); assertFalse(result.authorized); @@ -746,7 +819,12 @@ contract DstackKmsTest is Test { (uint256 rootEpoch,) = kms.currentRootEpoch(); assertEq(rootEpoch, 0); AuthorizationRequest memory request = AuthorizationRequest( - info, keccak256("app_get_key"), OptionalUint256(false, 0), OptionalUint256(false, 0), "dstack-tdx" + info, + keccak256("app_get_key"), + OptionalUint256(false, 0), + OptionalUint256(false, 0), + "dstack-tdx", + bytes32(0) ); AuthorizationResult memory result = kms.authorizeApp(request); assertFalse(result.authorized); @@ -788,7 +866,8 @@ contract DstackKmsTest is Test { keccak256("kms_serve"), OptionalUint256(false, 0), OptionalUint256(false, 0), - "dstack-tdx" + "dstack-tdx", + bytes32(0) ); result = kms.authorizeApp(request); assertEq(result.rejectionReason, "Invalid authorization operation"); @@ -1022,7 +1101,12 @@ contract DstackKmsTest is Test { returns (AuthorizationRequest memory) { return AuthorizationRequest( - info, keccak256("app_get_key"), OptionalUint256(true, appEpoch), OptionalUint256(true, root), "dstack-tdx" + info, + keccak256("app_get_key"), + OptionalUint256(true, appEpoch), + OptionalUint256(true, root), + "dstack-tdx", + bytes32(0) ); } diff --git a/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol index 3405e2102..c7236b4ea 100644 --- a/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol +++ b/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol @@ -22,7 +22,9 @@ function _appAuth( returns (bool, string memory) { AuthorizationResult memory result = target.authorizeApp( - AuthorizationRequest(info, operation, OptionalUint256(true, epoch), OptionalUint256(false, 0), "dstack-tdx") + AuthorizationRequest( + info, operation, OptionalUint256(true, epoch), OptionalUint256(false, 0), "dstack-tdx", bytes32(0) + ) ); return (result.authorized, result.rejectionReason); } @@ -135,7 +137,7 @@ contract DstackKmsEpochSymbolicTest is Test { returns (AuthorizationRequest memory) { return AuthorizationRequest( - info, operation, OptionalUint256(appSet, appEpoch), OptionalUint256(rootSet, root), "dstack-tdx" + info, operation, OptionalUint256(appSet, appEpoch), OptionalUint256(rootSet, root), "dstack-tdx", bytes32(0) ); } diff --git a/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol b/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol index e32a51358..18e586455 100644 --- a/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol +++ b/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol @@ -76,7 +76,12 @@ contract LegacyAppCompatTest is Test { info.tcbStatus = "UpToDate"; info.advisoryIds = new string[](0); return AuthorizationRequest( - info, keccak256("app_get_key"), OptionalUint256(appSet, appEpoch), OptionalUint256(false, 0), "dstack-tdx" + info, + keccak256("app_get_key"), + OptionalUint256(appSet, appEpoch), + OptionalUint256(false, 0), + "dstack-tdx", + bytes32(0) ); } From 77276e28a0e698d1ef63101feaed411daa3d53a6 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 13 Nov 2026 22:39:17 -0800 Subject: [PATCH 2/9] feat(auth-eth): forward launchTicketHash and report the root CA keys Signed-off-by: Kevin Wang --- dstack/kms/auth-eth/README.md | 4 +++- dstack/kms/auth-eth/index.test.ts | 35 +++++++++++++++++++++++++++---- dstack/kms/auth-eth/index.ts | 29 +++++++++++++++++++++++++ dstack/kms/auth-eth/openapi.json | 9 ++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/dstack/kms/auth-eth/README.md b/dstack/kms/auth-eth/README.md index 517f49e47..86e716f36 100644 --- a/dstack/kms/auth-eth/README.md +++ b/dstack/kms/auth-eth/README.md @@ -220,7 +220,9 @@ The selected endpoint determines the policy; the backend checks that the verifie omission uses the current app epoch, explicit zero selects epoch zero, and future values are denied. The optional `requestedRootEpoch` selects historical keys; omission uses the returned current -epoch, and an explicit `0` selects epoch 0. See the +epoch, and an explicit `0` selects epoch 0. The optional `launchTicketHash` is the +hash of the launch ticket the CVM presented and is passed to the contract as +`bytes32`, zero when absent. See the [KMS authorization protocol](../README.md#authorization-protocol) for registration, bootstrap, and compatibility requirements. The legacy `/bootAuth/*` endpoints remain available. diff --git a/dstack/kms/auth-eth/index.test.ts b/dstack/kms/auth-eth/index.test.ts index 04b4b825f..c61a40c94 100644 --- a/dstack/kms/auth-eth/index.test.ts +++ b/dstack/kms/auth-eth/index.test.ts @@ -255,6 +255,7 @@ describe('API Compatibility Tests', () => { case 'appImplementation': return '0x9876543210987654321098765432109876543210'; case 'rootEpochCount': return rootEpochCount(); case 'currentRootEpoch': return currentRootEpoch(); + case 'rootEpoch': return ['0x30' + params.args[0].toString(16).padStart(2, '0'), '0x02', '', hash]; } }); const boot = async (path: string, fields: object) => { @@ -280,7 +281,8 @@ describe('API Compatibility Tests', () => { expect((await boot('kms', {})).reason).toMatch(/reports none/); const info = await appFetch(new Request('http://localhost:3001/')); - expect(await info.json()).toMatchObject({ rootEpoch: 1, rootEpochHash: hash }); + // The CA key of the current epoch, which signs every serving KMS's RPC certificate. + expect(await info.json()).toMatchObject({ rootEpoch: 1, rootEpochHash: hash, rootCaPubkey: '0x3001' }); }); it('supports an upgraded empty registry without querying the current epoch', async () => { @@ -293,7 +295,9 @@ describe('API Compatibility Tests', () => { expect((await boot('app', { rootEpoch: 1, rootEpochHash: hash })).reason).toMatch(/not registered/); const info = await appFetch(new Request('http://localhost:3001/')); expect(info.status).toBe(200); - expect(await info.json()).not.toHaveProperty('rootEpoch'); + const body = await info.json(); + expect(body).not.toHaveProperty('rootEpoch'); + expect(body).not.toHaveProperty('rootCaPubkey'); expect(current).not.toHaveBeenCalled(); }); @@ -312,8 +316,10 @@ describe('API Compatibility Tests', () => { expect(response.status).toBe(200); } const calls = mockReadContract.mock.calls.map(([params]) => params) - .filter(({ functionName }) => ['rootEpochCount', 'currentRootEpoch'].includes(functionName)); - expect(calls.map(({ functionName }) => functionName)).toEqual(['rootEpochCount', 'currentRootEpoch']); + .filter(({ functionName }) => ['rootEpochCount', 'currentRootEpoch', 'rootEpoch'].includes(functionName)); + expect(calls.map(({ functionName }) => functionName)).toEqual(endpoint === 'app' + ? ['rootEpochCount', 'currentRootEpoch'] + : ['rootEpochCount', 'currentRootEpoch', 'rootEpoch']); expect(calls.every(({ blockNumber }) => blockNumber === 98n)).toBe(true); expect(mockGetBlock).toHaveBeenCalledTimes(1); }); @@ -905,6 +911,26 @@ describe('POST /authorizeApp', () => { })); }); + it('forwards the launch ticket hash, zero when absent', async () => { + const result = { + authorized: true, rejectionReason: '', gatewayAppId: 'gateway', + currentAppEpoch: 0n, currentRootEpoch: 1n, currentRootEpochHash: '0x' + '77'.repeat(32), + latestRootEpoch: 1n, latestRootEpochHash: '0x' + '77'.repeat(32), + }; + mockReadContract.mockResolvedValue(result); + const ticket = '0x' + '99'.repeat(32); + for (const [launchTicketHash, expected] of [[ticket, ticket], [undefined, '0x' + '00'.repeat(32)]]) { + const response = await appFetch(new Request('http://localhost/authorizeApp', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...appInfo, operation: 'app_get_key', launchTicketHash }), + })); + expect(response.status).toBe(200); + expect(mockReadContract).toHaveBeenLastCalledWith(expect.objectContaining({ + args: [expect.objectContaining({ launchTicketHash: expected })], + })); + } + }); + it('encodes an explicit epoch zero as present', async () => { mockReadContract.mockResolvedValueOnce({ authorized: true, rejectionReason: '', gatewayAppId: 'gateway', @@ -956,6 +982,7 @@ describe('POST /authorizeApp', () => { { status: 400, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', mrAggregated: 'not-hex' }) }, { status: 400, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', teeVariant: undefined }) }, { status: 400, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', teeVariant: '' }) }, + { status: 400, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', launchTicketHash: '0x' + '99'.repeat(33) }) }, { status: 400, body: '{"appId":' }, { status: 413, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', padding: 'a'.repeat(256 * 1024) }) }, ]; diff --git a/dstack/kms/auth-eth/index.ts b/dstack/kms/auth-eth/index.ts index 164a03ec0..1a0a654c5 100644 --- a/dstack/kms/auth-eth/index.ts +++ b/dstack/kms/auth-eth/index.ts @@ -42,6 +42,8 @@ const AuthorizationRequestSchema = BootInfoSchema.omit({ rootEpoch: true, rootEp operation: z.enum(['kms_bootstrap', 'kms_serve', 'kms_export_keys', 'kms_import_keys', 'app_get_key', 'app_get_cert']), requestedAppEpoch: z.number().int().min(0).max(0xffffffff).optional(), requestedRootEpoch: z.number().int().min(0).max(0xffffffff).optional(), + // The hash of the launch ticket the attested subject presented; absent when none. + launchTicketHash: boundedHex(32, 'launch ticket hash').optional(), }); type AuthorizationRequest = z.infer; @@ -105,6 +107,7 @@ const AUTHORIZATION_ABI = { { name: 'isSet', type: 'bool' }, { name: 'value', type: 'uint256' }, ] }, { name: 'teeVariant', type: 'string' }, + { name: 'launchTicketHash', type: 'bytes32' }, ] }], outputs: [{ name: 'result', @@ -189,6 +192,18 @@ const DSTACK_KMS_ABI = [ { name: 'epoch', type: 'uint256' }, { name: 'chainHash', type: 'bytes32' } ] + }, + { + name: 'rootEpoch', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'epoch', type: 'uint256' }], + outputs: [ + { name: 'caPubkey', type: 'bytes' }, + { name: 'k256Pubkey', type: 'bytes' }, + { name: 'evidenceUrl', type: 'string' }, + { name: 'chainHash', type: 'bytes32' } + ] } ] as const; @@ -291,6 +306,7 @@ export class EthereumBackend { requestedAppEpoch: { isSet: appInfo.requestedAppEpoch !== undefined, value: BigInt(appInfo.requestedAppEpoch ?? 0) }, requestedRootEpoch: { isSet: appInfo.requestedRootEpoch !== undefined, value: BigInt(appInfo.requestedRootEpoch ?? 0) }, teeVariant: appInfo.teeVariant, + launchTicketHash: this.decodeHex(appInfo.launchTicketHash ?? '', 32), }], blockNumber, }); @@ -364,6 +380,17 @@ export class EthereumBackend { return { rootEpoch: Number(epoch), rootEpochHash: chainHash }; } + // The root CA public key (P-256 SPKI DER) of the current root epoch, which + // signs the RPC certificate of every KMS serving it. + async getRootCaPubkey(blockNumber: bigint, current: RootEpochInfo): Promise { + if (current.rootEpoch === undefined) return undefined; + const [caPubkey] = await this.client.readContract({ + address: this.kmsContractAddr, abi: DSTACK_KMS_ABI, functionName: 'rootEpoch', + args: [BigInt(current.rootEpoch)], blockNumber, + }); + return caPubkey; + } + async getAppEpoch(appId: string): Promise { const epoch = Number(await this.client.readContract({ address: this.kmsContractAddr, abi: DSTACK_KMS_ABI, functionName: 'currentAppEpoch', @@ -468,6 +495,7 @@ app.get('/', async (c) => { ethereum.getAppImplementation(block), ethereum.getRootEpoch(block), ]); + const rootCaPubkey = await ethereum.getRootCaPubkey(block, batch[3]); return c.json({ status: 'ok', @@ -478,6 +506,7 @@ app.get('/', async (c) => { appAuthImplementation: batch[2], // NOTE: for backward compatibility appImplementation: batch[2], ...batch[3], + ...(rootCaPubkey && { rootCaPubkey }), }); } catch { console.error('authorization backend health check failed'); diff --git a/dstack/kms/auth-eth/openapi.json b/dstack/kms/auth-eth/openapi.json index b1cb2b5c2..e54a938ff 100644 --- a/dstack/kms/auth-eth/openapi.json +++ b/dstack/kms/auth-eth/openapi.json @@ -334,6 +334,10 @@ "rootEpochHash": { "type": "string", "description": "Chain hash committing to the root public keys of epochs 0..=rootEpoch (hex)" + }, + "rootCaPubkey": { + "type": "string", + "description": "Root CA public key (P-256 SPKI DER, hex) of the current root epoch, which signs the RPC certificate of every KMS serving it; absent when none is registered." } } }, @@ -484,6 +488,11 @@ "maxLength": 64, "description": "TEE platform of the attested identity, as the KMS names it, e.g. dstack-tdx; passed to the contract unchanged." }, + "launchTicketHash": { + "type": "string", + "pattern": "^(0x)?[0-9a-fA-F]{0,64}$", + "description": "Hash of the launch ticket the attested subject presented; omit when none. Passed to the contract left-padded to bytes32." + }, "operation": { "type": "string", "enum": [ From e57ce24c614e0d0d0aa33f0835aa5bdaf621cb7c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 13 Nov 2026 22:39:17 -0800 Subject: [PATCH 3/9] feat(dstack-auth): check launch tickets and report the root CA keys Signed-off-by: Kevin Wang --- dstack/crates/dstack-auth/src/main.rs | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/dstack/crates/dstack-auth/src/main.rs b/dstack/crates/dstack-auth/src/main.rs index 06efdb8bb..b53eaec74 100644 --- a/dstack/crates/dstack-auth/src/main.rs +++ b/dstack/crates/dstack-auth/src/main.rs @@ -75,6 +75,9 @@ struct BootInfo { /// KMS that predates root epochs, and from KMS checks made while onboarding. root_epoch: Option, root_epoch_hash: Option, + /// hash of the launch ticket the attested subject presented; empty when it + /// presented none, and from the legacy routes. + launch_ticket_hash: String, } #[derive(Serialize)] @@ -201,6 +204,10 @@ struct KmsRules { mr_aggregated: Vec, devices: Vec, allow_any_device: bool, + /// when set, a KMS importing the root keys must present one of these + /// launch ticket hashes; an empty list denies every import. Absent: the + /// import needs no ticket. + launch_tickets: Option>, } #[derive(Deserialize, Default)] @@ -210,6 +217,9 @@ struct AppRules { compose_hashes: Vec, devices: Vec, allow_any_device: bool, + /// when set, the app must present one of these launch ticket hashes; an + /// empty list denies everything. Absent: no ticket is needed. + launch_tickets: Option>, } /// normalize a hex string for comparison: trim, drop a `0x`/`0X` prefix, @@ -230,6 +240,13 @@ fn contains(list: &[String], value: &str) -> bool { list.iter().any(|x| norm(x) == v) } +/// No allowlist admits any ticket; an empty one admits none. +fn launch_ticket_ok(tickets: &Option>, ticket_hash: &str) -> bool { + tickets + .as_ref() + .is_none_or(|tickets| !ticket_hash.is_empty() && contains(tickets, ticket_hash)) +} + /// An empty `devices` list allows no device; any device needs `allowAnyDevice`. fn device_ok(allow_any: bool, devices: &[String], device_id: &str) -> bool { allow_any || contains(devices, device_id) @@ -295,6 +312,12 @@ fn tcb_denial(info: &BootInfo, al: &Allowlist) -> Option<&'static str> { } fn app_policy(info: &BootInfo, al: &Allowlist) -> BootResponse { + if al + .app(&info.app_id) + .is_some_and(|app| !launch_ticket_ok(&app.launch_tickets, &info.launch_ticket_hash)) + { + return deny(al, "invalid launch ticket"); + } if let Some(reason) = tcb_denial(info, al) { return deny(al, reason); } @@ -353,6 +376,10 @@ fn check_app(info: &BootInfo, al: &Allowlist) -> BootResponse { } fn check_kms(info: &BootInfo, al: &Allowlist) -> BootResponse { + // legacy callers present no ticket and cannot say which operation they authorize + if al.kms.launch_tickets.is_some() { + return deny(al, "invalid launch ticket"); + } let result = kms_policy(info, al); if result.is_allowed { allow(info, al) @@ -395,6 +422,11 @@ fn authorize( ); result.rejection_reason = if request.operation.is_kms() != is_kms { "invalid authorization operation".into() + } else if request.operation == Operation::KmsImportKeys + && !launch_ticket_ok(&al.kms.launch_tickets, &info.launch_ticket_hash) + { + // only the import hands a joining KMS the root keys + "invalid launch ticket".into() } else if current.is_none() && request.operation != Operation::KmsBootstrap { "root epoch not registered".into() } else if request @@ -506,6 +538,8 @@ fn load(path: &PathBuf) -> Allowlist { #[post("/bootAuth/app", data = "")] fn boot_app(info: Json, cli: &State) -> Json { + let mut info = info.into_inner(); + info.launch_ticket_hash.clear(); let r = check_app(&info, &load(&cli.config)); rocket::info!( "bootAuth/app app={} compose={} os={} device={} tcb={} -> allowed={} ({})", @@ -555,6 +589,10 @@ fn info_json(al: &Allowlist) -> Result { "appImplementation": al.app_implementation, }); if let Some(epoch) = al.current_root_epoch()? { + // the root CA key of the current epoch signs every serving KMS's RPC + // certificate + info["rootCaPubkey"] = + format!("0x{}", norm(&al.root_epochs[epoch.number].ca_pubkey)).into(); info["rootEpoch"] = epoch.number.into(); info["rootEpochHash"] = epoch.hash.into(); } @@ -1185,6 +1223,74 @@ mod tests { assert_eq!(serve.rejection_reason, "requested root epoch is not active"); } + #[test] + fn launch_tickets_are_required_only_when_configured() { + let mut al: Allowlist = serde_json::from_value(json!({ + "kmsContractAddr": "0x1111111111111111111111111111111111111111", + "rootEpochs": [{"caPubkey": "aa", "k256Pubkey": "bb"}, {"caPubkey": "cc", "k256Pubkey": "dd"}], + "osImages": ["aa"], + "kms": {"mrAggregated": ["bb"], "allowAnyDevice": true, "launchTickets": ["0xEE"]}, + "apps": {"0x2222222222222222222222222222222222222222": { + "composeHashes": ["cc"], "allowAnyDevice": true, "launchTickets": ["0xFF"] + }} + })) + .unwrap(); + let ask = |al: &Allowlist, is_kms: bool, operation: &str, ticket: Option<&str>| { + let mut request = if is_kms { + json!({"operation": operation, "appId": "11".repeat(20), "mrAggregated": "bb"}) + } else { + json!({"operation": operation, "appId": "22".repeat(20), "composeHash": "cc", "osImageHash": "aa"}) + }; + if let Some(ticket) = ticket { + request["launchTicketHash"] = ticket.into(); + } + let request: AuthorizationRequest = serde_json::from_value(request).unwrap(); + authorize(&request, is_kms, al).unwrap() + }; + for operation in ["app_get_key", "app_get_cert"] { + assert!(ask(&al, false, operation, Some("ff")).authorized); + assert_eq!( + ask(&al, false, operation, Some("ee")).rejection_reason, + "invalid launch ticket" + ); + assert!(!ask(&al, false, operation, None).authorized); + } + assert!(ask(&al, true, "kms_import_keys", Some("0xee")).authorized); + assert_eq!( + ask(&al, true, "kms_import_keys", None).rejection_reason, + "invalid launch ticket" + ); + // Only the import hands out the root keys. + assert!(ask(&al, true, "kms_serve", None).authorized); + // Legacy callers cannot present a ticket. + let legacy = boot("0x2222222222222222222222222222222222222222", "cc", "aa"); + assert!(!check_app(&legacy, &al).is_allowed); + assert!( + !check_kms( + &BootInfo { + mr_aggregated: "bb".into(), + ..Default::default() + }, + &al + ) + .is_allowed + ); + + // An empty allowlist admits no ticket; none configured admits any. + al.kms.launch_tickets = Some(vec![]); + assert!(!ask(&al, true, "kms_import_keys", Some("ee")).authorized); + al.kms.launch_tickets = None; + assert!(ask(&al, true, "kms_import_keys", None).authorized); + + assert_eq!(info_json(&al).unwrap()["rootCaPubkey"], "0xaa"); + al.current_root_epoch = 1; + assert_eq!(info_json(&al).unwrap()["rootCaPubkey"], "0xcc"); + assert!(info_json(&Allowlist::default()) + .unwrap() + .get("rootCaPubkey") + .is_none()); + } + #[test] fn allows_only_a_kms_serving_the_current_root_epoch() { // Shared with the DstackKms contract tests. From 6d5d49a463b7ef138d8ab246ddcd6b3f7c013670 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 13 Nov 2026 22:47:36 -0800 Subject: [PATCH 4/9] feat(kms): send the launch ticket hash for app keys, certificates and key imports Signed-off-by: Kevin Wang --- docs/deployment.md | 2 + dstack/cert-client/src/lib.rs | 34 ++++++--- dstack/dstack-types/src/authorization.rs | 38 ++++++++++ dstack/dstack-types/src/lib.rs | 6 ++ dstack/dstack-util/src/main.rs | 1 + dstack/dstack-util/src/system_setup.rs | 11 ++- dstack/guest-agent/src/rpc_service.rs | 5 +- dstack/kms/README.md | 4 ++ dstack/kms/rpc/proto/kms_rpc.proto | 11 +++ dstack/kms/src/main_service.rs | 39 ++++++++-- .../kms/src/main_service/upgrade_authority.rs | 71 ++++++++++++++++--- dstack/kms/src/onboard_service.rs | 3 + dstack/kms/src/www/onboard.html | 8 +++ 13 files changed, 197 insertions(+), 36 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 9095496dd..53f6c7934 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -561,6 +561,8 @@ curl http://:9203/finish > **Admin authentication.** The KMS *admin* RPCs (for example `ClearImageCache`) are served on a dedicated `[core.admin]` listener behind the shared HTTP authenticator, just like the VMM and gateway: set `[core.admin] enabled = true` with an `auth_token` (or the `DSTACK_KMS_ADMIN_TOKEN` / `ADMIN_API_TOKEN` env vars), and clients send `Authorization: Bearer ` or `X-Admin-Token`. Enabled with neither `auth_token` nor `htpasswd_file` (and `insecure_no_auth = false`) fails closed — the KMS refuses to start. See [dstack-kms admin authentication](../dstack/kms/README.md#admin-api-authentication). > +> **Launch ticket.** When the KMS contract requires one (`setRequireKmsLaunchTicket`), pass this instance's ticket as `launch_ticket` in the onboarding request; the source checks its hash against `kmsLaunchTickets()` before handing over the keys. Remove the ticket from the contract once the instance has joined. See [launch tickets](../dstack/kms/README.md#launch-tickets). +> > **Key handover.** It is served as `Admin.GetKmsKey` on the `[core.admin]` listener, which needs `[core.admin.tls]` so it can verify the onboarding KMS's RA-TLS certificate. Pass the source admin URL (`https://`) and its token (`source_token`) in the onboarding request; the token is required unless the source enables the public handover. The legacy public `KMS.GetKmsKey` is off by default; set `core.onboard.public_key_handover = true` on the source only when an onboarding KMS cannot use the admin listener, such as a 0.5.x KMS or an onboarding request without `source_token`. #### Rotating KMS Root Keys diff --git a/dstack/cert-client/src/lib.rs b/dstack/cert-client/src/lib.rs index b96968fec..d5055016d 100644 --- a/dstack/cert-client/src/lib.rs +++ b/dstack/cert-client/src/lib.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use anyhow::{bail, Context, Result}; use dstack_kms_rpc::{kms_client::KmsClient, AppKeyResponse, GetAppKeyRequest, SignCertRequest}; -use dstack_types::{AppKeys, KeyProvider}; +use dstack_types::{AppKeys, KeyProvider, SysConfig}; use ra_rpc::client::{CertInfo, RaClient, RaClientConfig}; use ra_tls::{ attestation::{AppInfo, AttestationVerifier, VersionedAttestation}, @@ -20,6 +20,7 @@ pub enum CertRequestClient { Kms { client: KmsClient, vm_config: String, + launch_ticket: String, }, } @@ -158,13 +159,18 @@ impl CertRequestClient { .context("Failed to sign certificate")?; Ok(vec![cert.pem(), ca.pem_cert.clone()]) } - CertRequestClient::Kms { client, vm_config } => { + CertRequestClient::Kms { + client, + vm_config, + launch_ticket, + } => { let response = client .sign_cert(SignCertRequest { api_version: 2, csr: csr.to_vec(), signature: signature.to_vec(), vm_config: vm_config.clone(), + launch_ticket: launch_ticket.clone(), }) .await?; Ok(response.certificate_chain) @@ -178,7 +184,12 @@ impl CertRequestClient { root_epoch: Option, app_epoch: Option, ) -> Result { - let CertRequestClient::Kms { client, vm_config } = self else { + let CertRequestClient::Kms { + client, + vm_config, + launch_ticket, + } = self + else { bail!("only KMS-provided keys have older root epochs"); }; let response = client @@ -187,6 +198,7 @@ impl CertRequestClient { vm_config: vm_config.clone(), root_epoch, app_epoch, + launch_ticket: launch_ticket.clone(), }) .await?; if root_epoch.is_some_and(|epoch| epoch != response.root_epoch) { @@ -208,7 +220,7 @@ impl CertRequestClient { pub async fn create( keys: &AppKeys, attestation_verifier: Arc, - vm_config: String, + sys_config: &SysConfig, ) -> Result { match &keys.key_provider { KeyProvider::None { key } @@ -231,7 +243,11 @@ impl CertRequestClient { client_cert.key_pem, attestation_verifier, )?; - Ok(CertRequestClient::Kms { client, vm_config }) + Ok(CertRequestClient::Kms { + client, + vm_config: sys_config.vm_config.clone(), + launch_ticket: sys_config.launch_ticket.clone().unwrap_or_default(), + }) } } } @@ -643,9 +659,7 @@ mod tests { }; let request = || GetAppKeyRequest { api_version: 1, - vm_config: String::new(), - root_epoch: None, - app_epoch: None, + ..Default::default() }; let result = request_app_keys( url.clone(), @@ -665,9 +679,8 @@ mod tests { for requested_root_epoch in [None, Some(0), Some(1)] { let request = || GetAppKeyRequest { api_version: 1, - vm_config: String::new(), root_epoch: requested_root_epoch, - app_epoch: None, + ..Default::default() }; let selected = AppKeyResponse { root_epoch: requested_root_epoch.unwrap_or(1), @@ -735,6 +748,7 @@ mod tests { ) .unwrap(), vm_config: "selector-test".into(), + launch_ticket: String::new(), }; for root_epoch in [None, Some(0), Some(1)] { for app_epoch in [None, Some(0), Some(2)] { diff --git a/dstack/dstack-types/src/authorization.rs b/dstack/dstack-types/src/authorization.rs index 1fdb48228..3487c1029 100644 --- a/dstack/dstack-types/src/authorization.rs +++ b/dstack/dstack-types/src/authorization.rs @@ -24,4 +24,42 @@ impl AuthorizationOperation { Self::KmsBootstrap | Self::KmsServe | Self::KmsExportKeys | Self::KmsImportKeys ) } + + /// The `launchTicketHash` of `ticket`: `sha256(domain || ticket)`, with a + /// domain per endpoint so that one ticket never admits both an app and a + /// KMS instance. + /// + /// Shell equivalent: `printf 'dstack-launch-ticket/v1:%s' "$TICKET" | sha256sum`. + pub fn launch_ticket_hash(self, ticket: &str) -> [u8; 32] { + let domain = if self.is_kms() { + "dstack-kms-launch-ticket/v1:" + } else { + "dstack-launch-ticket/v1:" + }; + crate::sha256(format!("{domain}{ticket}").as_bytes()) + } +} + +/// Shortest launch ticket a KMS accepts. Its hash is public, so a short ticket +/// can be recovered offline; length alone cannot prove entropy, so generate +/// tickets randomly. +pub const LAUNCH_TICKET_MIN_LEN: usize = 32; +/// Longest launch ticket a KMS accepts; sys-config must stay small. +pub const LAUNCH_TICKET_MAX_LEN: usize = 1024; + +/// Refuse a ticket the KMS would refuse. Surrounding whitespace is refused +/// rather than trimmed: it changes the hash, so a pasted newline would +/// otherwise fail as an unknown ticket. +pub fn check_launch_ticket(ticket: &str) -> Result<(), String> { + if !(LAUNCH_TICKET_MIN_LEN..=LAUNCH_TICKET_MAX_LEN).contains(&ticket.len()) { + return Err(format!( + "launch ticket must be {LAUNCH_TICKET_MIN_LEN} to {LAUNCH_TICKET_MAX_LEN} bytes, got {}", + ticket.len() + )); + } + if ticket.trim() != ticket { + return Err("launch ticket has surrounding whitespace".into()); + } + Ok(()) } + diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 2108fc8fc..70d9f0016 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -1303,6 +1303,12 @@ pub struct SysConfig { /// switches in app-compose, which a token here cannot widen. #[serde(default, skip_serializing_if = "Option::is_none")] pub guest_agent_api: Option, + /// Launch ticket the guest presents to the KMS, which checks its hash + /// against the app's on-chain allowlist before releasing keys. A secret + /// the host holds: unlike `vm_config`, it is never embedded in an + /// attestation or returned by the guest agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_ticket: Option, // JSON serialized VmConfig pub vm_config: String, } diff --git a/dstack/dstack-util/src/main.rs b/dstack/dstack-util/src/main.rs index c8dca2d13..a848da12a 100644 --- a/dstack/dstack-util/src/main.rs +++ b/dstack/dstack-util/src/main.rs @@ -659,6 +659,7 @@ async fn cmd_get_keys(args: GetKeysArgs) -> Result<()> { api_version: 1, vm_config: String::new(), root_epoch: None, + launch_ticket: String::new(), }, cert_pair, std::sync::Arc::new(ra_tls::attestation::AttestationVerifier::new_prod(None)?), diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 1f8bcf4c5..0164f751c 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -726,13 +726,9 @@ impl<'a> GatewayContext<'a> { .unwrap_or(0); let cert_not_after = now + CERT_VALIDITY_SECS; let verifier = attestation_verifier(&self.shared.sys_config)?; - let cert_client = CertRequestClient::create( - self.keys, - verifier, - self.shared.sys_config.vm_config.clone(), - ) - .await - .context("Failed to create cert client")?; + let cert_client = CertRequestClient::create(self.keys, verifier, &self.shared.sys_config) + .await + .context("Failed to create cert client")?; let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("Failed to generate key")?; @@ -2409,6 +2405,7 @@ impl HostShared { vm_config: self.sys_config.vm_config.clone(), root_epoch, app_epoch, + launch_ticket: self.sys_config.launch_ticket.clone().unwrap_or_default(), }, cert_pair, attestation_verifier(&self.sys_config)?, diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 5a3ac3e3a..a10e77479 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -294,7 +294,7 @@ impl AppState { // Same trust anchor decision as dstack-util: never host-supplied, and // development roots only when this guest published them itself. let verifier = Arc::new(default_verifier(&collateral_urls)?); - let cert_client = CertRequestClient::create(&keys, verifier, vm_config.clone()) + let cert_client = CertRequestClient::create(&keys, verifier, &sys_config) .await .context("Failed to create cert signer")?; // Only run the refresh loop when the app asked the gateway to gate on @@ -1503,7 +1503,8 @@ pNs85uhOZE8z2jr8Pg== }; let verifier = Arc::new(AttestationVerifier::new_prod(None).unwrap()); - let dummy_cert_client = CertRequestClient::create(&dummy_keys, verifier, String::new()) + let sys_config = serde_json::from_value(serde_json::json!({ "vm_config": "" })).unwrap(); + let dummy_cert_client = CertRequestClient::create(&dummy_keys, verifier, &sys_config) .await .expect("Failed to create CertRequestClient"); diff --git a/dstack/kms/README.md b/dstack/kms/README.md index f28fd8ee2..9d7378f46 100644 --- a/dstack/kms/README.md +++ b/dstack/kms/README.md @@ -345,6 +345,10 @@ none was presented: | `authorizeApp` | `sha256("dstack-launch-ticket/v1:" \|\| ticket)` | `app_get_key`, `app_get_cert` | | `authorizeKms` | `sha256("dstack-kms-launch-ticket/v1:" \|\| ticket)` | `kms_import_keys` | +A CVM reads its ticket from `launch_ticket` in its sys-config and sends it in +`GetAppKey` and `SignCert`; a joining KMS takes it in the `Onboard` request and +sends it in `GetKmsKey`. The ticket never enters `vm_config`, which attestations +embed. Tickets shorter than 32 bytes are refused, since their hash is public. Only the key import hands a joining KMS the root keys, so the other KMS operations take no ticket. `DstackApp.setRequireLaunchTicket` and `DstackKms.setRequireKmsLaunchTicket` turn the check on; `addLaunchTicket` and diff --git a/dstack/kms/rpc/proto/kms_rpc.proto b/dstack/kms/rpc/proto/kms_rpc.proto index 8085b2892..91e50da66 100644 --- a/dstack/kms/rpc/proto/kms_rpc.proto +++ b/dstack/kms/rpc/proto/kms_rpc.proto @@ -15,6 +15,9 @@ message GetAppKeyRequest { // the original root keys. Older epochs remain available so CVMs can migrate. optional uint32 root_epoch = 3; optional uint32 app_epoch = 4; + // The launch ticket from the CVM's sys-config; empty when it has none. Its + // hash is checked against the app's allowlist. + string launch_ticket = 5; } message AppId { @@ -82,6 +85,9 @@ message GetMetaResponse { message GetKmsKeyRequest { string vm_config = 1; + // The joining instance's launch ticket; empty when it has none. Its hash is + // checked against the KMS allowlist. + string launch_ticket = 2; } message KmsKeys { @@ -107,6 +113,8 @@ message SignCertRequest { bytes csr = 2; bytes signature = 3; string vm_config = 4; + // As in GetAppKeyRequest. + string launch_ticket = 5; } message SignCertResponse { @@ -164,6 +172,9 @@ message OnboardRequest { // Import the source's epochs 0..N-1 and generate epoch N locally. The source // must be at the current epoch N-1. bool rotate = 4; + // This instance's launch ticket, presented to the source; empty when the + // KMS policy requires none. + string launch_ticket = 5; } message OnboardResponse { diff --git a/dstack/kms/src/main_service.rs b/dstack/kms/src/main_service.rs index e8744e70a..89e0963d5 100644 --- a/dstack/kms/src/main_service.rs +++ b/dstack/kms/src/main_service.rs @@ -546,7 +546,9 @@ impl RpcHandler { self.ensure_self_allowed(Operation::KmsExportKeys) .await .context("KMS self authorization failed")?; - let info = self.ensure_kms_allowed(&request.vm_config).await?; + let info = self + .ensure_kms_allowed(&request.vm_config, &request.launch_ticket) + .await?; ensure_key_release_allowed(&info, (&self.state.config).into())?; let returned = format!("root 0..={}", self.state.keyring.latest()); self.audit_key_release(release, &info, "all", &returned, cached); @@ -654,7 +656,7 @@ impl RpcHandler { .state .config .auth_api - .authorize_kms(&boot_info, operation, None) + .authorize_kms(&boot_info, operation, None, "") .await .context("Failed to call self KMS auth check")?; let epoch = self.state.authorized_epoch(&response, None)?; @@ -674,7 +676,7 @@ impl RpcHandler { Ok(attestation) } - async fn ensure_kms_allowed(&self, vm_config: &str) -> Result { + async fn ensure_kms_allowed(&self, vm_config: &str, launch_ticket: &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. @@ -686,6 +688,7 @@ impl RpcHandler { &boot_info, Operation::KmsImportKeys, Some(self.state.keyring.latest()), + launch_ticket, ) .await?; self.state.authorized_epoch(&response, None)?; @@ -697,12 +700,20 @@ impl RpcHandler { async fn ensure_app_boot_allowed( &self, vm_config: &str, + launch_ticket: &str, epochs: RequestedAppEpochs, ) -> Result { let att = self.ensure_attested()?; let boot_info = build_boot_info_for_attestation(att, false, vm_config)?; - self.ensure_app_attestation_allowed(att, boot_info, vm_config, Operation::AppGetKey, epochs) - .await + self.ensure_app_attestation_allowed( + att, + boot_info, + vm_config, + launch_ticket, + Operation::AppGetKey, + epochs, + ) + .await } async fn ensure_app_attestation_allowed( @@ -710,6 +721,7 @@ impl RpcHandler { att: &VerifiedAttestation, boot_info: BootInfo, vm_config: &str, + launch_ticket: &str, operation: Operation, epochs: RequestedAppEpochs, ) -> Result { @@ -717,7 +729,13 @@ impl RpcHandler { .state .config .auth_api - .authorize_app(&boot_info, operation, epochs.root, epochs.app) + .authorize_app( + &boot_info, + operation, + epochs.root, + epochs.app, + launch_ticket, + ) .await?; let root_epoch = self.state.authorized_epoch(&response, epochs.root)?; let app_epoch = epochs.app.unwrap_or(response.current_app_epoch); @@ -797,6 +815,7 @@ impl KmsRpc for RpcHandler { } = self .ensure_app_boot_allowed( &request.vm_config, + &request.launch_ticket, RequestedAppEpochs { root: request.root_epoch, app: request.app_epoch, @@ -1006,6 +1025,7 @@ impl KmsRpc for RpcHandler { &attestation, boot_info, &request.vm_config, + &request.launch_ticket, Operation::AppGetCert, RequestedAppEpochs::default(), ) @@ -2025,7 +2045,10 @@ mod tests { attestation: Some(attestation), remote_endpoint: None, }; - let request = GetKmsKeyRequest { vm_config }; + let request = GetKmsKeyRequest { + vm_config, + ..Default::default() + }; let result = if case.admin { handler .handover_keys(KeyRelease::AdminHandover, request) @@ -2201,6 +2224,7 @@ mod tests { vm_config, root_epoch, app_epoch, + ..Default::default() }) .await; (result, auth_requests(server), state, boot_info) @@ -2352,6 +2376,7 @@ mod tests { csr: csr.to_vec(), vm_config: serde_json::json!({ "os_image_hash": hex::encode(os_image_hash) }) .to_string(), + ..Default::default() }; let result = RpcHandler { state: state.clone(), diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index ac640e820..a8c2220b3 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -44,6 +44,12 @@ pub(crate) fn build_boot_info( }) } +/// The hex `launchTicketHash` of a presented ticket. +fn launch_ticket_hash(operation: Operation, ticket: &str) -> Result { + dstack_types::authorization::check_launch_ticket(ticket).map_err(anyhow::Error::msg)?; + Ok(hex::encode(operation.launch_ticket_hash(ticket))) +} + pub(crate) fn ensure_app_id_len(app_id: &[u8]) -> Result<()> { if app_id.len() != 20 { bail!("app_id must be 20 bytes"); @@ -279,6 +285,7 @@ impl AuthApi { app_info: &BootInfo, operation: Operation, requested_root_epoch: Option, + launch_ticket: &str, ) -> Result { if !operation.is_kms() { bail!("invalid KMS authorization operation"); @@ -289,6 +296,7 @@ impl AuthApi { operation, requested_root_epoch, None, + launch_ticket, ) .await } @@ -299,6 +307,7 @@ impl AuthApi { operation: Operation, requested_root_epoch: Option, requested_app_epoch: Option, + launch_ticket: &str, ) -> Result { if operation.is_kms() { bail!("invalid app authorization operation"); @@ -309,6 +318,7 @@ impl AuthApi { operation, requested_root_epoch, requested_app_epoch, + launch_ticket, ) .await } @@ -320,6 +330,7 @@ impl AuthApi { operation: Operation, requested_root_epoch: Option, requested_app_epoch: Option, + launch_ticket: &str, ) -> Result { #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -331,8 +342,14 @@ impl AuthApi { requested_app_epoch: Option, #[serde(skip_serializing_if = "Option::is_none")] requested_root_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + launch_ticket_hash: Option, } ensure_identity_widths(app_info)?; + // An empty ticket is none. + let launch_ticket_hash = (!launch_ticket.is_empty()) + .then(|| launch_ticket_hash(operation, launch_ticket)) + .transpose()?; match self { AuthApi::Dev { dev } => Ok(AuthorizationResult { authorized: true, @@ -353,6 +370,7 @@ impl AuthApi { operation, requested_app_epoch, requested_root_epoch, + launch_ticket_hash, }, webhook.timeout, ) @@ -486,7 +504,7 @@ pub(crate) async fn ensure_self_kms_allowed( .context("failed to build local KMS boot info")?; let response = cfg .auth_api - .authorize_kms(&boot_info, Operation::KmsBootstrap, None) + .authorize_kms(&boot_info, Operation::KmsBootstrap, None, "") .await .context("failed to call KMS bootstrap authorization")?; if !response.authorized { @@ -515,7 +533,7 @@ pub(crate) async fn ensure_kms_allowed( ensure_source_os_image(&boot_info)?; let response = cfg .auth_api - .authorize_kms(&boot_info, Operation::KmsExportKeys, Some(root_epoch)) + .authorize_kms(&boot_info, Operation::KmsExportKeys, Some(root_epoch), "") .await .context("failed to authorize source KMS key export")?; if !response.authorized { @@ -546,6 +564,7 @@ pub(crate) mod tests { use crate::config::Webhook; use ra_tls::attestation::TeeVariant; use serde_json::Value; + use sha2::{Digest, Sha256}; use std::io::{Read, Write}; use std::net::TcpListener; use std::thread; @@ -808,11 +827,11 @@ pub(crate) mod tests { let auth = webhook(url); for operation in operations { if operation.is_kms() { - auth.authorize_kms(&boot_info(1), operation, Some(0)) + auth.authorize_kms(&boot_info(1), operation, Some(0), "") .await .unwrap(); } else { - auth.authorize_app(&boot_info(1), operation, None, None) + auth.authorize_app(&boot_info(1), operation, None, None, "") .await .unwrap(); } @@ -849,6 +868,38 @@ pub(crate) mod tests { assert!(serde_json::from_str::(r#""unknown""#).is_err()); } + #[rocket::async_test] + async fn launch_tickets_are_sent_as_domain_separated_hashes() { + let response = r#"{"authorized":true,"rejectionReason":"","gatewayAppId":"","currentRootEpoch":0,"currentAppEpoch":0,"currentRootEpochHash":"0000000000000000000000000000000000000000000000000000000000000000","latestRootEpoch":0,"latestRootEpochHash":"0000000000000000000000000000000000000000000000000000000000000000"}"#; + let (url, server) = serve(vec![response; 3]); + let auth = webhook(url); + let ticket = "t".repeat(32); + let info = boot_info(1); + auth.authorize_app(&info, Operation::AppGetKey, None, None, &ticket) + .await + .unwrap(); + auth.authorize_kms(&info, Operation::KmsImportKeys, Some(0), &ticket) + .await + .unwrap(); + auth.authorize_app(&info, Operation::AppGetCert, None, None, "") + .await + .unwrap(); + // A short ticket is refused before it reaches the backend. + let err = auth + .authorize_app(&info, Operation::AppGetKey, None, None, "short") + .await + .unwrap_err(); + assert!(err.to_string().contains("32 to 1024 bytes"), "{err:#}"); + + let requests = server.join().unwrap(); + let sent = |i: usize| requests[i].1.get("launchTicketHash").cloned(); + let expected = + |domain: &str| Some(hex::encode(Sha256::digest(format!("{domain}{ticket}"))).into()); + assert_eq!(sent(0), expected("dstack-launch-ticket/v1:")); + assert_eq!(sent(1), expected("dstack-kms-launch-ticket/v1:")); + assert_eq!(sent(2), None); + } + #[rocket::async_test] async fn authorization_returns_the_epoch_with_each_decision() { let response = |allowed: bool, epoch: u32| { @@ -869,11 +920,11 @@ pub(crate) mod tests { let auth = webhook(url); let info = boot_info(50); let first = auth - .authorize_app(&info, Operation::AppGetKey, None, None) + .authorize_app(&info, Operation::AppGetKey, None, None, "") .await .unwrap(); let second = auth - .authorize_app(&info, Operation::AppGetKey, None, None) + .authorize_app(&info, Operation::AppGetKey, None, None, "") .await .unwrap(); assert!(first.authorized); @@ -882,15 +933,15 @@ pub(crate) mod tests { assert_eq!(second.current_root().unwrap().unwrap().epoch, 1); let latest = second.latest_root().unwrap().unwrap(); assert_eq!((latest.epoch, latest.chain_hash), (2, [0x22; 32])); - auth.authorize_app(&info, Operation::AppGetKey, Some(0), Some(0)) + auth.authorize_app(&info, Operation::AppGetKey, Some(0), Some(0), "") .await .unwrap(); - auth.authorize_kms(&info, Operation::KmsServe, None) + auth.authorize_kms(&info, Operation::KmsServe, None, "") .await .unwrap(); assert!( !auth - .authorize_kms(&info, Operation::KmsImportKeys, Some(2)) + .authorize_kms(&info, Operation::KmsImportKeys, Some(2), "") .await .unwrap() .authorized @@ -938,7 +989,7 @@ pub(crate) mod tests { r#"{"isAllowed":true,"reason":"","gatewayAppId":"gw"}"#, ]); assert!(webhook(url) - .authorize_app(&boot_info(50), Operation::AppGetKey, None, None) + .authorize_app(&boot_info(50), Operation::AppGetKey, None, None, "") .await .is_err()); assert_eq!(server.join().unwrap().len(), 1); diff --git a/dstack/kms/src/onboard_service.rs b/dstack/kms/src/onboard_service.rs index 281cbb2bb..1cba9e816 100644 --- a/dstack/kms/src/onboard_service.rs +++ b/dstack/kms/src/onboard_service.rs @@ -171,6 +171,7 @@ impl OnboardRpc for OnboardHandler { cfg, &source_url, &request.source_token, + &request.launch_ticket, &request.domain, request.rotate, self.state.attestation_verifier.clone(), @@ -1068,6 +1069,7 @@ impl Keys { cfg: &KmsConfig, other_kms_url: &str, source_token: &str, + launch_ticket: &str, domain: &str, rotate: bool, attestation_verifier: Arc, @@ -1117,6 +1119,7 @@ impl Keys { let info = dstack_client().info().await.context("Failed to get info")?; let request = GetKmsKeyRequest { vm_config: info.vm_config, + launch_ticket: launch_ticket.to_string(), }; let keys_res = if source_token.is_empty() { KmsClient::new(client).get_kms_key(request).await? diff --git a/dstack/kms/src/www/onboard.html b/dstack/kms/src/www/onboard.html index a5d428d0b..5fe3029cc 100644 --- a/dstack/kms/src/www/onboard.html +++ b/dstack/kms/src/www/onboard.html @@ -262,6 +262,12 @@

Onboard from an Existing KMS Instance

autocomplete="off" placeholder="Without it, the source needs core.onboard.public_key_handover = true"> +
+ + +
+
+ + +
+
diff --git a/dstack/vmm/ui/src/components/UpdateVmDialog.ts b/dstack/vmm/ui/src/components/UpdateVmDialog.ts index 6a2db502c..b54df0751 100644 --- a/dstack/vmm/ui/src/components/UpdateVmDialog.ts +++ b/dstack/vmm/ui/src/components/UpdateVmDialog.ts @@ -259,6 +259,11 @@ const UpdateVmDialogComponent = {
+
+ + +
+