diff --git a/.github/workflows/foundry-test.yml b/.github/workflows/foundry-test.yml index 674e61081..3b74377a5 100644 --- a/.github/workflows/foundry-test.yml +++ b/.github/workflows/foundry-test.yml @@ -57,7 +57,11 @@ jobs: id: build - name: Install OpenZeppelin upgrades validator - run: npm install --no-save --package-lock=false @openzeppelin/upgrades-core@^1.37.0 + # The upgrade-safety tests run `npx @openzeppelin/upgrades-core@^1.37.0`; + # warm the npx cache with that spec once so parallel tests reuse it. + # `npm install` here would also resolve the webhook server's + # package.json, which is managed by Bun. + run: npx --yes @openzeppelin/upgrades-core@^1.37.0 --help > /dev/null - name: Run Forge tests # The upgrade-safety validator rejects a contract found in more than one diff --git a/.github/workflows/halmos.yml b/.github/workflows/halmos.yml new file mode 100644 index 000000000..40f8bf6ad --- /dev/null +++ b/.github/workflows/halmos.yml @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: © 2025 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +name: KMS Auth-ETH Halmos + +on: + push: + paths: + - 'dstack/kms/auth-eth/**' + - '.github/workflows/halmos.yml' + pull_request: + paths: + - 'dstack/kms/auth-eth/**' + - '.github/workflows/halmos.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + halmos: + name: ${{ matrix.contract }} + runs-on: ubuntu-latest + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + contract: + - DstackAppSymbolicTest + - DstackAppEpochSymbolicTest + - DstackKmsSymbolicTest + - DstackKmsEpochSymbolicTest + defaults: + run: + working-directory: dstack/kms/auth-eth + env: + # Halmos does not support the vm.deployCode that dynamic test linking emits. + FOUNDRY_DYNAMIC_TEST_LINKING: 'false' + steps: + - uses: actions/checkout@v5 + + - name: Checkout contract dependencies + working-directory: . + run: | + git submodule update --init --recursive --depth 1 -- \ + dstack/kms/auth-eth/lib/forge-std \ + dstack/kms/auth-eth/lib/openzeppelin-contracts-upgradeable \ + dstack/kms/auth-eth/lib/openzeppelin-foundry-upgrades + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.8.4 + + - name: Install Halmos + run: pipx install halmos==0.3.3 + + - name: Run Halmos + run: halmos --contract ${{ matrix.contract }} diff --git a/.github/workflows/kms-auth-eth-bun.yml b/.github/workflows/kms-auth-eth.yml similarity index 85% rename from .github/workflows/kms-auth-eth-bun.yml rename to .github/workflows/kms-auth-eth.yml index 687dd90d6..4eb49c251 100644 --- a/.github/workflows/kms-auth-eth-bun.yml +++ b/.github/workflows/kms-auth-eth.yml @@ -2,39 +2,41 @@ # # SPDX-License-Identifier: Apache-2.0 -name: KMS auth-eth-bun +name: KMS auth-eth -# The auth-eth-bun webhook ships on its own cadence, separate from the KMS -# image: a `kms-auth-eth-bun-v*` tag publishes -# ghcr.io/dstack-tee/dstack-kms-auth-eth-bun:. Pull requests and +# The auth-eth webhook ships on its own cadence, separate from the KMS +# image: a `kms-auth-eth-v*` tag publishes +# ghcr.io/dstack-tee/dstack-kms-auth-eth:. Pull requests and # pushes that touch the backend run the checks and build the image without # pushing it. on: workflow_dispatch: inputs: version: - description: Existing release tag version without the kms-auth-eth-bun-v prefix + description: Existing release tag version without the kms-auth-eth-v prefix required: true type: string push: branches: [ next, 'release/**' ] tags: - - 'kms-auth-eth-bun-v*' + - 'kms-auth-eth-v*' + # Top-level files only: the server lives next to the Foundry project, whose + # contracts/, lib/, test/ and script/ are covered by foundry-test.yml. paths: - - 'dstack/kms/auth-eth-bun/**' - - '.github/workflows/kms-auth-eth-bun.yml' + - 'dstack/kms/auth-eth/*' + - '.github/workflows/kms-auth-eth.yml' pull_request: branches: [ next, 'release/**', 'feat/**' ] paths: - - 'dstack/kms/auth-eth-bun/**' - - '.github/workflows/kms-auth-eth-bun.yml' + - 'dstack/kms/auth-eth/*' + - '.github/workflows/kms-auth-eth.yml' permissions: contents: read env: - IMAGE: ghcr.io/dstack-tee/dstack-kms-auth-eth-bun - BACKEND_DIR: dstack/kms/auth-eth-bun + IMAGE: ghcr.io/dstack-tee/dstack-kms-auth-eth + BACKEND_DIR: dstack/kms/auth-eth jobs: check: @@ -43,7 +45,7 @@ jobs: - name: Checkout repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('kms-auth-eth-bun-v{0}', inputs.version) || github.sha }} + ref: ${{ github.event_name == 'workflow_dispatch' && format('kms-auth-eth-v{0}', inputs.version) || github.sha }} persist-credentials: false - name: Set up Bun @@ -88,7 +90,7 @@ jobs: VERSION=$DISPATCH_VERSION else case "$GITHUB_REF" in - refs/tags/kms-auth-eth-bun-v*) VERSION=${GITHUB_REF#refs/tags/kms-auth-eth-bun-v} ;; + refs/tags/kms-auth-eth-v*) VERSION=${GITHUB_REF#refs/tags/kms-auth-eth-v} ;; esac fi if [ -n "$VERSION" ]; then @@ -107,7 +109,7 @@ jobs: - name: Checkout repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('kms-auth-eth-bun-v{0}', inputs.version) || github.sha }} + ref: ${{ github.event_name == 'workflow_dispatch' && format('kms-auth-eth-v{0}', inputs.version) || github.sha }} persist-credentials: false - name: Resolve the revision to build @@ -151,12 +153,12 @@ jobs: IMAGE_DIGEST: ${{ steps.build.outputs.digest }} run: | token=$(curl -fsSL \ - "https://ghcr.io/token?scope=repository:dstack-tee/dstack-kms-auth-eth-bun:pull" \ + "https://ghcr.io/token?scope=repository:dstack-tee/dstack-kms-auth-eth:pull" \ | jq -er .token) curl -fsSL -o /dev/null \ -H "Authorization: Bearer $token" \ -H "Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" \ - "https://ghcr.io/v2/dstack-tee/dstack-kms-auth-eth-bun/manifests/$IMAGE_DIGEST" + "https://ghcr.io/v2/dstack-tee/dstack-kms-auth-eth/manifests/$IMAGE_DIGEST" - name: Generate artifact attestation if: env.PUBLISH == '1' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8319bdc58..2e4b38e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,28 +7,150 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Upgrade notes + +- **Deployment order for key epochs:** upgrade the `DstackKms` and `DstackApp` + proxies and the auth backend, register the existing roots as root epoch 0 + (`registerRootEpoch`), then roll out the KMS nodes, then guests. Keep root and + app epochs at 0 until every KMS and guest is upgraded. See + `dstack/kms/README.md` and `docs/deployment.md`. +- **Auth backends must serve `GET /app/:appId`**, which reports an app's current + app epoch. The KMS reads it for `GetAppEnvEncryptPubKey` and + `GetSealedEnvPubKey`. `auth-eth` and `dstack-auth` serve it; custom webhook + backends must add it. + +### Added + +- KMS root and application key epochs, epoch-scoped authorization, SealedEnv + HPKE encryption, automatic environment migration, and epoch-aware SDK key + selection. +- dstack-auth: opt-in `allowedTcbStatuses` and `allowedAdvisoryIds` allowlists, + applied to app and KMS requests on `/authorize*` and `/bootAuth/*`. When set, + a `tcbStatus` outside the list, or any advisory ID outside the list, denies; + when absent, neither is checked. `GET /` reports the configured `chainId` and + `appImplementation`, and each decision is logged with the device ID, + measurements and TCB status. + ### Changed +- KMS: a KMS requires a key handover path and otherwise refuses to start, + before onboarding or bootstrap, naming the missing setting: unless + `core.onboard.public_key_handover = true`, set `[core.admin] enabled = true` + and `[core.admin.tls]` with `key` and `certs`. There is no opt-out, dev auth + included. The built-in config now enables the admin listener with TLS on the + RPC key and certificate, so a KMS on it needs an admin token to start: + `core.admin.auth_token`, `DSTACK_KMS_ADMIN_TOKEN`, or the new + `core.admin.auth_token_hash` (hex SHA-256), which keeps the token out of a + public app compose; the hash is then public, so use a high-entropy random + token such as `openssl rand -hex 32`. When upgrading, add the admin TLS and a token, or enable + the public handover. The `dstack-app` composes and `dstackup` generate the + token and configure its hash; the deploy scripts publish the admin listener + when `KMS_ADMIN_ADDR` is set. +- KMS: `core.onboard.public_key_handover` defaults to `false`, so + `KMS.GetKmsKey` is no longer served on the public port. Onboard through the + source's admin listener with `source_token`; a source that still onboards + 0.5.x KMS nodes must set the option to `true` until they are upgraded. +- KMS: onboarding refuses a source whose certificate carries no OS image hash, + which drops the fallback for KMS 0.5.x sources. Join only from a 0.6+ source; + upgrade a 0.5.x cluster to 0.6.x first. +- dstack-auth: an empty `devices` list no longer allows any device; set + `allowAnyDevice: true` for that. An empty `osImages` list now denies every + app instead of skipping the OS image check. Allowlists generated by + `dstack-cli` already set both explicitly. +- Auth backends: once a root epoch other than 0 is activated, `/bootAuth/kms` + denies a KMS that reports no root epoch, as `/bootAuth/app` already did, so + KMS binaries predating root epochs stop serving at activation. Upgrade every + KMS before activating a new root epoch. +- auth-eth: policy is read at the `finalized` block by default instead of + `latest`, so on-chain changes, including revocations, take effect only once + finalized. Set `ETH_BLOCK_TAG=latest` (or `safe`) to override; an OP-stack + chain read through the Helios light client requires `latest`. Only + `finalized` protects policy from reorgs: with `latest` or `safe`, a reorg can + roll back an activation or a revocation the KMS has already acted on. The + server refuses to start if the RPC cannot serve the configured tag. + `ETH_FINALITY_CONFIRMATIONS` now counts back from that block. +- DstackKms, DstackApp: material approvals are per epoch. A request is decided + on the row of the epoch it selects, so a value is revoked only once every row + allowing it is cleared. Material setters name their rows: + `*AtEpoch(epoch, value)` writes one epoch, `*AtEpochs(from, to, value)` a + range; `remove*AtEpochs(0, latest root epoch, value)` in `DstackKms` and + `remove*AtEpochs(0, currentAppEpoch, value)` in `DstackApp` revoke a value + everywhere. `DstackApp` reports its setters as the new ERC-165 interface + `IAppEpochManagement` (`0x853d2b79`). The `Manage.s.sol` scripts require + `ROOT_EPOCH` or `APP_EPOCH`, or a `FROM_*`/`TO_*` range. +- App contracts deployed before app epochs (v0.5.x, v0.6.0) keep working with + the upgraded `DstackKms` and KMS, which authorize them for app epoch 0 only. + `Manage.s.sol` detects them by interface ID and calls their epoch-less + setters. Upgrading one to the current `DstackApp` keeps its compose hashes, + devices and `allowAnyDevice` as app epoch 0. +- DstackKms, DstackApp: ownership transfers take two steps for proxies upgraded + from v0.5.9: `transferOwnership` nominates, and the new owner must call + `acceptOwnership`. +- dstack-auth: `kms_export_keys` and `kms_import_keys` accept a registered + pending root epoch, so KMS nodes can onboard a new epoch before + `currentRootEpoch` is raised, as with the contract backend. Key-issuing + operations stay limited to the current epoch. +- KMS: retain `GetTempCaCert`, temporary CA files and legacy handover fields so + KMS nodes can be upgraded before existing OS images. + - dstack-util, cert-client: guests mint self-issued RA-TLS client certificates for KMS calls instead of fetching the temp CA and minting from it; the KMS authenticates the quote inside the certificate, not its issuer. - The KMS root CA now comes from `GetMeta`, over the same unauthenticated - connection `GetTempCaCert` used, so the trust posture is unchanged: relying - parties bind the KMS through the `key-provider` launch event (RTMR3/PCR14) - against the on-chain `kmsInfo` or a pinned key, and `verify_key_provider_id` - also enforces it in-guest when the app pins `key_provider_id`. A guest built - from this change needs KMS 0.6.0 or later; older KMS releases pin the temp CA - and refuse the certificate at the TLS handshake + Guests fetch the root CA with the key response and bind it to the attested + RPC certificate, without a metadata preflight. Provider pins and the + `key-provider` launch event (RTMR3/PCR14) retain the root identity binding. + The new flow requires an attested KMS that accepts self-issued certificates. - dstack-types: `KeyProvider::Kms` drops `tmp_ca_key` and `tmp_ca_cert`, so `.appkeys.json` no longer carries them. Files written by older images still parse, but out-of-tree readers that require those fields fail on new files +- KMS: `auth-eth-bun` is renamed to `auth-eth`. The Bun webhook server moves + from `dstack/kms/auth-eth-bun` into `dstack/kms/auth-eth`, next to the + contracts. New releases are tagged `kms-auth-eth-v*` and published as + `ghcr.io/dstack-tee/dstack-kms-auth-eth`; images already published as + `ghcr.io/dstack-tee/dstack-kms-auth-eth-bun` remain available. Update image + references and source paths in deployments. + +### Removed + +- DstackKms: the epoch-less `addOsImageHash`, `removeOsImageHash`, + `addKmsAggregatedMr`, `removeKmsAggregatedMr`, `addKmsDevice` and + `removeKmsDevice`. Use the `*AtEpoch` or `*AtEpochs` forms; before any + rotation, `addOsImageHashAtEpoch(0, hash)` is the old call. +- **Breaking:** DstackApp: the epoch-less `addComposeHash`, `removeComposeHash`, + `addDevice`, `removeDevice` and `setAllowAnyDevice`, and with them + `IAppAuthBasicManagement`: an upgraded app no longer reports interface ID + `0x8fd37527`, and calls to those functions revert. Use the `*AtEpoch` or + `*AtEpochs` forms (`IAppEpochManagement`); at app epoch 0, + `addComposeHashAtEpoch(0, hash)` is the old call. +- DstackKms: `setKmsInfo`, `setKmsQuote` and `setKmsEventlog`. Root keys are + set with `registerRootEpoch`, and each epoch's attestation lives at its + `evidenceUrl`. +- KMS: the `auth-mock` webhook backend. For development, use the built-in + `[core.auth_api] type = "dev"`, which allows everything. +- `auth-simple` (`dstack/kms/auth-simple`). Use `dstack-auth`, which reads the + same config file. Differences: the TCB status and advisories are not checked + unless `allowedTcbStatuses` / `allowedAdvisoryIds` are set (auth-simple + defaulted to `["UpToDate"]` and `[]`); `/authorize*` requires + `kmsContractAddr`; KMS requests are not matched against `osImages`; the + config path and port are `--config` / `--port` flags instead of + `AUTH_CONFIG_PATH` / `PORT`, and it binds to `127.0.0.1` by default. See + `docs/dstack-auth-operations.md#migrating-from-auth-simple`. +- KMS: the Node `auth-eth` webhook server (`dstack/kms/auth-eth/src`) is + removed. Use the Bun server (formerly `auth-eth-bun`, now in the same + directory, see Changed), which serves the same webhook API and reads the + same environment variables, except that it binds all interfaces and ignores + `HOST`, and `PORT` defaults to 3000 from source (8000 in its image). The + `DstackKms`/`DstackApp` contracts, Foundry tests and deployment scripts stay + in `dstack/kms/auth-eth`. `kms/dstack-app/compose-dev.yaml` now builds + the Bun server. + ### Fixed -- kms: onboarding from a source older than 0.6.0 (no `source_token`) now - verifies the source's attestation on the connection that returns the root - keys, and checks it against local policy before using them. It used to - verify the source only on the earlier `GetTempCaCert` connection +- kms: onboarding with or without `source_token` verifies the source's + attestation on the connection that returns the root keys and checks local + policy before using them. Legacy sources requiring temp-CA-issued clients + must be upgraded. ## [0.6.3] - 2026-11-01 diff --git a/CLAUDE.md b/CLAUDE.md index 6fdcf5174..a42d3f073 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,22 +70,24 @@ cargo clippy -- -D warnings --allow unused_variables ```bash cd dstack/kms/auth-eth -npm install # Install Node.js dependencies for bootAuth server -forge install # Install Foundry dependencies (submodules) +git submodule update --init --recursive lib/forge-std \ + lib/openzeppelin-contracts-upgradeable lib/openzeppelin-foundry-upgrades +# Upgrade-safety validator called by `forge test --ffi` +npx --yes @openzeppelin/upgrades-core@^1.37.0 --help > /dev/null # Build forge build # Compile smart contracts -npm run build # Build TypeScript server # Test -forge test --ffi # Run Foundry contract tests -npm test # Run TypeScript server tests -npm run test:coverage # Run TypeScript tests with coverage +forge clean && forge test --ffi # Run Foundry contract tests # Local development anvil # Start local Ethereum node ``` +The KMS webhook server for these contracts lives in the same directory +(`bun install && bun run check`). + ### Python SDK ```bash @@ -130,7 +132,7 @@ cargo test --all-features -- --show-output --test-threads=1 cd dstack/kms/auth-eth # Run all Foundry tests -forge test +forge test --ffi # Run with verbosity forge test -vv diff --git a/REUSE.toml b/REUSE.toml index 68c074019..49afab624 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -34,17 +34,15 @@ path = [ "**/tsconfig.json", "**/tsconfig.node.json", "**/tsconfig.browser.json", - "dstack/kms/auth-eth-bun/.oxlintrc.json", + "dstack/kms/auth-eth/.oxlintrc.json", "dstack/kms/auth-eth/slither.config.json", "**/package-lock.json", "dstack/kms/auth-eth/.openzeppelin/unknown-2035.json", - "dstack/kms/auth-mock/.oxlintrc.json", - "dstack/kms/auth-simple/.oxlintrc.json", - "dstack/kms/auth-simple/auth-config.example.json", "tools/sca/examples/heartbeat/config.json", "tools/sca/examples/hello-c/config.json", "tools/sca/examples/heartbeat/rootfs/etc/heartbeat/interval", "sdk/simulator/*.json", + "sdk/fixtures/sealed-env/*.json", "sdk/go/go.sum", "sdk/go/ratls/go.sum", "dstack/kms/dstack-app/builder/shared/builder-pinned-packages.txt", @@ -194,11 +192,6 @@ precedence = "override" # Generated files -[[annotations]] -path = "dstack/kms/auth-eth/typechain-types/**" -SPDX-FileCopyrightText = "NONE" -SPDX-License-Identifier = "CC0-1.0" - [[annotations]] path = "**/*.lock" SPDX-FileCopyrightText = "NONE" diff --git a/docs/app-key-epochs.md b/docs/app-key-epochs.md new file mode 100644 index 000000000..fd73df504 --- /dev/null +++ b/docs/app-key-epochs.md @@ -0,0 +1,181 @@ +# Application key epochs + +Application owners can rotate their app's derived keys without rotating KMS root +keys or other apps. This extends SealedEnv (#1528); the PR dependency order is +#1519 → #1517 → #1520 → #1521 → #1528 → application epochs. + +## Authorization and rotation + +`authorizeApp` returns `currentAppEpoch` together with the current root +epoch and its chain hash. `requestedRootEpoch` and `requestedAppEpoch` are +independent `OptionalUint256` values: omitted selects current, explicit zero selects +historical epoch zero. Future epochs are denied through `rejectionReason`. +`authorizeKms` remains separate and uses app epoch zero. + +`DstackApp.currentAppEpoch` starts at zero. Its owner calls +`advanceAppEpoch(expectedCurrentEpoch)` to increment it. The expected value +prevents a stale or retried transaction from advancing twice. Epochs cannot be +rolled back and are limited to uint32. Prepare the next row before advancing, as +with a pending root epoch: approve its materials with `*AtEpoch(N + 1, ...)`, +then call `advanceAppEpoch(N)`, so guests that restart after the advance are +authorized at once. `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. Once the app epoch is nonzero, the legacy +`isAppAllowed` of both `DstackApp` and `DstackKms` refuses, since its callers +derive epoch-zero keys, so the KMS contract calling the app policy must support +epochs before the app epoch advances. The ERC-165 `IAppEpochAuth` capability combines +`currentAppEpoch()` and `authorizeApp(AuthorizationRequest)` in one interface; the +policy returns the same `AuthorizationResult` struct as `DstackKms`. 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 +its monotonicity. Public encryption-key lookup uses `GET /app/:appId` to read this +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. + +A request may select any epoch up to the current one and is decided on that +epoch's row, so a value is revoked only once every row allowing it is cleared. +Neither contract has epoch-less material setters: `*AtEpoch(epoch, material)` +changes one row and `*AtEpochs(fromEpoch, toEpoch, material)` every row in the +range, so one transaction revokes a value from all rows, with +`remove*AtEpochs(0, latest, …)` in `DstackKms` and +`remove*AtEpochs(0, currentAppEpoch, …)` in `DstackApp`. `DstackKms` writes +registered root epochs (before any is registered, only epoch zero); `DstackApp` +writes app epochs up to the next one, as do `setAllowAnyDeviceAtEpoch(s)`. +`DstackApp` reports these setters as the ERC-165 interface +`IAppEpochManagement` and no longer reports `IAppAuthBasicManagement` +(`0x8fd37527`), whose epoch-less setters it removed. The scripts in +`Manage.s.sol` take `ROOT_EPOCH` or `APP_EPOCH` for one row, or +`FROM_ROOT_EPOCH`/`TO_ROOT_EPOCH` and `FROM_APP_EPOCH`/`TO_APP_EPOCH` for a range, +and fail without them. + +App policies deployed before app epochs (v0.5.x, v0.6.0) keep working with an +upgraded `DstackKms` and KMS: they report no `IAppEpochAuth`, so `authorizeApp` +asks their `isAppAllowed` and only for app epoch zero, and `currentAppEpoch` +reports zero for them. Their owners keep the epoch-less setters until they +upgrade, which `Manage.s.sol` detects and uses with no epoch or `APP_EPOCH=0`. +Upgrading keeps their allowlists as app epoch zero. See +[onchain-governance.md](./onchain-governance.md#apps-deployed-before-app-epochs). + +Disk and environment migration after a root rotation fetches the keys of the +recorded epoch, so a new image migrating old data must be allowed in that old +row too. Without it the guest stops with an error naming the root and app epoch +it could not get keys for. + +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. +Root policy rows from epoch 1 belong to the epoch's registration, so a row can +be written only once the epoch is registered (register, prepare the row, then +activate). A popped epoch's rows do not carry over: the next registration of the +number starts with empty rows, even with the same keys, which must be prepared +again before activation. Epoch 0 keeps the legacy mappings. + +### 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 storage consumes storage-gap slots in each +contract (four in `DstackKms`, three in `DstackApp`); 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. `DstackKms.authorizeApp` first applies the +checks it owns (operation scope, registered root epoch, requested root epoch not +beyond the current one, app registration, OS image at the selected root epoch, +deployed contract), then forwards the request unchanged to the policy's +`authorizeApp`. The policy validates the operation and the requested app epoch and +fills `authorized`, `rejectionReason` and `currentAppEpoch`; the KMS then sets +`gatewayAppId` and the current and latest root epoch fields. The decision and +its app epoch therefore come from one policy call. 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. Config-file auth +backends such as dstack-auth retain their own policy model; dstack-auth applies the +same epoch limits, accepting a registered pending root epoch for KMS handover and +only active epochs for key-issuing operations. + +## 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 +`"app-epoch-v1:" || app_epoch[4, big-endian]` to the existing derivation context +for disk, environment, SealedEnv, app CA and secp256k1 keys. + +The root signature for a nonzero app secp256k1 key signs Keccak256 of +`"dstack-kms-issued-v2:" || app_id[20] || app_epoch[4, big-endian] || public_key[33]`. +Relying parties must verify this format against the trusted root key for the +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 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 +changes and rejects future epochs in either dimension. LUKS tokens record both +epochs (missing app epoch means zero). Rekeying replaces the keyslot wrapping key; +it does not rotate the volume encryption key or erase host snapshots. Once a disk +header carries an epoch token, older guest images reject that header as malformed; +reverting the VM image therefore requires migrating the disk with a compatible +image or restoring a compatible header backup. App rotation isolates future app +keys from an exposed old derived key, but cannot recover from compromise of the +KMS root or revoke already disclosed secrets. + +## SDK selection + +Both selectors are optional; specify zero explicitly to recover an old key. +For example, Python `client.get_key("storage", "secp256k1", root_epoch=3, +app_epoch=0)` selects exactly that pair. JavaScript uses +`client.getKey("storage", "secp256k1", 3, 0)`. Rust provides +`get_key_at_epochs(..., Some(3), Some(0))`; Go provides `GetKeyAtEpochs` with +pointer selectors. Responses report both epochs. Existing root-only helpers +continue to use the CVM's current app epoch. + +Sealing helpers also accept app epochs: `seal_env_at_epochs` (Rust), +`SealEnvAtEpochs` (Go), and the optional final argument of `sealEnv` / `seal_env` +(JavaScript / Python). See [SealedEnv](sealed-env.md) for the wire and signature +formats. The VMM UI and CLI use the epochs returned with the encryption key. SealedEnv +uses a single version-1 format that always includes both epochs. diff --git a/docs/auth-simple-operations.md b/docs/auth-simple-operations.md deleted file mode 100644 index 6b8826d8f..000000000 --- a/docs/auth-simple-operations.md +++ /dev/null @@ -1,302 +0,0 @@ -# auth-simple Operations Guide - -> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). - -This guide covers day-to-day operations for managing apps and devices with auth-simple. - -For initial deployment setup, see [Deployment Guide](./deployment.md). - -## Overview - -auth-simple uses a JSON config file to whitelist: -- **OS images** - Which guest OS versions can boot -- **KMS nodes** - Which KMS instances can onboard (mrAggregated, devices) -- **Apps** - Which applications can boot (appId, composeHash, devices) - -The config is re-read on each request, so changes take effect immediately without restart. - ---- - -## Config File Structure - -```json -{ - "osImages": ["0x..."], - "gatewayAppId": "0x...", - "kms": { - "mrAggregated": ["0x..."], - "devices": ["0x..."], - "allowAnyDevice": true - }, - "apps": { - "0x": { - "composeHashes": ["0x..."], - "devices": ["0x..."], - "allowAnyDevice": true - } - } -} -``` - -> **Note:** `osImages` is always required. For KMS authorization, you must also populate `kms.mrAggregated`; if it is left empty, auth-simple denies all KMS boots. Add `gatewayAppId` after deploying the Gateway. Add `apps` entries as you deploy applications. - ---- - -## Adding an App - -### Step 1: Generate App ID - -App IDs are typically the contract address (for on-chain) or a unique identifier you choose. - -For auth-simple, you can use any unique hex string (40 characters / 20 bytes): - -```bash -# Generate a random app ID -openssl rand -hex 20 -# Output: 7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b -``` - -### Step 2: Get Compose Hash - -The compose hash is computed from the normalized docker-compose file. The VMM displays it when deploying: - -``` -Docker compose file: -... -Compose hash: 0x700a50336df7c07c82457b116e144f526c29f6d8... -``` - -Or query from a running CVM: - -```bash -curl -s --unix-socket /var/run/dstack.sock http://localhost/Info | \ - jq -r '"0x" + (.tcb_info | fromjson | .compose_hash)' -``` - -> **Note:** The VMM normalizes YAML to JSON before hashing. For exact hash, use the value shown during deployment. - -### Step 3: Add to Config - -Edit your `auth-config.json`: - -```json -{ - "apps": { - "0x7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b": { - "composeHashes": [ - "0x700a50336df7c07c82457b116e144f526c29f6d8..." - ], - "devices": [], - "allowAnyDevice": true - } - } -} -``` - -### Step 4: Deploy via VMM - -Use the App ID when deploying through VMM: - -```bash -./vmm-cli.py deploy \ - --app-id 7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b \ - --compose docker-compose.yaml \ - ... -``` - ---- - -## Updating an App - -When you change your docker-compose.yaml, a new compose hash is generated. - -### Add New Hash (Keep Old Running) - -Add the new hash to the `composeHashes` array: - -```json -{ - "apps": { - "0x": { - "composeHashes": [ - "0x", - "0x" - ] - } - } -} -``` - -### Replace Hash (Force Upgrade) - -Remove the old hash to prevent old versions from booting: - -```json -{ - "apps": { - "0x": { - "composeHashes": [ - "0x" - ] - } - } -} -``` - ---- - -## Device Management - -Devices are identified by their TDX device ID (hardware-specific). - -### Get Device ID - -The `deviceId` is sent by the booting app/KMS in its auth request. Check auth-simple logs: - -``` -app boot auth request: { appId: '0x...', deviceId: '0x...', ... } -``` - -> **Tip:** Use `allowAnyDevice: true` initially, then restrict to specific devices after capturing IDs from logs. - -### Restrict App to Specific Devices - -```json -{ - "apps": { - "0x": { - "composeHashes": ["0x..."], - "devices": [ - "0xe5a0c70bb6503de2d31c11d85914fe3776ed5b33a078ed856327c371a60fe0fd" - ], - "allowAnyDevice": false - } - } -} -``` - -### Allow Any Device - -For initial testing or when device restriction isn't needed: - -```json -{ - "apps": { - "0x": { - "allowAnyDevice": true - } - } -} -``` - ---- - -## Removing an App - -Delete the app entry from the `apps` object: - -```json -{ - "apps": { - // "0x": { ... } <- deleted - } -} -``` - -Running instances will continue until restarted. New boot requests will be rejected. - ---- - -## Setting the Gateway App (After Gateway Deployment) - -The `gatewayAppId` field is **optional** during initial KMS deployment. Add it after deploying the Gateway. - -The Gateway is a special app that routes traffic to other apps. Once deployed, add its App ID to your config: - -```json -{ - "gatewayAppId": "0x75537828f2ce51be7289709686A69CbFDbB714F1", - "apps": { - "0x75537828f2ce51be7289709686A69CbFDbB714F1": { - "composeHashes": ["0x..."], - "allowAnyDevice": true - } - } -} -``` - -The `gatewayAppId` is returned in boot responses and used by KMS for key derivation. - -To trust several gateways, join their App IDs with commas (`"0xaaa...,0xbbb..."`). CVMs accept a gateway whose App ID is contained in this value. - ---- - -## KMS Onboarding (Multi-Node) - -To allow additional KMS nodes to onboard (receive root keys from the primary KMS), whitelist their `mrAggregated` value. - -### Get mrAggregated - -The `mrAggregated` is sent by the booting KMS in its auth request. To get this value: - -1. **From auth-simple logs**: When a KMS boots, auth-simple logs the mrAggregated: - ``` - KMS boot auth request: { osImageHash: '0x...', mrAggregated: '0x...', ... } - ``` - -2. **Initial setup**: capture the first KMS measurement with `Onboard.GetAttestationInfo` or from auth logs, then add it to `kms.mrAggregated` before bootstrap. An empty array now denies all KMS boots. - -### Add to Config - -```json -{ - "kms": { - "mrAggregated": ["0x"], - "allowAnyDevice": true - } -} -``` - -> **Note:** All KMS nodes using the same OS image and compose will have the same mrAggregated, so you only need to capture it once. - ---- - -## Verification - -### Check Config is Valid - -```bash -curl -s http://localhost:3001/ | jq -``` - -Returns current config status including `gatewayAppId`. - -### Test Boot Authorization - -```bash -# Test app boot -curl -s -X POST http://localhost:3001/bootAuth/app \ - -H "Content-Type: application/json" \ - -d '{ - "appId": "0x", - "composeHash": "0x", - "osImageHash": "0x", - "deviceId": "0x", - "mrAggregated": "0x...", - "instanceId": "0x...", - "tcbStatus": "UpToDate" - }' | jq -``` - -Expected responses: -- `{"isAllowed": true, ...}` - App is authorized -- `{"isAllowed": false, "reason": "app not registered", ...}` - App ID not in config -- `{"isAllowed": false, "reason": "compose hash not allowed", ...}` - Hash not whitelisted - ---- - -## See Also - -- [Deployment Guide](./deployment.md) - Initial setup -- [auth-simple README](../dstack/kms/auth-simple/README.md) - Developer reference -- [On-Chain Governance](./onchain-governance.md) - Smart contract-based alternative diff --git a/docs/aws-attested-instance-security-evaluation.md b/docs/aws-attested-instance-security-evaluation.md index 7dbbb3841..1b118f56d 100644 --- a/docs/aws-attested-instance-security-evaluation.md +++ b/docs/aws-attested-instance-security-evaluation.md @@ -38,11 +38,11 @@ NitroTPM path meets them today. | P4 | Runtime application identity is cryptographically bound | RTMR3 `compose-hash`, `app-id`, `instance-id`, `key-provider`; event log replay | SHA384 `PCR14` event-log replay is the authoritative binding (RTMR3 analogue; non-resettable). Launch events: `system-preparing`, `app-id`, `compose-hash`, zero or more ordered `init-script-hash` events, `instance-id`, `boot-mr-done`, `key-provider`, `storage-fs`, `storage-encrypted`, `system-ready` (`dstack/dstack-util/src/system_setup.rs`). GPU launches also include `gpu-policy-hash` before `instance-id`, and `gpu-attestation` after `boot-mr-done` and before `key-provider`. `dstack-attest`, `dstack-verifier`, and KMS reject missing/mismatched PCR14 and bad replay. Optionally, the guest extends the raw `MrConfig` V2 `config_id` into `PCR8` once (`PCR8 = sha384(0^48 || config_id)`) so a lightweight third-party verifier can check compose hash + key provider without event-log replay; dstack's own verifier and KMS do not check PCR8. | | P5 | Challenge/liveness and caller key binding | `report_data` challenge or RA-TLS public key hash in quote | RA-TLS binds `report_data` to the TLS certificate public key. KMS key release is bound to the live RA-TLS handshake; external `/verify` callers supply and check their own `report_data` challenge. The low-level NitroTPM document verifier also rejects stale or far-future document timestamps. | | P6 | Secret release only to attested code | dstack KMS verifies attestation, checks auth policy, derives per-app keys | dstack KMS verifies the NitroTPM attestation, runs the same `verify_os_image_hash_for_aws_nitro_tpm` binding check as the verifier, builds `BootInfo` from verified boot PCRs plus PCR14 launch events, and checks auth policy before deriving app keys (`dstack/kms/src/main_service.rs`). AWS NitroTPM key release is gated behind the opt-in `aws_nitro_tpm_key_release` flag (default false in `kms.toml`). | -| P7 | Key-release policy is not controlled by the untrusted account admin | KMS runs inside TEE; policy from auth API/contracts; KMS identity measured | Satisfied with dstack KMS or another verifiable secret authority outside the untrusted AWS account admin's control. A NitroTPM-backed dstack KMS keeps root material out of account-admin snapshots and clones. The policy backend (auth-simple in a trusted control plane, or on-chain `DstackKms`/`DstackApp`) must be outside the workload account admin's control. Same-account AWS KMS fails this property if the admin can change key policy, create grants, or call secret-bearing operations through a policy they control. | +| P7 | Key-release policy is not controlled by the untrusted account admin | KMS runs inside TEE; policy from auth API/contracts; KMS identity measured | Satisfied with dstack KMS or another verifiable secret authority outside the untrusted AWS account admin's control. A NitroTPM-backed dstack KMS keeps root material out of account-admin snapshots and clones. The policy backend (dstack-auth in a trusted control plane, or on-chain `DstackKms`/`DstackApp`) must be outside the workload account admin's control. Same-account AWS KMS fails this property if the admin can change key policy, create grants, or call secret-bearing operations through a policy they control. | | P8 | Operator cannot inject boot-time or runtime inputs that affect secrets without detection | Host-shared files are measured or independently authenticated; KMS URLs not trusted, KMS key identity measured | App TPM access is enforced by measurement, not by a container sandbox: `init_script`, `pre_launch_script`, and the docker-compose are measured into the app-compose hash, hence into the governed app identity, and replayed into non-resettable `PCR14`, so an operator cannot alter app inputs without changing the measured identity and being denied key release. Production dstack-os AWS images exclude SSH, cloud-init, SSM, EC2 Instance Connect, and serial login (checked by `audit-aws-ec2-image-hardening.sh`). IMDS/operator-provided config must not be a secret-affecting input unless measured or authenticated. | | P9 | Persistent state confidentiality from account admin | dstack disk key derived after attestation; encrypted env vars decrypted only inside CVM | The local TPM key provider seals the disk seed under a SHA384 PCR policy over `[4, 7, 8, 12, 14]` (`AWS_NITRO_PCRS` in `dstack/tpm-attest/src/lib.rs`), so the sealed seed survives same-instance stop/start but does not transfer with cloned EBS volumes to another instance. EBS snapshots and volumes remain account-admin visible at the AWS control plane, so persisted confidential state must stay encrypted inside the instance and AMIs must contain no secrets. | | P10 | Network and TLS endpoint identity is attestation-bound | RA-TLS / Zero Trust HTTPS / gateway attestation; TLS keys generated in TEE | The Rust RA-TLS verifier exposes `verify_der`/`verify_pem`, which extract the certificate's embedded attestation, verify it with the platform verifier (including AWS NitroTPM), and require `report_data = QuoteContentType::RaTlsCert(SubjectPublicKeyInfo)`. `dstack-verifier --verify-cert` provides an operator/relying-party certificate evidence path that also binds `os_image_hash`. AWS attestation alone does not protect DNS, load balancers, or admin-controlled proxies; clients must require this RA-TLS check, signed responses, or an attested gateway. | -| P11 | Upgrade governance is explicit and non-bypassable | DstackApp/DstackKms whitelists compose hashes, OS images, KMS aggregate MRs | auth-simple and the unchanged on-chain `DstackKms`/`DstackApp` contracts pin OS image hash, app compose hash, device ID, and KMS identity. KMS auth pins the early `mrAggregated` (the `boot-mr-done` launch-event snapshot). AWS reuses the standard gate via `tcbStatus = "UpToDate"` normalization with no AWS-specific on-chain field, plus the opt-in `aws_nitro_tpm_key_release` KMS flag. Secure Boot PCR7 certificate policies need explicit rotation/revocation handling so old AMIs do not remain authorized. | +| P11 | Upgrade governance is explicit and non-bypassable | DstackApp/DstackKms whitelists compose hashes, OS images, KMS aggregate MRs | dstack-auth and the unchanged on-chain `DstackKms`/`DstackApp` contracts pin OS image hash, app compose hash, device ID, and KMS identity. KMS auth pins the early `mrAggregated` (the `boot-mr-done` launch-event snapshot). AWS reuses the standard gate via `tcbStatus = "UpToDate"` normalization (dstack-auth enforces it once `allowedTcbStatuses: ["UpToDate"]` is set) with no AWS-specific on-chain field, plus the opt-in `aws_nitro_tpm_key_release` KMS flag. Secure Boot PCR7 certificate policies need explicit rotation/revocation handling so old AMIs do not remain authorized. | | P12 | Verifier is independent and complete | `dstack-verifier`, DCAP/QVL, RTMR replay, KMS/app/governance checks | `dstack-verifier` verifies the NitroTPM document against the AWS Nitro PKI, enforces the required `aws_measurement`/`boot_pcr_digest` binding against the unified `os_image_hash`, replays the PCR14 launch-event chain, and emits the canonical auth-policy object as `details.boot_info` for the same policy logic used by `/bootAuth/app` and `/bootAuth/kms`. The relying-party deployment workflow is `docs/aws-ec2-production-verifier-runbook.md`; each deployment must instantiate it with real OS-image, PCR, and compose-hash allowlists. | ## Sources diff --git a/docs/aws-ec2-production-verifier-runbook.md b/docs/aws-ec2-production-verifier-runbook.md index d43fc3719..ff68cb4ba 100644 --- a/docs/aws-ec2-production-verifier-runbook.md +++ b/docs/aws-ec2-production-verifier-runbook.md @@ -18,7 +18,7 @@ and endpoint identity. - The dstack monorepo sources pinned at the exact release revision, for the reproducible rebuild. - A dstack verifier built from this repository. -- The production auth policy backend: `auth-simple`, auth-eth contracts, or an +- The production auth policy backend: `dstack-auth`, auth-eth contracts, or an equivalent verifier-controlled service. - For public services, the endpoint's RA-TLS certificate. @@ -198,7 +198,7 @@ The relying party should then check `details.boot_info` against its auth policy. For AWS NitroTPM, the policy must require: -- accepted `tcbStatus` (`UpToDate` by default, matching the normalized value); +- accepted `tcbStatus` (`UpToDate`, matching the normalized value); - accepted `osImageHash` (unified `sha256(sha256sum.txt)`; `aws_measurement` is required and must bind the attested boot PCRs); - accepted app `composeHash` and `appId`; @@ -209,6 +209,33 @@ For AWS NitroTPM, the policy must require: `instance-id`, `key-provider`); - a `report_data` challenge for external verifier flows that need liveness. +With `dstack-auth`, the TCB status and advisories are checked only when the +allowlist sets them, so set both explicitly: + +```json +{ + "allowedTcbStatuses": ["UpToDate"], + "allowedAdvisoryIds": [], + "osImages": ["0x"], + "kms": { + "mrAggregated": ["0x"], + "devices": [], + "allowAnyDevice": true + }, + "apps": { + "0x": { + "composeHashes": ["0x"], + "devices": [], + "allowAnyDevice": true + } + } +} +``` + +dstack-auth authorizes the canonical `BootInfo` the KMS derives after verifying +the NitroTPM evidence; it does not verify raw NitroTPM evidence itself. See +[dstack-auth operations](./dstack-auth-operations.md) for the remaining fields. + For GPU workloads, PCR14 also contains `gpu-policy-hash` immediately after `compose-hash`. Its 32-byte payload must equal `SHA-256(JCS(requirements.gpu_policy))`, using `{}` when the policy is omitted. diff --git a/docs/deployment.md b/docs/deployment.md index 9d010e67b..9095496dd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -122,28 +122,39 @@ Production KMS requires: #### Auth Server Options -> **Note:** the boot-authorization webhook below (auth-simple / auth-eth) is a *different* mechanism from the KMS admin-API authentication. The webhook allowlists which CVMs may boot and receive keys; the admin API (`[core.admin]` in `kms.toml`) guards operator RPCs. See [dstack-kms admin authentication](../dstack/kms/README.md#admin-api-authentication). +> **Note:** the boot-authorization webhook below (dstack-auth / auth-eth) is a *different* mechanism from the KMS admin-API authentication. The webhook allowlists which CVMs may boot and receive keys; the admin API (`[core.admin]` in `kms.toml`) guards operator RPCs. See [dstack-kms admin authentication](../dstack/kms/README.md#admin-api-authentication). | Server | Use Case | Configuration | |--------|----------|---------------| -| [auth-simple](../dstack/kms/auth-simple/) | Config-file-based whitelisting | JSON config file | -| [auth-eth](../dstack/kms/auth-eth/) | On-chain governance via smart contracts | Ethereum RPC + contract | +| [dstack-auth](./dstack-auth-operations.md) | Config-file-based whitelisting | JSON config file | +| [auth-eth](../dstack/kms/auth-eth/) | On-chain governance via the `DstackKms`/`DstackApp` contracts in the same directory | Ethereum RPC + contract | | Custom | Your own authorization logic | Implement webhook interface | All auth servers implement the same webhook interface: -- `GET /` - Health check -- `POST /bootAuth/app` - App boot authorization -- `POST /bootAuth/kms` - KMS boot authorization -#### Using auth-simple (Config-Based) +- `GET /` - Health check and current root epoch +- `POST /authorizeApp` - Application key and certificate authorization +- `POST /authorizeKms` - KMS self-authorization and key handover -auth-simple validates boot requests against a JSON config file. +The legacy `/bootAuth/app` and `/bootAuth/kms` routes remain available for older +clients and KMS bootstrap/onboarding checks. + +#### Using dstack-auth (Config-Based) + +dstack-auth validates requests against a JSON config file. Build it: + +```bash +cd dstack +cargo build --release -p dstack-auth +``` Create `auth-config.json` for initial KMS deployment: ```json { "osImages": ["0x"], + "allowedTcbStatuses": ["UpToDate"], + "allowedAdvisoryIds": [], "kms": { "mrAggregated": ["0x"], "allowAnyDevice": true @@ -152,22 +163,42 @@ Create `auth-config.json` for initial KMS deployment: } ``` -> **Important:** `auth-simple` now treats an empty `kms.mrAggregated` allowlist as deny-all for KMS. Capture the current KMS measurement with `Onboard.GetAttestationInfo` and add it before bootstrap. +> **Important:** an empty `kms.mrAggregated` allowlist denies every KMS. Capture the current KMS measurement with `Onboard.GetAttestationInfo` and add it before bootstrap. -Run auth-simple: +`allowedTcbStatuses` and `allowedAdvisoryIds` are opt-in: without them the TCB +status and advisories are not checked. Keep them unless you operate and trust +the host yourself; see [TCB and advisory policy](./dstack-auth-operations.md#tcb-and-advisory-policy). + +Bootstrap needs only the fields above. Before the KMS serves keys, set +`kmsContractAddr` to the KMS CVM's app ID and register its root keys as +`rootEpochs` (see [Rotating KMS Root Keys](#rotating-kms-root-keys)); every +other operation is denied until both are set. + +Run dstack-auth on the operator host: ```bash -cd dstack/kms/auth-simple -bun install -PORT=3001 AUTH_CONFIG_PATH=/path/to/auth-config.json bun run start +./target/release/dstack-auth --config /path/to/auth-config.json --port 3001 ``` -For adding Gateway, apps, and other config fields, see [auth-simple Operations Guide](./auth-simple-operations.md). +It binds to `127.0.0.1`, which a KMS CVM on QEMU user-mode networking reaches as +`http://10.0.2.2:3001`. Add `--address 0.0.0.0` if the KMS reaches the host over +a real network. + +For adding Gateway, apps, and other config fields, see [dstack-auth Operations Guide](./dstack-auth-operations.md). #### Using auth-eth (On-Chain) For decentralized governance via smart contracts, see [On-Chain Governance](./onchain-governance.md). +auth-eth reads policy at the block `ETH_BLOCK_TAG` names, `finalized` by +default. Only `finalized` protects policy from reorgs. With `latest` (required +for an OP-stack chain read through Helios) or `safe`, a reorg can revert both +activations and revocations: a removed image or compose hash is authorized +again, and a rolled-back root epoch activation makes every KMS restart onto the +previous epoch. `ETH_FINALITY_CONFIRMATIONS=N` hides reorgs shallower than N +blocks but delays every policy change, emergency revocations included, by N +blocks. See [reorgs and the block tag](../dstack/kms/auth-eth/README.md#reorgs-and-the-block-tag). + #### Getting OS Image Hash The OS image hash is in the `digest.txt` file inside the guest image tarball: @@ -179,15 +210,15 @@ cat dstack-0.5.5/digest.txt # Output: 0b327bcd642788b0517de3ff46d31ebd3847b6c64ea40bacde268bb9f1c8ec83 ``` -Add `0x` prefix for auth-simple config: `0x0b327bcd...` +Add `0x` prefix for the dstack-auth config: `0x0b327bcd...` #### Deploy KMS CVM Choose the deployment script based on your auth server: -**For auth-simple (external webhook):** +**For dstack-auth (external webhook):** -auth-simple runs on your infrastructure, outside the CVM. +dstack-auth runs on your infrastructure, outside the CVM. ```bash cd dstack/kms/dstack-app/ @@ -197,7 +228,7 @@ Edit `.env.simple`: ```bash VMM_RPC=http://127.0.0.1:9080 -AUTH_WEBHOOK_URL=http://your-auth-server:3001 +AUTH_WEBHOOK_URL=http://10.0.2.2:3001 KMS_RPC_ADDR=0.0.0.0:9201 GUEST_AGENT_ADDR=127.0.0.1:9205 OS_IMAGE=dstack-0.5.5 @@ -286,7 +317,7 @@ OS_IMAGE=dstack-0.5.5 **Note on hex formats:** - Gateway `.env` file: Use raw hex without `0x` prefix (e.g., `GATEWAY_APP_ID=32467b43...`) -- auth-simple config: Use `0x` prefix (e.g., `"0x32467b43..."`). The server normalizes both formats. +- dstack-auth config: Use `0x` prefix (e.g., `"0x32467b43..."`). The server normalizes both formats. Run the script again: @@ -306,7 +337,7 @@ Continue? [y/N] ``` **Before pressing 'y'**, add the compose hash to your auth server whitelist: -- For auth-simple: Add to `composeHashes` array in `auth-config.json` +- For dstack-auth: Add to `composeHashes` array in `auth-config.json` - For auth-eth: Use Foundry scripts (see [On-Chain Governance](./onchain-governance.md#register-gateway-app)) Then return to the first terminal and press 'y' to deploy. @@ -470,15 +501,17 @@ Additional KMS instances can onboard from an existing KMS to share the same root **How it works:** 1. New KMS starts in onboard mode (empty `auto_bootstrap_domain`) -2. New KMS generates an RA-TLS client certificate with its TDX quote: self-issued when given a `source_token`, or minted from the temp CA fetched with `GetTempCaCert` for a source older than 0.6.0 -3. New KMS calls `GetKmsKey` over mTLS: `Admin.GetKmsKey` with the token, or the legacy public `KMS.GetKmsKey` without one -4. Source KMS verifies attestation via `bootAuth/kms` webhook +2. New KMS generates an RA-TLS client certificate with its attestation: self-issued with an admin token, or signed by the source temporary CA for tokenless legacy onboarding +3. New KMS calls `GetKmsKey` over mTLS: `Admin.GetKmsKey` with the token, or the explicitly enabled public `KMS.GetKmsKey` without one +4. Source KMS verifies the attestation and authorizes handover via `/authorizeKms` 5. If approved, source KMS returns root keys 6. New KMS verifies the source's attestation on that same connection and checks it against its own policy before using the keys 7. Both KMS instances now derive identical keys **Configure the source KMS for key handover:** +Every KMS needs this, or `core.onboard.public_key_handover = true`: without a handover path it refuses to start, before onboarding or bootstrap, naming the missing setting. In a public config such as an app compose, set `auth_token_hash` (the token's SHA-256) instead of `auth_token`, with a high-entropy random token (`openssl rand -hex 32`), since the hash can be brute-forced offline. + `Admin.GetKmsKey` only works on an admin listener with `[core.admin.tls]`: without TLS the listener speaks plain HTTP, so the new KMS can present no RA-TLS certificate and the call fails with `No attestation provided`. The admin listener binds to `127.0.0.1` by default; bind it to an address the new KMS can reach and restrict access to the KMS nodes at the network layer. ```toml @@ -528,7 +561,71 @@ 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). > -> **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. `core.onboard.public_key_handover = true` keeps the legacy public `KMS.GetKmsKey`: omit `source_token` and pass the source's public RPC URL instead. +> **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 + +KMS root keys are versioned by epoch; epoch 0 is the original roots. Every KMS +keeps all epochs so CVMs can migrate from any of them, and serves the epoch +governance made current. See the [rotation design](./kms-emergency-key-rotation-protocol.md) +for the full procedure; the KMS steps are: + +1. If epochs were never registered, publish the attestation the KMS produced at + bootstrap (kept in `bootstrap-info.json` on the bootstrapped node) and + register the current root public keys as epoch 0: + `DstackKms.registerRootEpoch(caPubkey, k256Pubkey, evidenceUrl)`, via + `script/Manage.s.sol:RegisterRootEpoch`, or a `rootEpochs` entry in the + dstack-auth config. Epoch 0 is current once registered, and must be the + `kmsInfo` keys when those are set. A deployment whose `kmsInfo` holds a + legacy `quote` and `eventlog` instead of an attestation converts them with + `dstack-kms legacy-attestation --quote --eventlog ` and publishes + the result first: from then on `kmsInfo` follows the current epoch's keys and + its quote and event log are cleared. +2. Onboard a fresh KMS with `"rotate": true` (the **Rotate** checkbox in the + onboard UI) from a KMS at the current epoch N-1. It imports epochs 0..N-1, + generates epoch N, and returns the new public keys with an attestation whose + report data is + `keccak256("dstack-kms-genereted-keys-v1:{ca_pubkey_hex};{k256_pubkey_hex};")` + zero-padded to 64 bytes, the same encoding as the original bootstrap (the + spelling is part of the format). It binds both public keys; the epoch number + comes from the on-chain registration order. +3. Before registering, verify the attestation with + [dstack-verifier](../dstack/verifier/README.md) (`POST /verify`): check the + quote, the approved KMS image and measurements, and that its report data + equals the value recomputed from the returned public keys. Then publish it + and register epoch N with its URL. The cluster keeps serving N-1. +4. Onboard the other KMS nodes from the rotated one; they import 0..N and keep + serving N-1. A join checks the source's keyring against the latest registered + epoch as well as the current one, so a source holding an unregistered or + different epoch N is refused. +5. Activate epoch N (`ActivateRootEpoch`, or `currentRootEpoch` for + dstack-auth). Each KMS restarts onto N on its next uncached authorization + check; a KMS that does not hold N waits + until it is onboarded again. + If the nodes holding N are lost before N is activated, drop it with + `PopRootEpoch` (or remove the `rootEpochs` entry) and rotate again; nodes + that imported the dropped N must be onboarded again. On chain, the policy + rows prepared for the dropped N do not apply to the new one; approve them + again before activating it. +6. Boot CVMs on an OS that supports root epochs. At boot they re-key their data + disk from the epoch recorded in its LUKS header to the current one. If an + encrypted environment still uses an older epoch, the CVM re-encrypts it during + boot and the VMM replaces the ciphertext only if it was not changed concurrently. + See [SealedEnv](./sealed-env.md) for the new format, file precedence, and rollback requirements. + +Each `authorizeKms` or `authorizeApp` result carries the current epoch and its chain hash, +and the latest registered epoch (`latestRootEpoch`, `latestRootEpochHash`), which may be +pending activation, including when authorization is denied. KMS verifies these against its keyring; +there is no cached serving epoch. An optional `requestedRootEpoch` selects historical +keys, and omission selects the returned current epoch. Requests for future +epochs are denied. No registered epoch returns a rejection with +`rejectionReason: "Root epoch not registered"` and a zero hash. + +RPC certificates still require a listener restart when their root CA changes; +callers retry after the certificate is refreshed. Register epoch 0 before normal +key release. Bootstrap keeps using scoped KMS authorization so it can generate +the first roots before registration. See the +[authorization protocol](../dstack/kms/README.md#authorization-protocol). --- @@ -539,7 +636,7 @@ After setup, deploy apps via the VMM dashboard or CLI. ### Register App Before deploying, register your app in your auth server: -- For auth-simple: See [auth-simple Operations Guide](./auth-simple-operations.md#adding-an-app) +- For dstack-auth: See [dstack-auth Operations Guide](./dstack-auth-operations.md#adding-an-app) - For auth-eth: See [On-Chain Governance](./onchain-governance.md#register-apps-on-chain) ### Deploy via UI diff --git a/docs/dstack-auth-operations.md b/docs/dstack-auth-operations.md new file mode 100644 index 000000000..54b203580 --- /dev/null +++ b/docs/dstack-auth-operations.md @@ -0,0 +1,470 @@ +# dstack-auth Operations Guide + +> **This guide is for self-hosted deployments** on your own TDX hardware. For cloud deployments, see [Quickstart](./quickstart.md). + +`dstack-auth` is the file-based KMS authorization webhook. It allowlists OS +images, KMS instances and apps in one JSON file. For on-chain governance, use +[auth-eth](../dstack/kms/auth-eth/) instead +(see [On-Chain Governance](./onchain-governance.md)). + +This guide covers running `dstack-auth` and managing apps and devices with it. +For initial deployment setup, see [Deployment Guide](./deployment.md). + +## Overview + +The allowlist file whitelists: +- **OS images** - Which guest OS versions can boot +- **KMS nodes** - Which KMS instances can bootstrap, serve and onboard (`mrAggregated`, devices) +- **Apps** - Which applications can boot (appId, composeHash, devices) +- **Platform state (opt-in)** - Which TCB statuses and security advisories are acceptable + +The file is re-read on every request, so changes take effect immediately without restart. +A missing or invalid file denies every request. + +--- + +## Running dstack-auth + +### Installed by dstackup + +`dstackup` installs `dstack-auth` as the `dstack-auth.service` systemd unit, +listening on `127.0.0.1:8001`, and writes the allowlist to +`auth-allowlist.json` in its config directory. `dstack run` adds the compose +hash of each app it deploys. Edit that file to change the policy. + +### Running the binary yourself + +Build it from the repository: + +```bash +cd dstack +cargo build --release -p dstack-auth +./target/release/dstack-auth --config /path/to/auth-config.json --port 3001 +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--config` | `/var/lib/dstack/auth-allowlist.json` | Allowlist JSON file | +| `--address` | `127.0.0.1` | Bind address | +| `--port` | `8001` | Bind port | + +A KMS CVM on QEMU user-mode networking reaches the host loopback at `10.0.2.2`, +so the default address is enough for a KMS on the same host: point the KMS at +`http://10.0.2.2:`. Bind `--address 0.0.0.0` only when the KMS reaches the +host over a real network, and restrict access to that port. + +Point the KMS at it in `kms.toml`: + +```toml +[core.auth_api] +type = "webhook" + +[core.auth_api.webhook] +url = "http://10.0.2.2:3001" +``` + +--- + +## Config File Structure + +```json +{ + "osImages": ["0x..."], + "gatewayAppId": "0x...", + "kmsContractAddr": "0x", + "kms": { + "mrAggregated": ["0x..."], + "devices": ["0x..."], + "allowAnyDevice": true + }, + "apps": { + "0x": { + "composeHashes": ["0x..."], + "devices": ["0x..."], + "allowAnyDevice": true + } + }, + "rootEpochs": [{"caPubkey": "0x...", "k256Pubkey": "0x..."}], + "currentRootEpoch": 0 +} +``` + +> **Note:** `osImages` is always required for apps. For KMS authorization, you must also populate `kms.mrAggregated`; if it is left empty, dstack-auth denies all KMS boots. Add `gatewayAppId` after deploying the Gateway. Add `apps` entries as you deploy applications. + +Hex values are compared case-insensitively, with or without a `0x` prefix. + +### Configuration Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `osImages` | Yes | Allowed OS image hashes (from `digest.txt`). Empty denies every app. | +| `gatewayAppId` | No | Gateway app ID (add after Gateway deployment). Several gateways: join their IDs with commas. | +| `kmsContractAddr` | For `/authorize*` | The attested app ID of the KMS. Every operation except `kms_bootstrap` is denied while it is unset, and a caller is accepted only on the endpoint matching its app ID (`/authorizeKms` for this ID, `/authorizeApp` for any other). Reported by `GET /`. | +| `chainId` | No | Informational, reported by `GET /` (default `0`). | +| `appImplementation` | No | Informational, reported by `GET /` as `appImplementation` and `appAuthImplementation`. | +| `allowedTcbStatuses` | No | When set, deny unless the verifier-derived `tcbStatus` is listed; `[]` denies everything. **Absent: not checked.** See [TCB and advisory policy](#tcb-and-advisory-policy). | +| `allowedAdvisoryIds` | No | When set, deny any reported advisory ID that is not listed; `[]` denies any advisory. **Absent: not checked.** | +| `kms.mrAggregated` | Yes | Allowed KMS early aggregate MR values (boot-mr-done). Empty denies every KMS. | +| `kms.devices` | No | Allowed KMS device IDs. Empty denies all unless `kms.allowAnyDevice` is true. | +| `kms.allowAnyDevice` | No | If true, skip the device ID check for KMS | +| `apps..composeHashes` | No | Allowed compose hashes for this app | +| `apps..devices` | No | Allowed device IDs for this app. Empty denies all unless `allowAnyDevice` is true. | +| `apps..allowAnyDevice` | No | If true, skip the device ID check for this app | +| `apps..currentAppEpoch` | No | Current app key epoch (default `0`), served by `GET /app/`. A non-zero epoch denies the legacy `/bootAuth/app` route. | +| `rootEpochs` | No | KMS root public keys of each root epoch, indexed by epoch, `[{"caPubkey": "0x...", "k256Pubkey": "0x..."}]`. Epoch 0 is the original roots. Append only. `GET /` reports the current epoch as `rootEpoch` with its chain hash as `rootEpochHash`; the last entry is reported as `latestRootEpoch`, and entries beyond `currentRootEpoch` are pending activation. | +| `currentRootEpoch` | No | Root epoch KMS instances serve (default 0). Key-issuing operations (`kms_serve`, `app_get_key`, `app_get_cert`) may request up to this epoch; KMS key handover (`kms_export_keys`, `kms_import_keys`) may request any registered epoch, so the other KMS nodes can onboard from the rotated one while the new epoch is pending. Raise it only once every KMS node holds that epoch. | + +### Validation Logic + +KMS (`/authorizeKms`, `/bootAuth/kms`): + +1. `tcbStatus` must be in `allowedTcbStatuses`, if set +2. Every `advisoryIds` entry must be in `allowedAdvisoryIds`, if set +3. `mrAggregated` must be in `kms.mrAggregated` +4. `deviceId` must be in `kms.devices` unless `kms.allowAnyDevice` is true + +Apps (`/authorizeApp`, `/bootAuth/app`): + +1. `tcbStatus` must be in `allowedTcbStatuses`, if set +2. Every `advisoryIds` entry must be in `allowedAdvisoryIds`, if set +3. `osImageHash` must be in `osImages` +4. `appId` must exist in `apps` +5. `composeHash` must be in the app's `composeHashes` +6. `deviceId` must be in the app's `devices` unless `allowAnyDevice` is true + +KMS requests are not matched against `osImages`: `mrAggregated` already pins +the OS image measurements. The `/authorize*` endpoints and legacy `/bootAuth/*` +routes also enforce root epochs, see the +[KMS authorization protocol](../dstack/kms/README.md#authorization-protocol). + +--- + +## TCB and Advisory Policy + +By default `dstack-auth` does not check the platform TCB status or security +advisories. In the single-operator model the operator controls and trusts the +host, and real TDX hosts routinely report a non-`UpToDate` TCB when microcode or +the TDX module lags behind, so a hard TCB gate there adds friction without +adding trust. + +Deployments where the host operator is not the party relying on the policy +(a KMS serving other users, AWS, SEV-SNP) should enforce it explicitly: + +```json +{ + "allowedTcbStatuses": ["UpToDate"], + "allowedAdvisoryIds": [], + "osImages": ["0x..."], + "kms": { "mrAggregated": ["0x..."], "allowAnyDevice": true }, + "apps": {} +} +``` + +With this, any status other than `UpToDate` and any advisory ID denies. To +accept a known state temporarily, list it explicitly, for example +`"allowedTcbStatuses": ["UpToDate", "SWHardeningNeeded"]` or +`"allowedAdvisoryIds": ["INTEL-SA-00837"]`. + +AWS NitroTPM has no TDX/SNP-style TCB advisory surface, so the KMS normalizes a +verified NitroTPM attestation to `tcbStatus: "UpToDate"` before calling the +webhook; the policy above works unchanged there. + +--- + +## Migrating from auth-simple + +`auth-simple` (the Bun/TypeScript server in `dstack/kms/auth-simple`) has been +removed. `dstack-auth` reads the same config file format, so an existing +`auth-config.json` loads unchanged. To switch: + +1. Keep auth-simple's strict platform defaults by adding them explicitly, unless + your config already sets them: + + ```json + "allowedTcbStatuses": ["UpToDate"], + "allowedAdvisoryIds": [] + ``` + + Without these fields, dstack-auth accepts any TCB status and advisory. +2. Set `kmsContractAddr` to the attested KMS app ID if it is not set. auth-simple + defaulted it to the zero address and did not check it; dstack-auth denies + every `/authorize*` operation except `kms_bootstrap` without it. +3. Replace the start command. `PORT` and `AUTH_CONFIG_PATH` become flags, and + auth-simple listened on all interfaces while dstack-auth binds to loopback + by default: + + ```bash + # before + PORT=3001 AUTH_CONFIG_PATH=/path/to/auth-config.json bun run start + # after + dstack-auth --config /path/to/auth-config.json --port 3001 + ``` + + Add `--address 0.0.0.0` if the KMS reaches the webhook over a real network + rather than `10.0.2.2`. + +Other differences: KMS requests are no longer matched against `osImages` (see +[Validation Logic](#validation-logic)), `GET /` no longer reports `configPath`, +and rejection reasons are lowercase (for example `root epoch not registered`). + +--- + +## Adding an App + +### Step 1: Generate App ID + +App IDs are typically the contract address (for on-chain) or a unique identifier you choose. + +For dstack-auth, you can use any unique hex string (40 characters / 20 bytes): + +```bash +# Generate a random app ID +openssl rand -hex 20 +# Output: 7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b +``` + +### Step 2: Get Compose Hash + +The compose hash is computed from the normalized docker-compose file. The VMM displays it when deploying: + +``` +Docker compose file: +... +Compose hash: 0x700a50336df7c07c82457b116e144f526c29f6d8... +``` + +Or query from a running CVM: + +```bash +curl -s --unix-socket /var/run/dstack.sock http://localhost/Info | \ + jq -r '"0x" + (.tcb_info | fromjson | .compose_hash)' +``` + +> **Note:** The VMM normalizes YAML to JSON before hashing. For exact hash, use the value shown during deployment. + +### Step 3: Add to Config + +Edit your allowlist file: + +```json +{ + "apps": { + "0x7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b": { + "composeHashes": [ + "0x700a50336df7c07c82457b116e144f526c29f6d8..." + ], + "devices": [], + "allowAnyDevice": true + } + } +} +``` + +### Step 4: Deploy via VMM + +Use the App ID when deploying through VMM: + +```bash +./vmm-cli.py deploy \ + --app-id 7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b \ + --compose docker-compose.yaml \ + ... +``` + +--- + +## Updating an App + +When you change your docker-compose.yaml, a new compose hash is generated. + +### Add New Hash (Keep Old Running) + +Add the new hash to the `composeHashes` array: + +```json +{ + "apps": { + "0x": { + "composeHashes": [ + "0x", + "0x" + ] + } + } +} +``` + +### Replace Hash (Force Upgrade) + +Remove the old hash to prevent old versions from booting: + +```json +{ + "apps": { + "0x": { + "composeHashes": [ + "0x" + ] + } + } +} +``` + +--- + +## Device Management + +Devices are identified by their TDX device ID (hardware-specific). + +### Get Device ID + +The `deviceId` is sent by the booting app/KMS in its auth request. dstack-auth +logs it with each decision (`journalctl -u dstack-auth` when installed by +`dstackup`): + +``` +authorize AppGetKey app=7a3b... compose=700a... mr=... os=0b32... device=e5a0... tcb=UpToDate advisories=[] -> authorized=true () +``` + +> **Tip:** Use `allowAnyDevice: true` initially, then restrict to specific devices after capturing IDs from logs. + +### Restrict App to Specific Devices + +```json +{ + "apps": { + "0x": { + "composeHashes": ["0x..."], + "devices": [ + "0xe5a0c70bb6503de2d31c11d85914fe3776ed5b33a078ed856327c371a60fe0fd" + ], + "allowAnyDevice": false + } + } +} +``` + +### Allow Any Device + +For initial testing or when device restriction isn't needed: + +```json +{ + "apps": { + "0x": { + "allowAnyDevice": true + } + } +} +``` + +--- + +## Removing an App + +Delete the app entry from the `apps` object. Running instances continue until +restarted; new key requests are rejected. + +--- + +## Setting the Gateway App (After Gateway Deployment) + +The `gatewayAppId` field is **optional** during initial KMS deployment. Add it after deploying the Gateway. + +The Gateway is a special app that routes traffic to other apps. Once deployed, add its App ID to your config: + +```json +{ + "gatewayAppId": "0x75537828f2ce51be7289709686A69CbFDbB714F1", + "apps": { + "0x75537828f2ce51be7289709686A69CbFDbB714F1": { + "composeHashes": ["0x..."], + "allowAnyDevice": true + } + } +} +``` + +The `gatewayAppId` is returned in authorization responses and used by KMS for key derivation. + +To trust several gateways, join their App IDs with commas (`"0xaaa...,0xbbb..."`). CVMs accept a gateway whose App ID is contained in this value. + +--- + +## KMS Onboarding (Multi-Node) + +To allow additional KMS nodes to onboard (receive root keys from the primary KMS), whitelist their `mrAggregated` value. + +### Get mrAggregated + +1. **Initial setup**: capture the first KMS measurement with `Onboard.GetAttestationInfo` and add it to `kms.mrAggregated` before bootstrap. An empty array denies all KMS boots. +2. **From dstack-auth logs**: every KMS request is logged with its measurement: + ``` + authorize KmsBootstrap app=... compose=... mr= os=... device=... -> authorized=false (kms mrAggregated not allowed) + ``` + +### Add to Config + +```json +{ + "kms": { + "mrAggregated": ["0x"], + "allowAnyDevice": true + } +} +``` + +> **Note:** All KMS nodes using the same OS image and compose will have the same mrAggregated, so you only need to capture it once. + +--- + +## Verification + +### Check Config is Valid + +```bash +curl -s http://127.0.0.1:3001/ | jq +``` + +Returns `status`, `gatewayAppId`, `kmsContractAddr` and, once `rootEpochs` is +configured, `rootEpoch` and `rootEpochHash`. An invalid `rootEpochs` +configuration returns HTTP 503. + +### Test Boot Authorization + +```bash +curl -s -X POST http://127.0.0.1:3001/bootAuth/app \ + -H "Content-Type: application/json" \ + -d '{ + "appId": "0x", + "composeHash": "0x", + "osImageHash": "0x", + "deviceId": "0x", + "tcbStatus": "UpToDate", + "advisoryIds": [] + }' | jq +``` + +Expected responses: +- `{"isAllowed": true, ...}` - App is authorized +- `{"isAllowed": false, "reason": "app not registered", ...}` - App ID not in config +- `{"isAllowed": false, "reason": "compose hash not allowed", ...}` - Hash not whitelisted +- `{"isAllowed": false, "reason": "tcb status not allowed", ...}` - `tcbStatus` not in `allowedTcbStatuses` + +--- + +## API Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /` | Health check and info (`kmsContractAddr`, `gatewayAppId`, `chainId`, `appImplementation`, `rootEpoch`, `rootEpochHash`) | +| `POST /authorizeKms`, `POST /authorizeApp` | Epoch-aware authorization; return an `AuthorizationResult`. See the [KMS authorization protocol](../dstack/kms/README.md#authorization-protocol). | +| `GET /app/` | `{"currentAppEpoch": N}`; 404 for an app that is not configured | +| `POST /bootAuth/kms`, `POST /bootAuth/app` | Legacy boot authorization for older KMS binaries; return `{isAllowed, reason, gatewayAppId}` | + +--- + +## See Also + +- [Deployment Guide](./deployment.md) - Initial setup +- [On-Chain Governance](./onchain-governance.md) - Smart contract-based alternative diff --git a/docs/formal/key-rotation/DiskMigration.cfg b/docs/formal/key-rotation/DiskMigration.cfg new file mode 100644 index 000000000..5ef9caddc --- /dev/null +++ b/docs/formal/key-rotation/DiskMigration.cfg @@ -0,0 +1,12 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + MaxEpoch = 3 +INVARIANTS + TypeOK + NoBootFailure + DataRecoverable + NoFormatOverData + RunningIsMigrated diff --git a/docs/formal/key-rotation/DiskMigration.tla b/docs/formal/key-rotation/DiskMigration.tla new file mode 100644 index 000000000..23b3cf56f --- /dev/null +++ b/docs/formal/key-rotation/DiskMigration.tla @@ -0,0 +1,186 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +---------------------------- MODULE DiskMigration ---------------------------- +(***************************************************************************) +(* Data disk setup and LUKS key migration across KMS key epochs, as *) +(* implemented by dstack-util (system_setup.rs: setup_data_disk, *) +(* luks_setup, open_encrypted_volume; system_setup/luks.rs: *) +(* sync_luks_epochs). *) +(* *) +(* A key epoch abstracts the (root, app) epoch pair: the disk key differs *) +(* per epoch and the KMS serves every epoch up to the current one. A boot *) +(* fetches its keys once, so it targets the epoch current at its start; *) +(* the KMS may rotate at any time. *) +(* *) +(* The header has two keyslots and a token recording a keyslot and its *) +(* epoch. Each cryptsetup command on the device (luksFormat, luksAddKey, *) +(* luksKillSlot, token import, config --label) is atomic: it writes a new *) +(* keyslot area before the header copies, each synced, and an outdated *) +(* copy is repaired from the newer one on the next load. A crash can stop *) +(* the boot between any two commands. *) +(***************************************************************************) +EXTENDS Integers + +CONSTANTS MaxEpoch + +Slots == {0, 1} +NoKey == -1 +NoToken == [slot |-> -1, epoch |-> -1] +Tokens == {NoToken} \cup [slot: Slots, epoch: 0..MaxEpoch] + +VARIABLES + current, \* current key epoch on chain + target, \* epoch whose keys this boot fetched + luks, \* the device holds a LUKS header + slots, \* keyslots: Slots -> key epoch or NoKey + token, \* the dstack-kms token + init, \* the header carries the initializing label + pc, \* boot step + oldSlot, \* sync_luks_epochs: the recorded keyslot being migrated from + slot, \* sync_luks_epochs: the keyslot holding the target key + hasData \* applications have written data to the volume + +vars == <> +boot == <> + +Keyslots == {s \in Slots: slots[s] # NoKey} +StoredEpoch == IF token = NoToken THEN 0 ELSE token.epoch +\* unlocked_keyslot: the first keyslot the target key opens. +Unlocked == {s \in Slots: slots[s] = target} +First(S) == IF 0 \in S THEN 0 ELSE 1 + +Init == + /\ current \in 0..MaxEpoch + /\ target = 0 + /\ luks = FALSE + /\ slots = [s \in Slots |-> NoKey] + /\ token = NoToken + /\ init = FALSE + /\ pc = "off" + /\ oldSlot = 0 /\ slot = 0 + /\ hasData = FALSE + +Rotate == + /\ current < MaxEpoch + /\ current' = current + 1 + /\ UNCHANGED <> + +\* setup_data_disk: an initialized disk is opened; an interrupted +\* initialization or a blank disk is formatted. +Boot == + /\ pc = "off" + /\ target' = current + /\ pc' = IF luks /\ ~init THEN "sync" ELSE "format" + /\ UNCHANGED <> + +\* luks_setup: luksFormat with the initializing label, key in keyslot 0. +Format == + /\ pc = "format" + /\ luks' = TRUE /\ init' = TRUE + /\ slots' = [s \in Slots |-> IF s = 0 THEN target ELSE NoKey] + /\ token' = NoToken + /\ pc' = IF target > 0 THEN "formatToken" ELSE "sync" + /\ UNCHANGED <> + +FormatToken == + /\ pc = "formatToken" + /\ token' = [slot |-> 0, epoch |-> target] + /\ pc' = "sync" + /\ UNCHANGED <> + +\* sync_luks_epochs, entry. +Sync == + /\ pc = "sync" + /\ LET old == IF token = NoToken THEN 0 ELSE token.slot IN + CASE StoredEpoch > target -> pc' = "fail" /\ UNCHANGED <> + [] Unlocked # {} -> pc' = "record" /\ slot' = First(Unlocked) /\ UNCHANGED oldSlot + [] StoredEpoch = target -> pc' = "fail" /\ UNCHANGED <> + \* The recorded keyslot must exist and open with the recorded epoch. + [] old \notin Keyslots \/ slots[old] # StoredEpoch -> + pc' = "fail" /\ UNCHANGED <> + [] OTHER -> pc' = "dropStale" /\ oldSlot' = old /\ UNCHANGED slot + /\ UNCHANGED <> + +\* Drop the keyslot whose epochs were never recorded, to make room. +DropStale == + /\ pc = "dropStale" + /\ slots' = [s \in Slots |-> IF s = oldSlot THEN slots[s] ELSE NoKey] + /\ pc' = "addKey" + /\ UNCHANGED <> + +AddKey == + /\ pc = "addKey" + /\ slots' = [slots EXCEPT ![1 - oldSlot] = target] + /\ slot' = 1 - oldSlot + /\ pc' = "record" + /\ UNCHANGED <> + +\* Record the target epoch for the keyslot that holds it; a legacy disk at +\* epoch 0 keeps no token. +Record == + /\ pc = "record" + /\ LET legacy == target = 0 /\ token = NoToken IN + token' = IF legacy THEN token ELSE [slot |-> slot, epoch |-> target] + /\ pc' = "killOld" + /\ UNCHANGED <> + +KillOld == + /\ pc = "killOld" + /\ slots' = [s \in Slots |-> IF s = slot THEN slots[s] ELSE NoKey] + /\ pc' = "open" + /\ UNCHANGED <> + +\* luksOpen with the target key, from the header loaded after the changes. +Open == + /\ pc = "open" + /\ pc' = CASE slots[slot] # target -> "fail" + [] init -> "mkfs" + [] OTHER -> "running" + /\ UNCHANGED <> + +\* mkfs on the volume, then cryptsetup config --label= on the device. +Mkfs == + /\ pc = "mkfs" + /\ init' = FALSE + /\ pc' = "running" + /\ UNCHANGED <> + +WriteData == + /\ pc = "running" /\ ~hasData + /\ hasData' = TRUE + /\ UNCHANGED <> + +\* Shutdown, a crash at any step, or a restart after a failed boot. +Stop == + /\ pc # "off" + /\ pc' = "off" + /\ UNCHANGED <> + +Next == + \/ Rotate \/ Boot \/ Format \/ FormatToken \/ Sync \/ DropStale \/ AddKey + \/ Record \/ KillOld \/ Open \/ Mkfs \/ WriteData \/ Stop + +Spec == Init /\ [][Next]_vars + +----------------------------------------------------------------------------- +TypeOK == + /\ current \in 0..MaxEpoch /\ target \in 0..current + /\ slots \in [Slots -> {NoKey} \cup (0..MaxEpoch)] + /\ token \in Tokens + +\* A boot never fails: a failed boot fails again on every retry. +NoBootFailure == pc # "fail" + +\* Data, once written, stays under a key the KMS still serves. +DataRecoverable == hasData => (luks /\ \E s \in Keyslots: slots[s] <= current) + +\* Reinitialization never runs over data. +NoFormatOverData == pc = "format" => ~hasData + +\* A running disk is keyed by its boot's epoch alone, and records it. +RunningIsMigrated == + pc = "running" => + /\ Keyslots = {slot} /\ slots[slot] = target + /\ IF token = NoToken THEN target = 0 ELSE token = [slot |-> slot, epoch |-> target] +============================================================================= diff --git a/docs/formal/key-rotation/EnvUpdate.cfg b/docs/formal/key-rotation/EnvUpdate.cfg new file mode 100644 index 000000000..bf77ce3ce --- /dev/null +++ b/docs/formal/key-rotation/EnvUpdate.cfg @@ -0,0 +1,12 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + MaxEpoch = 2 + MaxOwnerWrites = 2 + Lossy = TRUE + FailClosed = TRUE +INVARIANTS + OwnerUpdateKept + NoAppEnv diff --git a/docs/formal/key-rotation/EnvUpdate.tla b/docs/formal/key-rotation/EnvUpdate.tla new file mode 100644 index 000000000..69a0cd06c --- /dev/null +++ b/docs/formal/key-rotation/EnvUpdate.tla @@ -0,0 +1,112 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +------------------------------ MODULE EnvUpdate ------------------------------ +(***************************************************************************) +(* Environment re-encryption at boot (dstack-util unseal_env_vars) and the *) +(* VMM's once-per-boot update window (vmm app.rs update_sealed_env and the *) +(* boot.progress handler). *) +(* *) +(* An env value records which owner write it carries, the key epoch it is *) +(* encrypted to, and who stored it. The VMM opens the window when it *) +(* launches the VM, recording the stored env; the first update closes it *) +(* and is applied only over that env; boot progress reaching application *) +(* code also closes it. Owner updates (UpdateVm) can happen at any time. *) +(* *) +(* Lossy lets either host notification, or the answer to the guest's *) +(* update, be lost. With FailClosed (#1546) the guest starts applications *) +(* only once the host answered its update, retrying or stopping the boot *) +(* otherwise; without it, an unanswered update is only logged. *) +(***************************************************************************) +EXTENDS Naturals + +CONSTANTS MaxEpoch, MaxOwnerWrites, Lossy, FailClosed + +None == [owner |-> 0, epoch |-> 0, by |-> "none"] + +VARIABLES + current, \* current key epoch + store, \* env stored by the VMM + ownerWrites, \* number of owner updates so far + window, \* env recorded when the window opened, or None when closed + vm, \* guest step + seen \* env the guest read at boot + +vars == <> + +Init == + /\ current = 0 + /\ store = [owner |-> 0, epoch |-> 0, by |-> "owner"] + /\ ownerWrites = 0 + /\ window = None + /\ vm = "off" + /\ seen = None + +\* The VMM applies an update from the guest: once per boot, only over the +\* env the boot started with. +Deliver(env, empty) == + /\ window' = None + /\ store' = IF window # None /\ ~empty /\ store = window THEN env ELSE store + +Launch == + /\ vm = "off" + /\ vm' = "booting" + /\ window' = store + /\ seen' = store + /\ UNCHANGED <> + +\* Re-encrypt to the current epoch if needed and send it, empty otherwise. +\* An unanswered update, whether lost on the way or after the VMM applied +\* it, is retried under FailClosed; the boot may also stop (Stop). +GuestPush == + /\ vm = "booting" + /\ LET env == [owner |-> seen.owner, epoch |-> current, by |-> "guest"] + empty == seen.epoch = current + unanswered == IF FailClosed THEN "booting" ELSE "pushed" + IN \/ Deliver(env, empty) /\ vm' = "pushed" + \/ Lossy /\ Deliver(env, empty) /\ vm' = unanswered + \/ Lossy /\ UNCHANGED <> /\ vm' = unanswered + /\ UNCHANGED <> + +\* boot.progress "init-script": application code starts after this. +Progress == + /\ vm = "pushed" + /\ vm' = "app" + /\ \/ window' = None + \/ Lossy /\ UNCHANGED window + /\ UNCHANGED <> + +\* Application code calls the host API with an env of its choosing. +AppUpdate == + /\ vm = "app" + /\ Deliver([owner |-> seen.owner, epoch |-> current, by |-> "app"], FALSE) + /\ UNCHANGED <> + +OwnerUpdate == + /\ ownerWrites < MaxOwnerWrites + /\ ownerWrites' = ownerWrites + 1 + /\ store' = [owner |-> ownerWrites + 1, epoch |-> current, by |-> "owner"] + /\ UNCHANGED <> + +Stop == + /\ vm # "off" + /\ vm' = "off" + /\ window' = None + /\ UNCHANGED <> + +Rotate == + /\ vm = "off" /\ current < MaxEpoch + /\ current' = current + 1 + /\ UNCHANGED <> + +Next == Launch \/ GuestPush \/ Progress \/ AppUpdate \/ OwnerUpdate \/ Stop \/ Rotate + +Spec == Init /\ [][Next]_vars + +----------------------------------------------------------------------------- +\* The latest owner update is never replaced by an older one. +OwnerUpdateKept == store.owner = ownerWrites + +\* Application code never replaces the env. +NoAppEnv == store.by # "app" +============================================================================= diff --git a/docs/formal/key-rotation/EnvUpdateLossy.cfg b/docs/formal/key-rotation/EnvUpdateLossy.cfg new file mode 100644 index 000000000..679e7c643 --- /dev/null +++ b/docs/formal/key-rotation/EnvUpdateLossy.cfg @@ -0,0 +1,12 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + MaxEpoch = 2 + MaxOwnerWrites = 2 + Lossy = TRUE + FailClosed = FALSE +INVARIANTS + OwnerUpdateKept + NoAppEnv diff --git a/docs/formal/key-rotation/EpochPolicy.cfg b/docs/formal/key-rotation/EpochPolicy.cfg new file mode 100644 index 000000000..18cb9cfe4 --- /dev/null +++ b/docs/formal/key-rotation/EpochPolicy.cfg @@ -0,0 +1,20 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + Values = {v1, v2} + Keys = {1, 2} + MaxEpoch = 2 + MaxRegistrations = 5 + KeyByChainHash = FALSE + Composes = {c1} + MaxAppEpoch = 2 + LegacyIgnoresAppEpoch = FALSE +CHECK_DEADLOCK FALSE +INVARIANTS + TypeOK + RevocationIsTotal + AppKeyMatchesSelectedRow +PROPERTIES + RegistrationStartsEmpty diff --git a/docs/formal/key-rotation/EpochPolicy.tla b/docs/formal/key-rotation/EpochPolicy.tla new file mode 100644 index 000000000..e47a305a0 --- /dev/null +++ b/docs/formal/key-rotation/EpochPolicy.tla @@ -0,0 +1,166 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +----------------------------- MODULE EpochPolicy ----------------------------- +(***************************************************************************) +(* Epoch-scoped policy of DstackKms and DstackApp, and key release decided *) +(* on the selected rows. *) +(* *) +(* Root side (DstackKms): one row of allowed values (OS images; KMS MRs *) +(* and devices alike) per root epoch, written by *AtEpoch(s) over a range *) +(* of registered rows. A pending epoch can be popped and registered again, *) +(* with the same or other keys. Rows are keyed by registration *) +(* (RootEpoch.registration); KeyByChainHash = TRUE models the earlier *) +(* contract, which keyed them by chain hash. *) +(* *) +(* App side (DstackApp): one row of allowed compose hashes per app epoch, *) +(* written over a range up to the next epoch. An app policy predating app *) +(* epochs (v0.5.x, v0.6.0) has only its flat mapping, which is row 0, and *) +(* epoch-less setters writing it; DstackKms authorizes it through *) +(* isAppAllowed for app epoch 0 only. Its owner may upgrade it at any *) +(* time, keeping the mapping as row 0. LegacyIgnoresAppEpoch = TRUE drops *) +(* the app epoch 0 check of that fallback. *) +(***************************************************************************) +EXTENDS Naturals, Sequences + +CONSTANTS Values, Keys, MaxEpoch, MaxRegistrations, KeyByChainHash, + Composes, MaxAppEpoch, LegacyIgnoresAppEpoch + +VARIABLES + keys, \* keys of the registered root epochs, epoch e at index e + 1 + regs, \* registration of each registered root epoch + nextReg, \* registrations so far + cur, \* current root epoch + rows, \* <> pairs allowed + revoked, \* values removed from every registered row, not added since + legacy, \* the app policy predates app epochs + appCur, \* current app epoch + appRows, \* <> pairs allowed + appRevoked, \* compose hashes removed from every app row, not added since + leaked, \* a key released for a revoked value or compose hash + mismatched \* an app key released for an app epoch whose row disallows it + +vars == <> +rootVars == <> +appVars == <> +ghostVars == <> + +Latest == Len(keys) - 1 +RowKey(ks, rs, e) == IF KeyByChainHash THEN SubSeq(ks, 1, e + 1) ELSE <> +Row(e) == {v \in Values: <> \in rows} +AppRow(a) == {c \in Composes: <> \in appRows} + +Init == + /\ \E k \in Keys: keys = <> + /\ regs = <<0>> /\ nextReg = 1 /\ cur = 0 + /\ rows = {} /\ revoked = {} + /\ legacy \in BOOLEAN /\ appCur = 0 /\ appRows = {} /\ appRevoked = {} + /\ leaked = {} /\ mismatched = FALSE + +\* ---- DstackKms ---- +Register(k) == + /\ Latest < MaxEpoch /\ nextReg < MaxRegistrations + /\ keys' = Append(keys, k) + /\ regs' = Append(regs, nextReg) + /\ nextReg' = nextReg + 1 + /\ UNCHANGED <> + +Activate == + /\ cur < Latest + /\ cur' = cur + 1 + /\ UNCHANGED <> + +Pop == + /\ Latest > cur + /\ keys' = SubSeq(keys, 1, Latest) + /\ regs' = SubSeq(regs, 1, Latest) + /\ UNCHANGED <> + +\* add*AtEpochs / remove*AtEpochs(from, to, v); *AtEpoch is from = to. +Write(from, to, v, allowed) == + /\ from <= to /\ to <= Latest + /\ LET written == {<>: e \in from..to} + IN rows' = IF allowed THEN rows \cup written ELSE rows \ written + /\ revoked' = IF allowed THEN revoked \ {v} + ELSE IF from = 0 /\ to = Latest THEN revoked \cup {v} + ELSE revoked + /\ UNCHANGED <> + +\* ---- DstackApp ---- +AppWrite(from, to, c, allowed) == + /\ ~legacy /\ from <= to /\ to <= appCur + 1 + /\ LET written == {<>: a \in from..to} + IN appRows' = IF allowed THEN appRows \cup written ELSE appRows \ written + /\ appRevoked' = IF allowed THEN appRevoked \ {c} + ELSE IF from = 0 /\ to = appCur + 1 THEN appRevoked \cup {c} + ELSE appRevoked + /\ UNCHANGED <> + +\* The epoch-less setters of a legacy policy write its only row. +LegacyWrite(c, allowed) == + /\ legacy + /\ appRows' = IF allowed THEN appRows \cup {<<0, c>>} ELSE appRows \ {<<0, c>>} + /\ appRevoked' = IF allowed THEN appRevoked \ {c} ELSE appRevoked \cup {c} + /\ UNCHANGED <> + +Upgrade == + /\ legacy /\ legacy' = FALSE + /\ UNCHANGED <> + +Advance == + /\ ~legacy /\ appCur < MaxAppEpoch + /\ appCur' = appCur + 1 + /\ UNCHANGED <> + +\* ---- Key release ---- +\* kms_serve releases keys of the current epoch at most; the key handover +\* may carry a registered pending one. +KmsRelease(op, e, v) == + /\ e <= (IF op = "kms_export_keys" THEN Latest ELSE cur) + /\ v \in Row(e) + /\ leaked' = IF v \in revoked THEN leaked \cup {<<"root", e, v>>} ELSE leaked + /\ UNCHANGED <> + +\* app_get_key for root epoch e <= current and app epoch a. A legacy policy +\* is asked through isAppAllowed, which reads its only row. +AppRelease(e, a, v, c) == + /\ e <= cur /\ v \in Row(e) + /\ IF legacy + THEN (a = 0 \/ LegacyIgnoresAppEpoch) /\ c \in AppRow(0) + ELSE a <= appCur /\ c \in AppRow(a) + /\ leaked' = leaked \cup (IF v \in revoked THEN {<<"root", e, v>>} ELSE {}) + \cup (IF c \in appRevoked THEN {<<"app", a, c>>} ELSE {}) + /\ mismatched' = (mismatched \/ c \notin AppRow(a)) + /\ UNCHANGED <> + +Next == + \/ Activate \/ Pop \/ Upgrade \/ Advance + \/ \E k \in Keys: Register(k) + \/ \E from, to \in 0..MaxEpoch, v \in Values, allowed \in BOOLEAN: Write(from, to, v, allowed) + \/ \E from, to \in 0..MaxAppEpoch + 1, c \in Composes, allowed \in BOOLEAN: AppWrite(from, to, c, allowed) + \/ \E c \in Composes, allowed \in BOOLEAN: LegacyWrite(c, allowed) + \/ \E op \in {"kms_serve", "kms_export_keys"}, e \in 0..MaxEpoch, v \in Values: KmsRelease(op, e, v) + \/ \E e \in 0..MaxEpoch, a \in 0..MaxAppEpoch, v \in Values, c \in Composes: AppRelease(e, a, v, c) + +Spec == Init /\ [][Next]_vars + +----------------------------------------------------------------------------- +TypeOK == cur <= Latest /\ Latest <= MaxEpoch /\ Len(regs) = Len(keys) /\ appCur <= MaxAppEpoch + /\ (legacy => appCur = 0) + +\* A value or compose hash removed from every row gets no key of any root or +\* app epoch, for any operation, until it is added again; including through a +\* root epoch popped and registered again, and across the upgrade of a legacy +\* app policy. +RevocationIsTotal == leaked = {} + +\* An app key of app epoch a is released only when row a allows the compose +\* hash; for a legacy policy, only app epoch 0, its flat mapping. +AppKeyMatchesSelectedRow == ~mismatched + +\* A root epoch registration starts with an empty row, whatever its keys. +RegistrationStartsEmpty == + [][Len(keys') > Len(keys) => + \A v \in Values: <> \notin rows']_vars +============================================================================= diff --git a/docs/formal/key-rotation/EpochPolicyChainHash.cfg b/docs/formal/key-rotation/EpochPolicyChainHash.cfg new file mode 100644 index 000000000..58751f4fb --- /dev/null +++ b/docs/formal/key-rotation/EpochPolicyChainHash.cfg @@ -0,0 +1,16 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + Values = {v1, v2} + Keys = {1, 2} + MaxEpoch = 2 + MaxRegistrations = 5 + KeyByChainHash = TRUE + Composes = {c1} + MaxAppEpoch = 2 + LegacyIgnoresAppEpoch = FALSE +CHECK_DEADLOCK FALSE +INVARIANTS + RevocationIsTotal diff --git a/docs/formal/key-rotation/EpochPolicyLegacyAppEpoch.cfg b/docs/formal/key-rotation/EpochPolicyLegacyAppEpoch.cfg new file mode 100644 index 000000000..b96abd94a --- /dev/null +++ b/docs/formal/key-rotation/EpochPolicyLegacyAppEpoch.cfg @@ -0,0 +1,16 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + Values = {v1, v2} + Keys = {1, 2} + MaxEpoch = 2 + MaxRegistrations = 5 + KeyByChainHash = FALSE + Composes = {c1} + MaxAppEpoch = 2 + LegacyIgnoresAppEpoch = TRUE +CHECK_DEADLOCK FALSE +INVARIANTS + AppKeyMatchesSelectedRow diff --git a/docs/formal/key-rotation/KmsRotation.cfg b/docs/formal/key-rotation/KmsRotation.cfg new file mode 100644 index 000000000..e1956460a --- /dev/null +++ b/docs/formal/key-rotation/KmsRotation.cfg @@ -0,0 +1,13 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +SPECIFICATION Spec +CONSTANTS + Nodes = {n1, n2, n3} + MaxRoots = 4 +SYMMETRY Symmetry +CHECK_DEADLOCK FALSE +INVARIANTS + ReleasedFromRegisteredRoots +PROPERTIES + HandoverAgreesWithRegistry diff --git a/docs/formal/key-rotation/KmsRotation.tla b/docs/formal/key-rotation/KmsRotation.tla new file mode 100644 index 000000000..3a78bada7 --- /dev/null +++ b/docs/formal/key-rotation/KmsRotation.tla @@ -0,0 +1,123 @@ +\* SPDX-FileCopyrightText: © 2026 Phala Network +\* SPDX-License-Identifier: Apache-2.0 + +----------------------------- MODULE KmsRotation ----------------------------- +(***************************************************************************) +(* KMS root epochs: the DstackKms registry, KMS keyrings, onboarding and *) +(* key release, as implemented by the contract and dstack-kms *) +(* (main_service.rs servable_epoch/authorized_epoch, onboard_service.rs *) +(* ensure_handover_registered). *) +(* *) +(* A root is an opaque fresh value; a keyring is the sequence of roots of *) +(* epochs 0..latest. A node serves the current epoch when its keyring *) +(* holds it and the chain hash over epochs 0..current matches the *) +(* registry, which is equality of the prefixes. *) +(* *) +(* The owner registers whatever a generating node attests (the evidence *) +(* binds the epoch and keys, not the earlier roots) and activates an epoch *) +(* once some node can serve it. Operators may wipe a node to re-onboard. *) +(***************************************************************************) +EXTENDS Naturals, Sequences, TLC + +CONSTANTS Nodes, MaxRoots + +VARIABLES + reg, \* registered roots, epoch e at index e + 1 + cur, \* current epoch + kr, \* keyring of each node; <<>> before bootstrap or onboarding + fresh, \* next fresh root + released \* <> pairs whose derived keys were released + +vars == <> + +Prefix(s, n) == SubSeq(s, 1, n) +Latest(n) == Len(kr[n]) - 1 + +\* servable_epoch: the keyring holds the current epoch with its chain hash. +Serves(n) == Len(reg) > 0 /\ Len(kr[n]) > cur /\ Prefix(kr[n], cur + 1) = Prefix(reg, cur + 1) + +Init == + /\ reg = <<>> /\ cur = 0 /\ kr = [n \in Nodes |-> <<>>] + /\ fresh = 1 /\ released = {} + +Bootstrap(n) == + /\ kr[n] = <<>> /\ fresh <= MaxRoots + /\ kr' = [kr EXCEPT ![n] = <>] + /\ fresh' = fresh + 1 + /\ UNCHANGED <> + +\* Handover from src (onboard_service.rs ensure_handover_registered): src +\* passes its kms_export_keys self-check (it serves the current epoch) and +\* holds nothing beyond the latest registered epoch. Epochs past the current +\* one are checked only through the latest registered epoch's chain hash, +\* so a source holding some of them must hold the latest one and match it. +\* A rotation starts from the current epoch with nothing pending. +CanHandOver(src, rotate) == + LET held == Latest(src) + latest == Len(reg) - 1 + IN /\ Serves(src) + /\ held <= latest + /\ ~(held > cur /\ held < latest) + /\ held = latest => kr[src] = reg + /\ rotate => held = cur /\ latest = cur + +Join(dst, src) == + /\ kr[dst] = <<>> /\ CanHandOver(src, FALSE) + /\ kr' = [kr EXCEPT ![dst] = kr[src]] + /\ UNCHANGED <> + +RotationJoin(dst, src) == + /\ kr[dst] = <<>> /\ CanHandOver(src, TRUE) /\ fresh <= MaxRoots + /\ kr' = [kr EXCEPT ![dst] = Append(kr[src], fresh)] + /\ fresh' = fresh + 1 + /\ UNCHANGED <> + +\* registerRootEpoch with the keys node n generated for the next epoch. +Register(n) == + /\ Len(kr[n]) = Len(reg) + 1 + /\ reg' = Append(reg, kr[n][Len(reg) + 1]) + /\ UNCHANGED <> + +Activate == + /\ Len(reg) > cur + 1 + /\ \E n \in Nodes: Len(kr[n]) > cur + 1 /\ Prefix(kr[n], cur + 2) = Prefix(reg, cur + 2) + /\ cur' = cur + 1 + /\ UNCHANGED <> + +Pop == + /\ Len(reg) > cur + 1 + /\ reg' = SubSeq(reg, 1, Len(reg) - 1) + /\ UNCHANGED <> + +Wipe(n) == + /\ kr[n] # <<>> + /\ kr' = [kr EXCEPT ![n] = <<>>] + /\ UNCHANGED <> + +\* GetAppKey for epoch e <= current, current or historical. +Release(n, e) == + /\ Serves(n) /\ e <= cur + /\ released' = released \cup {<>} + /\ UNCHANGED <> + +Next == + \/ Activate \/ Pop + \/ \E n \in Nodes: Bootstrap(n) \/ Register(n) \/ Wipe(n) \/ \E e \in 0..cur: Release(n, e) + \/ \E d, s \in Nodes: d # s /\ (Join(d, s) \/ RotationJoin(d, s)) + +Spec == Init /\ [][Next]_vars + +Symmetry == Permutations(Nodes) + +----------------------------------------------------------------------------- +\* Keys are only ever released from the registered root of their epoch, so +\* no two roots are ever used for one epoch. +ReleasedFromRegisteredRoots == + \A r \in released: r[1] <= cur /\ reg[r[1] + 1] = r[2] + +Agrees(k) == \A e \in 1..Len(k): e <= Len(reg) => k[e] = reg[e] + +\* An ordinary handover only passes on roots the registry agrees with, so a +\* recipient can serve every registered epoch it received once activated. +HandoverAgreesWithRegistry == [][\A d, s \in Nodes: Join(d, s) => Agrees(kr[s])]_vars +============================================================================= diff --git a/docs/formal/key-rotation/README.md b/docs/formal/key-rotation/README.md new file mode 100644 index 000000000..b5e8cfede --- /dev/null +++ b/docs/formal/key-rotation/README.md @@ -0,0 +1,74 @@ + + +# Key rotation models + +TLA+ models of the [KMS key rotation protocol](../../kms-emergency-key-rotation-protocol.md) +as implemented, checked exhaustively with TLC within the stated bounds. + +| Model | Covers | Implementation | +| --- | --- | --- | +| `KmsRotation.tla` | Root epoch registry, KMS keyrings, onboarding, key release | `DstackKms.sol`, `kms/src/main_service.rs`, `kms/src/onboard_service.rs` | +| `DiskMigration.tla` | Data disk setup and LUKS key migration, with crashes and rotations | `dstack-util/src/system_setup.rs`, `system_setup/luks.rs` | +| `EnvUpdate.tla` | Env re-encryption at boot and the VMM update window | `dstack-util/src/system_setup.rs`, `vmm/src/app.rs` | +| `EpochPolicy.tla` | Root and app epoch policy rows, range setters, pop and re-registration, legacy app policies, key release on the selected rows | `DstackKms.sol`, `DstackApp.sol` | + +## Running + +```bash +java -cp tla2tools.jar tlc2.TLC -config DiskMigration.cfg DiskMigration.tla +``` + +| Config | Bounds | Result | +| --- | --- | --- | +| `KmsRotation.cfg` | 3 nodes, 4 roots | `ReleasedFromRegisteredRoots` and `HandoverAgreesWithRegistry` hold (4,423 states); also with 4 nodes, 5 roots (57,889 states) | +| `DiskMigration.cfg` | epochs 0-3 | all invariants hold (828 states) | +| `EnvUpdate.cfg` | epochs 0-2, 2 owner updates, lossy host API, fail-closed guest | `OwnerUpdateKept` and `NoAppEnv` hold (267 states) | +| `EnvUpdateLossy.cfg` | same, guest only logs an unanswered update | `NoAppEnv` violated | +| `EpochPolicy.cfg` | root epochs 0-2, 5 registrations, 2 choices of keys, 2 values; app epochs 0-2, 1 compose hash | `RevocationIsTotal`, `AppKeyMatchesSelectedRow` and `RegistrationStartsEmpty` hold (6,104,564 states) | +| `EpochPolicyChainHash.cfg` | same, rows keyed by chain hash | `RevocationIsTotal` violated | +| `EpochPolicyLegacyAppEpoch.cfg` | same, legacy app fallback ignores the requested app epoch | `AppKeyMatchesSelectedRow` violated | + +Checked with TLC 2026.11.06 on OpenJDK 21. `EnvUpdate.cfg` models the guest of +#1546; `EnvUpdateLossy.cfg` the guest before it. + +Each model was checked against mutations of the implementation, each of which +produces a counterexample: + +- `DiskMigration`: requiring a single keyslot when the current key unlocks none + (a migration interrupted before a later rotation then fails every boot); + opening without removing the old keyslot; treating a disk that still carries + the initializing label as initialized. +- `KmsRotation`: skipping the latest registered epoch's chain hash check on + handover, which lets an ordinary join pass on a losing pending root. +- `EnvUpdate`: starting applications after an unanswered update. +- `EpochPolicy`: the two configs above; and treating a removal from the + current epoch through the latest as a revocation, as the epoch-less setters + before #1561 did, which lets a historical row still release the key. + +## Abstractions + +- A key epoch in `DiskMigration` and `EnvUpdate` stands for the (root, app) + epoch pair; the KMS serves every epoch up to the current one. Authorization + is assumed to allow the instance at each of them; `EpochPolicy` models the + rows that decide it, and a migrating instance must be allowed in the old + rows too. +- In `KmsRotation`, keys are released for epochs up to the current one and + handed over up to the latest registered one, the operation bounds of + `kms_serve`/`app_get_key` and `kms_export_keys`/`kms_import_keys`. +- `EpochPolicy` merges the root materials (OS image, KMS MR and device) into + one row per epoch, and the app materials into compose hashes; a release + needs one allowed value in each selected row. It has one app and no KMS + nodes; `KmsRotation` covers the keyrings. +- A boot fetches its keys once; the KMS may rotate at any point of + `DiskMigration`, and between boots in `EnvUpdate`. +- Each cryptsetup command on the device is atomic: it writes a new keyslot + area before the header copies, syncs each, and repairs an outdated copy on + the next load. A crash may stop a boot between any two commands. +- Roots are unique values; chain hash comparison is prefix equality. +- The owner registers what a generating node attests and activates an epoch once + some node can serve it. Governance and attestation are trusted. +- `EnvUpdate` does not model a VMM without `UpdateSealedEnv`, which has no + update window. diff --git a/docs/kms-emergency-key-rotation-protocol.md b/docs/kms-emergency-key-rotation-protocol.md new file mode 100644 index 000000000..fe628d37c --- /dev/null +++ b/docs/kms-emergency-key-rotation-protocol.md @@ -0,0 +1,350 @@ +# KMS emergency key rotation protocol + +**Status:** Implemented. + +## 1. Scope and assumptions + +- Threat model: KMS root private keys are exposed, while disk data, LUKS headers, + and provider/host access are not. Repair the KMS environment before rotation. +- Stop all application CVMs and gateways; upgrade their OS and restart together. +- Reuse DstackKms governance and its OS allowlist. Assume configured chain RPCs + are trusted; RPC rollback and malicious-host/data anti-rollback are out of scope. +- Keep existing root-public-key pins. Affected deployments are handled separately. +- Rotate LUKS keyslots only; the underlying volume key remains unchanged. +- Every current KMS stores and hands over the complete root history, epochs 0..N. +- KMS stores no individual migration progress. No JWT, live rotation, or gateway + admission-lease protocol is introduced. + +**Storage boundary:** old root + old LUKS header reveals the unchanged volume +key. If ciphertext is later exposed, this can affect post-rotation data too. +Treat historical headers/snapshots as sensitive and inventory/isolate/dispose of +copies operationally. Full reencryption is future work if host/storage exposure +enters the threat model; slot replacement is not equivalent protection. + +## 2. Behavior summary + +Historical GetAppKey requests return complete epoch material and stay available +without a deadline, so late or restored instances can still migrate. Automatic recovery covers +disk keyslots and environment ciphertext with cloud-platform assistance; +application-owned GetKey data and wallets remain application responsibilities. +Onboarding supports ordinary full-history import and rotation import followed +by local generation of the next epoch. Sections 6-7 specify these flows. + +## 3. Roots, policy, and compatibility + +`api_version` selects protocol behavior; `root_epoch` identifies root material. +Epoch 0 is the existing roots, with existing derivation formulas and outputs +unchanged. Every epoch selector is optional; unset means the current epoch. +The name leaves room for a separate per-app epoch later. Generate +independent fresh CA and secp256k1 roots for each new epoch; existing derivation +formulas can remain unchanged. Never reuse an epoch for different roots. + +DstackKms keeps a registry of each epoch's root public keys, whose keys and +chain hash never change once registered (a pending epoch may be popped), an +`evidenceUrl` locating the attestation that proves where they were generated, and +a chain hash `h_n = keccak256(h_{n-1} || keccak256(caPubkey) || keccak256(k256Pubkey))` +(zero `h_{-1}`) committing to every key up to `n`; the position in the chain +fixes `n`, and hashing each key first keeps the preimage fixed-length. The +contract accepts only the encodings the KMS produces: a 91-byte P-256 DER +SubjectPublicKeyInfo with an uncompressed point, and a 33-byte compressed +secp256k1 key, so one key cannot be registered under two encodings. The +evidence stays off chain (stored on chain it would cost about 720 gas per byte) +and needs no hash: its report data binds the keys, so only availability depends +on the URL, which the owner can update (`setRootEpochEvidenceUrl`) when the +evidence moves. Registering an epoch and +making it current are separate: `registerRootEpoch` appends (epoch 0 becomes +current at once) and `activateRootEpoch(n)` moves the current epoch forward one +step. `popRootEpoch(n, chainHash)` drops the last epoch while it is still pending, for when +the nodes holding it are lost before activation; a re-rotation then registers a +new `n`, whose OS image, KMS MR and device rows start empty because policy rows +are keyed by the epoch's chain hash. Prepare them again before activating. A +current epoch is never popped. `kmsInfo` follows the current epoch: +registering epoch 0 and each activation copy its keys there and clear the legacy +quote and event log; the legacy `setKmsInfo`, `setKmsQuote` and `setKmsEventlog` setters are removed. Epoch 0 must be the `kmsInfo` keys +when those are set; a legacy `kmsInfo` quote and event log are converted off +chain into the standard attestation encoding (`Attestation::from_tdx_quote`) +before publishing. The KMS sends the epoch it serves and its chain hash (`rootEpoch`, +`rootEpochHash`) in every boot check, and the auth backend owns the policy: it +denies a KMS that does not serve the current epoch with the registered keys. +Info responses report the current epoch for KMS startup and onboarding; boot +responses are unchanged, and so is the on-chain `AppBootInfo`. +Governance credentials must remain independent and uncompromised. + +The KMS keyring and backups contain every epoch the node holds, not just the +latest two. Keeping old roots does not authorize issuing old derived keys. There +is no local epoch configuration: a KMS serves the current epoch on chain, +provided its keyring holds it and reproduces its chain hash, and waits +otherwise. When the current epoch moves, the KMS restarts to follow it and +re-signs its RPC certificate under the new root CA. A `root-epoch` marker records +the newest stored epoch. + +Historical epochs are not time-limited. Their roots are already exposed, so +withholding derived keys from authorized CVMs would not reduce what an attacker +can derive, while it would strand instances or backups that miss a deadline. +Old OS images are cut off through the OS allowlist instead. If host-side disk +rollback enters the threat model, a release deadline can be added later. + +Compatibility: keep accepting GetAppKey versions 0/1. An absent selector means +the current epoch, which before any rotation is epoch 0, so old OS images see no +change. After rotation they cannot open migrated disks; remove old OS images +through the existing allowlist and retain OS-image verification. Historical +epoch access never overrides OS/application authorization. + +## 4. GetAppKey additive extension + +Reuse the existing [GetAppKey transport and authorization path](../dstack/kms/src/main_service.rs), +without claiming a new KMS measurement-allowlist check on the guest. Preserve +root-public-key checking through `key_provider_id`; there is no separate guest +epoch pin or chain query. Unpinned deployments retain their existing KMS identity +limitations: this proposal does not create independent replacement-root trust +for them. Changing a pin can change compose/config hashes and related allowlists +or verifier expectations; affected deployments need separate coordinated updates. + +Keep `api_version = 1`; add one epoch selector: + +```protobuf +message GetAppKeyRequest { + uint32 api_version = 1; + string vm_config = 2; + // Unset = the current epoch, N = epoch N. + optional uint32 root_epoch = 3; +} +// Add to AppKeyResponse; preserve fields 1-8 and their types. +uint32 root_epoch = 9; +``` + +Unset resolves to current once for this request; unknown epochs are rejected. +Response `root_epoch` and all epoch-dependent material in fields 1-8 belong to +the resolved epoch. There is no separate source disk field. Every epoch up to +current uses the same normal authorization. A guest asks for an older epoch by +number only when it needs one, so a boot with nothing to migrate makes a single +request. + +An allowed historical request returns the complete historical AppKeyResponse, +including env and application keys, to preserve legacy compatibility. This +replaces the earlier disk-only server response proposal: the new OS extracts +only disk_crypt_key and, when needed, env_crypt_key for migration, discarding +other historical material. It retains its latest bundle for app startup. This +accepted tradeoff restricts recipients, not cryptographic uses of returned keys. + +The guest checks explicit epoch responses against the requested number. An old +KMS ignores the selector and serves epoch 0, reporting 0; that is +indistinguishable from a not-yet-rotated KMS using these fields alone. +Existing root-public-key pins and removal of old KMS endpoints remain essential; +unpinned clients gain no new downgrade protection. Do not rely on cached/disabled +KMS self-authorization for immediate revocation. Test JSON compatibility separately. + +Authenticate the KMS connection against current configured trust. Historical +ca_cert in the response is historical data, not a replacement transport trust +anchor: do not install it or compare it as though it were the current server pin. + +## 5. OS slot migration and LUKS header metadata + +Store the epochs in one LUKS2 JSON token bound to the keyslot holding their key: + +```json +{"type":"dstack-kms","keyslots":["0"],"root_epoch":2,"app_epoch":0} +``` + +The validator rejects nonzero subsystem, unexpected labels, and any token but +this one. It accepts keyslot 0, keyslot 1, or both (only mid-migration), each +with the same pinned parameters, and requires both header copies to agree. +Disks without the token are epoch 0. Requires cryptsetup 2.5 or later +(`--token-replace`, `--new-key-slot`); Debian trixie ships 2.7. + +```text +copy the header to tmpfs -> validate the copy + initialization label set -> reformat (the disk holds no data yet) + token_or_0 > current -> refuse + current key unlocks a keyslot of the copy -> finish + otherwise, token_or_0 < current -> old keys of epoch token_or_0 + -> check they unlock the recorded slot (token slot, else slot 0) + -> luksKillSlot any other slot + -> luksAddKey (old key authorizes the new key in the free slot) + -> recopy and validate -> finish + finish: token import (current epochs, surviving slot) -> luksKillSlot others + -> recopy, validate, and check the result -> open with the copy +``` + +Each step is one LUKS2 metadata commit, which cryptsetup keeps crash-consistent +across both header copies. Whatever step a crash interrupts, the next boot +finds either the old key alone or a keyslot the current key already unlocks, +so trying the current key first resumes the migration. If another rotation +happens before that boot, the current key unlocks neither of the two keyslots +an interrupted migration leaves; the token still names the slot of the recorded +epochs, so the other slot is dropped, but only once the recorded epochs' key is +confirmed to unlock that slot, and the migration restarts from it. A crash between +`luksFormat` and the token import leaves the initialization label set, which is +cleared only after the filesystem exists, so such a disk is reformatted. Never +format on unlock failure. Invalid or unsupported metadata requires recovery. +The disk is opened only with the last validated in-memory copy. + +No extra reboot is required. Historical material is confined to the migration +step, not persisted or passed to applications. +Old OS implementations with the existing validator reject a token-bearing disk; +this is an additional defense, not a substitute for the OS allowlist. Because of +that, an epoch-0 disk is never tagged: before any rotation the new OS leaves the +header untouched, so rolling back to an older OS image still works. Disks +formatted at a later epoch are tagged immediately. + +Tested in a Debian trixie container (cryptsetup 2.7.5, 32 MiB file images, +LUKS2/PBKDF2, no dm mapping): full migrations, resumption after each step, +disagreeing header copies, failed `luksAddKey` or denied old epochs leaving the +old epochs in place, failed token import, and disks recording newer epochs. +Old key-area wiping remains a release blocker. + +## 6. Environment migration, application keys, and TLS + +First decrypt env using latest. On authentication failure, try historical keys +from current down to 0, at most once each per boot; no platform metadata is +required. Skip an epoch whose key the KMS refuses, or that does not decrypt, until +one does: each historical key is still authorized by the KMS, and stopping at a +refusal would strand env encrypted under an older epoch whose image is approved +there but not in a newer row. The search costs at most current+1 RA-TLS requests, +only until the env has been migrated. Ciphertext no key decrypts fails closed; +env epoch is independent of the disk token. Empty env requires no migration. + +Decrypt/re-encrypt only inside the CVM, using current env public key, existing +format, and fresh randomness. The guest does not read the replacement back: +sealing and opening are deterministic code, so integration tests guarantee that +a re-sealed env opens on the next boot, after the VMM has replaced the old one. +The guest pushes the result to the VMM with `HostApi.UpdateSealedEnv` on +every boot, before any application code runs, sending an empty env when nothing +changed. The host API is reached over the VM's own vsock, so the VMM knows which +VM sent it. The VMM records the env each boot starts with and accepts one update +per boot, only if the stored env is still the boot env, so an owner's concurrent +update is never overwritten. The update closes the window, and the guest starts +init scripts and containers only once the VMM has answered, failing the boot +otherwise; application-stage progress reports close it as well. Application +containers and scripts therefore cannot replace the env. Rocket's per-method limit bounds the request (1 MiB by default). A +missed update is retried on the next boot. Plaintext and private keys never +leave the CVM. + +`GetAppEnvEncryptPubKey` keeps its interface. It always derives and signs with +the current epoch's roots and never serves historical epochs: env public keys +encrypt new data, so serving an older epoch would encrypt new secrets to a leaked +key. The signer public key identifies the epoch; no response field is added. +SDKs/frontends refresh cached env public keys before uploads; old-key uploads +are still migrated at boot, but put new secrets under an exposed key. Prompt owners to replace exposed +external credentials: reencryption alone does not revoke passwords/tokens. + +Guest API v1 `GetKey` takes an optional `root_epoch` (unset = current) and reports the epoch it +derived from; the SDKs expose it as an optional argument. The default is the +current epoch so new keys never come from an exposed root by accident. GetKey +outputs therefore change after a rotation: owners migrate app-encrypted records, +signing trust, wallets, and permissions by reading old keys with an explicit +`root_epoch` before releasing business services. The guest agent fetches an +older epoch's app root from the KMS the first time GetKey names it and keeps it +in memory; old roots are not stored in the keys file. The frozen v0 surface +always uses the current epoch. A per-app epoch, if added later, follows the same convention. SignCert +retains existing checks and signs under current roots; GetMeta is not a trust +anchor. Change CVM/gateway certificate trust in both directions. Public ACME +certificates remain separate. Orchestration gates all business activity, including +outbound workers, until readiness. + +## 7. Full-history KMS onboarding + +Onboarding imports the source's whole keyring, 0..S, and checks it against the +recipient's `authorizeKms` result: the current epoch's chain hash, and the latest +registered epoch L (`latestRootEpoch`, `latestRootEpochHash`). It never selects a +subset. A source holding epochs beyond L is refused ("source KMS holds +unregistered root epochs"). When S = L, the chain hash of L must match; chain +hashes are cumulative, so this binds the whole pending tail, not only the current +prefix. A source holding exactly the current epoch still joins; one stopping +between the current epoch and L is refused, since its tail cannot be checked. With +`rotate`, it then generates epoch S+1. + +| Mode | Source requirement | Result | +| --- | --- | --- | +| Ordinary join | Holds the current epoch and nothing unregistered; a registered tail pending activation must match the latest chain hash | Keyring 0..S | +| Rotation join (`rotate=true`) | Holds exactly up to the current epoch, with no epoch registered beyond it | Keyring 0..S plus fresh epoch S+1 and attestation evidence | + +Every handover requires the recipient's KMS attestation: the source checks the +attested RA-TLS client certificate on both `Admin.GetKmsKey` and the public +`KMS.GetKmsKey`. Admin authorization (`source_token`) is required for joins +and rotations alike, because the public `KMS.GetKmsKey` is off by default +(`core.onboard.public_key_handover = false`). Enable it on a source only for +receivers that cannot use the admin listener, such as 0.5.x KMS nodes, and +reject incompatible legacy receivers without reducing the payload. The source's certificate must carry its +OS image hash. +Deployments that never registered epochs can still join at epoch 0. + +For rotation, generate fresh roots and attest them with the bootstrap encoding +used for every epoch: report data is +`keccak256("dstack-kms-genereted-keys-v1:{ca_pubkey_hex};{k256_pubkey_hex};")` +zero-padded to 64 bytes. The "genereted" spelling is part of the released format. +The evidence only has to prove the key pair was generated in an approved TEE. +It binds neither the epoch number, which comes from the on-chain registration +order, nor the preceding chain hash, which joins already check against the +registry; a mismatch in either is an availability problem that `servable_epoch` +catches at once, not a key leak. Before registering N, the owner verifies the +quote with dstack-verifier, checks the approved code and measurements, and +recomputes the report data from the published keys; the cluster keeps +serving N-1. After the other nodes onboard and hold N, the owner activates N and +every KMS restarts onto it. An old-root signature is not authorization. +Competing generators cannot both be registered as N; a losing node's keyring no +longer reproduces the chain hash, so it never serves and must onboard again. It +also cannot hand its N over: joining from it fails the latest-epoch check while N +is pending, so the wrong tail does not reach later joiners. + +Every authorization request (app checks and the KMS self check that gates all +RPCs) carries the epoch the KMS serves, so a backend refuses a stale or forked +KMS. On a denial the KMS reads the current epoch from the info endpoint and +restarts if it moved. A KMS predating root epochs sends none; backends treat it +as epoch 0 and, once a later epoch is current, deny both its self check and its +app checks, so old KMS software stops serving. The contract's legacy +`isKmsAllowed` and `isAppAllowed` refuse as well, so this cutoff does not depend +on the backend. Upgrade every KMS before +activating a new epoch. In-flight operations are not cancelled. + +Backups retain full history; closing migration does not delete roots. Replacement +KMS boot cannot depend on its own unavailable service or stopped gateway ingress. +Protect root storage and independent admin access. + +## 8. Cutover runbook + +1. Inventory app/key dependencies, env ciphertext, pins, and historical snapshots. + Prepare trusted OS images, application recovery, and provider-pin adjustments. +2. Stop business writes, CVMs, gateways, and compromised KMS paths. Repair/replace + KMS execution and rotate independently exposed admin credentials. +3. Rotation-onboard one node, register its new epoch, then ordinary-onboard peers. + Authorize new KMS images before removing old OS images; do not rotate again + during the cutover. +4. Boot new OS with business services held by orchestration. Migrate slots and + complete platform env replacement; confirm app readiness. Ingress closure alone does + not stop workers, cron jobs, queues, or outbound database/wallet writes. +5. Start gateways with fresh data directories/KV namespaces and clean WireGuard + peers. Rebuild required non-admission configuration from trusted sources; + never restore old registration databases or admit old sync nodes. +6. Re-register CVMs with new certificates and release approved business + services/ingress. Keep failed instances isolated for operator follow-up. +7. Notify env public-key verifiers (vmm-cli signer whitelists, frontends, SDK + callers) to replace the old k256 signer with the new one, preferably by reading + it from DstackKms. For vmm-cli, run `kms remove `, then + `kms add `. A stale whitelist entry does not break migration, but + lets anyone holding the old root and able to intercept client-to-KMS traffic + forge env public keys; the `root_epoch` in a response is set by its signer + and protects nothing. + +## 9. Invariants and acceptance + +[TLA+ models](formal/key-rotation/README.md) check the registry, disk +migration and env update steps against crashes and interleavings. + +Authenticate actual secret-bearing connections; old-CA signatures alone do not +replace evidence checks. Do not downgrade on failure or expose secrets in logs, +command lines, swap, app key files, or host-shared storage. Released secrets +cannot be recalled. Historical key possession, +current unlock success, and production readiness are three different facts. + +Test legacy compatibility, OS-allowlist cutoff, selector boundaries and epoch-0 compatibility, non-adjacent epochs, +both onboarding modes, full-history atomic persistence, pending registration +restart, competing roots, live stale-node rejection, and epoch mismatch, slot/token crash recovery, +new-key reboot success, old-key unlock failure against the working header, and +absence of residual old paths and recovery from missing/stale epoch tokens. Confirm the volume key intentionally stays the +same. Test env corruption, independent env epochs, concurrent platform updates, +response-source authentication, bounded epoch search, stale-key uploads, +replacement/reboot recovery, application readiness, +and clean gateway reauthentication/sync. +Reports must state OS/TEE, kernel/cryptsetup, filesystem/layout, builds, topology, +policy, and failure injections. The preliminary test above is limited evidence, not release validation. diff --git a/docs/onboarding.md b/docs/onboarding.md index 28b216ff2..2aa0dc588 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -312,3 +312,26 @@ dstack logs ``` The KMS VM ID is printed by `dstackup install` when it creates or reuses the KMS CVM. + +### Upgrading the local KMS to root epochs + +Upgrade the bundled `dstack-auth` together with the KMS. Before normal key release, +set `kmsContractAddr` in the host's `auth-allowlist.json` to the KMS's attested app ID +and register the verified bootstrap CA/k256 public keys in `rootEpochs` (epoch zero). +Use the bootstrap evidence to verify these keys; do not trust an unauthenticated +metadata response. Set `currentRootEpoch` to the active registered epoch (initially +zero). See [root registration and rotation](deployment.md#rotating-kms-root-keys). +The local installer does not register these keys automatically; an older installed +KMS can continue using the legacy boot routes while this migration is prepared. + +The Rust backend implements `/authorizeKms` and `/authorizeApp` with the same +registered-root requirement as the other backends. Missing registration denies key +release, and invalid epoch configuration returns HTTP 503. Legacy `/bootAuth/*` +routes remain available for bootstrap. Local compose/device allowlists apply to +all permitted historical roots; they are not the on-chain per-epoch matrices. + +Application entries also accept `currentAppEpoch` (default zero). Keep this value +monotonic, restart the application after advancing it, and retain permissions for +historical keys needed during migration. `/app/:appId` exposes the current app +epoch; legacy app boot authorization is denied once it is nonzero. The Rust +backend uses the same normalized app ID lookup for policy and epoch selection. diff --git a/docs/onchain-governance.md b/docs/onchain-governance.md index 777675399..e5d19387d 100644 --- a/docs/onchain-governance.md +++ b/docs/onchain-governance.md @@ -16,14 +16,12 @@ On-chain governance adds: - Production dstack deployment with KMS and Gateway as CVMs (see [Deployment Guide](./deployment.md)) - Ethereum wallet with funds on Sepolia testnet (or your target network) - [Foundry](https://book.getfoundry.sh/getting-started/installation) installed -- Node.js and npm installed (for the bootAuth server) ## Deploy DstackKms Contract ```bash cd dstack/kms/auth-eth -npm install # Install Node.js dependencies -forge install # Install Foundry dependencies +forge install # Install Foundry dependencies (git submodules) # Deploy contracts (deploys both DstackApp implementation and DstackKms proxy) PRIVATE_KEY= forge script script/Deploy.s.sol:DeployScript \ @@ -51,11 +49,13 @@ export RPC_URL="https://eth-sepolia.g.alchemy.com/v2/" ## Configure KMS for On-Chain Auth -The KMS CVM includes an auth-api service that connects to your DstackKms contract. Configure it via environment variables in the KMS CVM: +The KMS CVM includes an auth-api service, [auth-eth](../dstack/kms/auth-eth/), that connects to your DstackKms contract. Configure it via environment variables in the KMS CVM: ```bash KMS_CONTRACT_ADDR= ETH_RPC_URL= +ETH_CHAIN_ID= # optional, rejects decisions from another chain +ETH_BLOCK_TAG=finalized # latest, safe or finalized (default) ``` The auth-api validates boot requests against the smart contract. See [Deployment Guide](./deployment.md#2-deploy-kms-as-cvm) for complete setup instructions. @@ -63,7 +63,7 @@ The auth-api validates boot requests against the smart contract. See [Deployment ## Whitelist OS Image ```bash -OS_IMAGE_HASH=0x \ +OS_IMAGE_HASH=0x ROOT_EPOCH=0 \ forge script script/Manage.s.sol:AddOsImage --broadcast --rpc-url $RPC_URL ``` @@ -109,7 +109,7 @@ sha256sum /path/to/gateway-compose.json | awk '{print "0x"$1}' Then add it: ```bash -APP_CONTRACT_ADDR= COMPOSE_HASH= \ +APP_CONTRACT_ADDR= COMPOSE_HASH= APP_EPOCH=0 \ forge script script/Manage.s.sol:AddComposeHash --broadcast --rpc-url $RPC_URL ``` @@ -139,7 +139,7 @@ sha256sum /path/to/your-app-compose.json | awk '{print "0x"$1}' Then add it: ```bash -APP_CONTRACT_ADDR= COMPOSE_HASH= \ +APP_CONTRACT_ADDR= COMPOSE_HASH= APP_EPOCH=0 \ forge script script/Manage.s.sol:AddComposeHash --broadcast --rpc-url $RPC_URL ``` @@ -155,8 +155,8 @@ The central governance contract that manages OS image whitelisting, app registra | Function | Description | |----------|-------------| -| `addOsImageHash(bytes32)` | Whitelist an OS image hash | -| `removeOsImageHash(bytes32)` | Remove an OS image from whitelist | +| `addOsImageHashAtEpochs(uint256,uint256,bytes32)` | Whitelist an OS image hash in a range of root epochs | +| `removeOsImageHashAtEpochs(uint256,uint256,bytes32)` | Remove an OS image from a range of root epochs; `0` through the latest revokes it | | `setGatewayAppId(string)` | Set the trusted Gateway app ID(s) | | `registerApp(address)` | Register an app contract | | `deployAndRegisterApp(...)` | Deploy and register app in one transaction | @@ -165,18 +165,40 @@ The central governance contract that manages OS image whitelisting, app registra ### DstackApp (Per-App Contract) -Each app has its own contract controlling which compose hashes and devices are allowed. +Each app has its own contract controlling which compose hashes and devices are allowed, +per app epoch. Every setter names the app epoch rows it writes: one epoch, or a range up +to the next epoch. Removing a value from rows `0` through the current epoch revokes it. | Function | Description | |----------|-------------| -| `addComposeHash(bytes32)` | Whitelist a compose hash | -| `removeComposeHash(bytes32)` | Remove a compose hash from whitelist | -| `addDevice(bytes32)` | Whitelist a device ID | -| `removeDevice(bytes32)` | Remove a device from whitelist | -| `setAllowAnyDevice(bool)` | Allow any device to run this app | -| `isAppAllowed(AppBootInfo)` | Check if app can boot with given config | +| `addComposeHashAtEpoch(uint256,bytes32)` / `addComposeHashAtEpochs(uint256,uint256,bytes32)` | Whitelist a compose hash in one app epoch or a range | +| `removeComposeHashAtEpoch(uint256,bytes32)` / `removeComposeHashAtEpochs(uint256,uint256,bytes32)` | Remove a compose hash from one app epoch or a range | +| `addDeviceAtEpoch(uint256,bytes32)` / `addDeviceAtEpochs(uint256,uint256,bytes32)` | Whitelist a device ID | +| `removeDeviceAtEpoch(uint256,bytes32)` / `removeDeviceAtEpochs(uint256,uint256,bytes32)` | Remove a device | +| `setAllowAnyDeviceAtEpoch(uint256,bool)` / `setAllowAnyDeviceAtEpochs(uint256,uint256,bool)` | Allow any device to run this app | +| `currentAppEpoch()` | The app epoch new keys are derived for | +| `isAppAllowed(AppBootInfo)` | Check if app can boot with given config (app epoch 0 only) | | `disableUpgrades()` | Permanently disable contract upgrades | +#### Apps deployed before app epochs + +App contracts deployed from dstack 0.6.x or earlier, and not upgraded since, have only +app epoch 0 and the epoch-less setters `addComposeHash(bytes32)`, +`removeComposeHash(bytes32)`, `addDevice(bytes32)`, `removeDevice(bytes32)` and +`setAllowAnyDevice(bool)`. They keep working with an upgraded `DstackKms` and KMS, +which authorize them for app epoch 0. Tell the two kinds apart by ERC-165 interface ID: + +```bash +cast call "supportsInterface(bytes4)(bool)" 0x853d2b79 --rpc-url $RPC_URL # IAppEpochManagement: epoch setters +cast call "supportsInterface(bytes4)(bool)" 0x8fd37527 --rpc-url $RPC_URL # IAppAuthBasicManagement: epoch-less setters +``` + +The `Manage.s.sol` app scripts check this themselves: on an older app they call the +epoch-less setter and accept only `APP_EPOCH=0` or no epoch; on a current app they +require `APP_EPOCH`, or `FROM_APP_EPOCH` and `TO_APP_EPOCH`. Upgrading an older app to +the current `DstackApp` keeps its compose hashes, devices and `allowAnyDevice` as app +epoch 0. + ### AppBootInfo Structure Both `isAppAllowed` and `isKmsAllowed` take an `AppBootInfo` struct: diff --git a/docs/rc-testing-runbook.md b/docs/rc-testing-runbook.md index 53808e6a9..55acdd9bc 100644 --- a/docs/rc-testing-runbook.md +++ b/docs/rc-testing-runbook.md @@ -153,7 +153,11 @@ KMS first, then gateway, then the app. Each needs the previous one's identity. gramine-sealing-key-provider on the host, default `127.0.0.1:3443`). The simplest authorization that releases keys without a chain is `[core.auth_api] type = "dev"` with `gateway_app_id = "any"` — no extra - containers, unlike the on-chain compose which needs a Helios light client. + containers, unlike the on-chain compose, which runs auth-eth next to the KMS + and needs an Ethereum RPC endpoint. The KMS refuses to start without a + key handover path, dev auth included: give it `[core.admin]` with + `[core.admin.tls]` (`key`, `certs`) and an `auth_token_hash`, or set + `core.onboard.public_key_handover = true`. 3. **Gateway as a CVM** with `--kms`. Internal ports are RPC 8000, admin 8001, WireGuard 51820/udp, proxy 443. diff --git a/docs/sealed-env.md b/docs/sealed-env.md new file mode 100644 index 000000000..6258d38a7 --- /dev/null +++ b/docs/sealed-env.md @@ -0,0 +1,141 @@ +# Sealed environment variables + +SealedEnv protects environment variables with HPKE and identifies the KMS root +and application key epochs needed to decrypt them. Plaintext remains inside the client and CVM; +the VMM stores and forwards ciphertext. + +## Wire format + +The `.sealed-env` file contains UTF-8 JSON: + +```json +{"version":1,"root_epoch":3,"app_epoch":0,"enc":"","ciphertext":""} +``` + +Version 1 fixes RFC 9180 Base mode with DHKEM(X25519, HKDF-SHA256), +HKDF-SHA256, and AES-256-GCM. `enc` is a 32-byte encapsulated key; `ciphertext` +includes the 16-byte authentication tag. Binary fields use standard padded +Base64. HPKE `info` is UTF-8 `dstack-sealed-env-v1:R:A`, where `R` and `A` are the canonical +decimal uint32 root and app epochs, with no leading zeros. AAD is empty. Unknown envelope +versions are rejected; extra JSON fields may be ignored. This provides +confidentiality, not authentication of the person uploading the environment. + +Plaintext uses the existing JSON representation: + +```json +{"env":[{"key":"MESSAGE","value":"Hello"}]} +``` + +The guest still enforces the application's environment-name allowlist. + +## Keys and signatures + +`GetSealedEnvPubKey` is available on KMS and proxied by VMM. Its request contains +`app_id` (20 bytes) only and always selects the current active epochs. Historical +private keys for decryption and migration are obtained through `GetAppKey`, not +through either environment public-key endpoint. The response contains +`public_key`, `root_epoch`, `app_epoch`, `timestamp`, and `signature`. + +For each app, the KMS derives the 32-byte receiver private key directly from +that epoch's P-256 CA private scalar using HKDF-SHA256: + +- salt: ASCII `dstack-sealed-env-v1`; +- IKM: the 32-byte P-256 private scalar; +- info: the raw 20-byte app ID. + +`GetAppKey` returns it as `sealed_env_key` alongside the legacy key. The legacy +key derivation remains unchanged at app epoch zero. `GetAppEnvEncryptPubKey` +rejects nonzero app epochs; use `GetSealedEnvPubKey` after app rotation. + +The public-key response signs Keccak256 of this unambiguous byte sequence: + +```text +"dstack-sealed-env-pubkey-v1:" || app_id[20] || root_epoch[4, big-endian] + || app_epoch[4, big-endian] || public_key[32] || timestamp[8, big-endian] +``` + +The signing key is the selected epoch's secp256k1 root. The signature is +`r[32] || s[32] || recovery_id[1]`. Clients must compare the recovered signer +against an independently trusted epoch root key or pin. Recovering a signer +alone is not a trust decision. Clients should reject stale responses (default +maximum age 300 seconds, future clock tolerance 60 seconds). + +## Image compatibility + +New image `metadata.json` files declare `"sealed_env": true`; `ListImages` exposes +this flag. Missing metadata means legacy encryption only. The UI and CLI use the target +image for both creation and environment updates, including image changes. +Old or unknown images use legacy encryption even with a capable KMS. Capable images require the SealedEnv RPC: CLI and UI fail on any lookup error, +including "service not found". Such errors are not signed and must not select a +weaker encryption protocol. Upgrade the KMS before submitting secrets for a +SealedEnv-capable image. + +The VMM also enforces the flag for API callers and `dstack-vmm run`, including +existing SealedEnv files in a reused one-shot workdir. Creation, updates and image changes +through resize reject an effective SealedEnv file when the target image lacks the +capability, before writing files or changing resources. An update may replace it +with nonempty legacy `encrypted_env` while switching to an older image. Empty env +fields preserve the existing format. Guest `UpdateSealedEnv` uploads also require +a capable running image. + +SealedEnv has one format, version 1, with both epoch fields always present. The +receiver-key KDF appends `"app-epoch-v1:" || app_epoch[4, big-endian]` to its info +for nonzero app epochs; at zero its info is just the app ID. Signature and HPKE +contexts always include both epochs, including zero. Changing either epoch +invalidates the signature or ciphertext. See [application epochs](app-key-epochs.md) +for owner operations. + +The browser UI receives the key through the same-origin VMM API and does not +independently verify the response signer or freshness. Its trust boundary includes +the VMM and the configured KMS connection; it does not provide the independently +pinned signer verification described above. Use the CLI with a trusted signer +allowlist, or an SDK verification helper with an independently trusted signer, +when that verification is required. + +## File precedence and migration + +A new guest chooses `.sealed-env` whenever that file exists. An empty, invalid, +or unreadable sealed file is an error; it must not fall back to a legacy file. +If no sealed file exists, the guest accepts `.encrypted-env` for compatibility. +The copy limits are 384 KiB for sealed JSON and 256 KiB for legacy ciphertext. + +At boot, sealed ciphertext explicitly selects its historical root/app epoch pair. The +guest obtains that epoch's app key over the already trusted KMS connection, +decrypts, and seals to the current epoch. Legacy ciphertext uses the legacy +key path at app epoch zero, trying earlier root epochs when necessary. It is +converted to SealedEnv only when decrypted with a historical epoch. Ciphertext +already decryptable with the current keys is left unchanged, even in the legacy +format; an older KMS need not provide a SealedEnv key in that case. +A future epoch or failed authentication stops environment provisioning. + +The guest sends `UpdateSealedEnv` through the host channel before application +code runs. The VMM binds the update to the VM's CID and a snapshot of both env +files, including presence. It accepts at most one update in the boot window; +an empty update closes the window without replacing files. Owner updates and +guest replacement share the VM launch lock so an owner change cannot be lost +between the snapshot comparison and write. + +The window is guaranteed closed before application code runs: the VMM closes it +as soon as it identifies the VM and then always answers, logging why an env was +not stored, and the guest starts init scripts and containers only after an +answer, failing the boot if none comes after a few retries. A VMM that predates +`UpdateSealedEnv` has no window; the guest recognizes its unknown-method answer. + +VM creation and update accept either `sealed_env` or legacy `encrypted_env`. +Supplying both nonempty fields is rejected. Writing one format removes the other: +an external legacy update after a guest sealed update therefore becomes the +sole environment for the next boot. Omitting both preserves existing files on +update. A replacement is safely written before the other file is removed; +filesystem errors are reported rather than ignored. + +Old OS images understand only `.encrypted-env`. After conversion removes that +file, reverting to an old OS requires the owner to upload legacy ciphertext +again. Rotation does not revoke credentials exposed before migration, and +cannot erase old host snapshots. + +## Scope of verification + +Local tests exercise HPKE implementations with synthetic keys, signature +contexts, file precedence and update races. They do not establish production +TEE isolation or real-chain availability. The shared fixture under +`sdk/fixtures/sealed-env/` is public test data, never deployment key material. diff --git a/docs/security/public-security-reports.md b/docs/security/public-security-reports.md index d04b40628..373299a7c 100644 --- a/docs/security/public-security-reports.md +++ b/docs/security/public-security-reports.md @@ -59,7 +59,7 @@ These issues were filed as concrete vulnerability reports, security audit findin | [#616](https://github.com/Dstack-TEE/dstack/issues/616) Host-controlled Docker registry mirror enables image substitution attacks | Closed | Not a production vulnerability | Registry mirrors are untrusted transport. Digest-pinned image references and measured compose configuration protect against substitution. No code fix was applied | | [#617](https://github.com/Dstack-TEE/dstack/issues/617) Guest agent exposes raw private keys to all local processes | Closed | Not a production vulnerability | dstack treats a CVM as one application trust domain. It does not provide per-container key isolation inside the same measured application. No code fix was applied | | [#618](https://github.com/Dstack-TEE/dstack/issues/618) Disk encryption disableable via kernel cmdline, not measured in RTMR | Closed | Not a production vulnerability | The kernel command line is measured into RTMR2, so changing `dstack.storage_encrypted=false` changes attestation evidence. No code fix was applied | -| [#619](https://github.com/Dstack-TEE/dstack/issues/619) KMS `get_temp_ca_cert` returns temp CA private key without authentication | Closed | Duplicate | The report duplicates the private advisory response for the temp CA bootstrap flow. [#1106](https://github.com/Dstack-TEE/dstack/pull/1106) removes the reason the temp CA existed - the KMS no longer pins it - so callers can be migrated off it; the RPC itself is retained until guests and KMS-to-KMS onboarding are | +| [#619](https://github.com/Dstack-TEE/dstack/issues/619) KMS `get_temp_ca_cert` returns temp CA private key without authentication | Closed | Duplicate | The report duplicates the private advisory response for the temp CA bootstrap flow. [#1106](https://github.com/Dstack-TEE/dstack/pull/1106) removes the reason the temp CA existed - the KMS no longer pins it - so callers can be migrated off it; the RPC and temporary CA material remain available for existing OS images and legacy onboarding while new guests use self-issued certificates | ## Related security roadmap and hardening diff --git a/docs/security/security-best-practices.md b/docs/security/security-best-practices.md index 5948b2c28..e3f61a666 100644 --- a/docs/security/security-best-practices.md +++ b/docs/security/security-best-practices.md @@ -111,7 +111,7 @@ Development settings are intentionally easy to audit, but they are not productio - The KMS contract pins a concrete gateway app id. Do not use `gateway_app_id = "any"` for production traffic. - TEE quotes are evaluated by deployment policy, including TCB status and expected OS/application measurements. -The KMS TLS listener verifies client certificates by the attestation they carry rather than by an issuer CA, so it needs no `rpc.tls.mutual` section. It still accepts connections without a client certificate, because bootstrap and public metadata endpoints must be reachable before a client has an RA-TLS certificate. Current guests present self-issued certificates, but `GetTempCaCert` remains in use by older guest images and by KMS-to-KMS onboarding without a `source_token`, which mint their client certificates from that CA; it returns temp CA private material, so treat it as bootstrap-sensitive. +The KMS TLS listener verifies client certificates by the attestation they carry rather than by an issuer CA, so it needs no `rpc.tls.mutual` section. It still accepts connections without a client certificate, because bootstrap and public metadata endpoints must be reachable before a client has an RA-TLS certificate. Guests and onboarding clients use self-issued certificates. The temporary-CA RPC remains available for old OS images and tokenless legacy onboarding; its public key material authenticates no caller. App key release and KMS key handover still require verified caller attestation from the RA-TLS client certificate. Certificate signing verifies the CSR signature and embedded attestation before signing. diff --git a/docs/security/security-model.md b/docs/security/security-model.md index b3d43ed3b..24f9ae264 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -425,11 +425,12 @@ The KMS Rocket TLS listener permits connections without a client certificate bec App key release and KMS key handover require verified caller attestation from the RA-TLS client certificate. Certificate signing verifies the CSR signature and the attestation embedded in the CSR before signing. -The unauthenticated or non-client-certificate surface includes bootstrap and temp-CA bootstrap material retrieval, env-encryption public-key retrieval, metadata, health, and metrics behavior documented for operators. `GetTempCaCert` returns temp CA private material and remains in use by older guest images and by KMS-to-KMS onboarding without a `source_token`, which mint their client certificates from that CA; operators must treat it as bootstrap-sensitive rather than harmless public metadata. +The unauthenticated or non-client-certificate surface includes bootstrap, env-encryption public-key retrieval, metadata, health, and metrics behavior documented for operators. Clients create self-issued certificates locally. The temporary-CA RPC and its public key material remain available for legacy clients. Guest key retrieval verifies the actual response connection's attestation and checks that the returned CA signed its RPC certificate. Provider pins and launch measurement continue to bind the selected KMS root identity; attestation alone does not authorize an arbitrary CVM as the expected KMS. KMS-to-KMS root-key handover is served by `Admin.GetKmsKey` on the authenticated admin listener, which additionally requires a verified RA-TLS client certificate. -The legacy public `KMS.GetKmsKey` is controlled by `core.onboard.public_key_handover`. +The legacy public `KMS.GetKmsKey` is off by default and is served only when +`core.onboard.public_key_handover = true` is set explicitly. ## Limitations diff --git a/docs/tutorials/contract-deployment.md b/docs/tutorials/contract-deployment.md index b5e429871..cc940dd9a 100644 --- a/docs/tutorials/contract-deployment.md +++ b/docs/tutorials/contract-deployment.md @@ -12,7 +12,7 @@ tags: - kms - ethereum - sepolia - - hardhat + - foundry - deployment difficulty: "intermediate" estimatedTime: "15 minutes" @@ -58,31 +58,20 @@ git checkout next cd dstack/kms/auth-eth ``` -### Step 2: Install Node.js and Dependencies +### Step 2: Install Foundry and Dependencies -Install nvm (Node Version Manager), then use it to install the correct Node.js version: +The contracts are built and deployed with [Foundry](https://book.getfoundry.sh/getting-started/installation). Install it if `forge --version` fails: ```bash -# Install nvm -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash - -# Load nvm into current shell -export NVM_DIR="$HOME/.nvm" -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - -# Install and use Node.js 18 (LTS) -nvm install 18 -nvm use 18 - -# Verify versions -node --version # Should show v18.x.x -npm --version # Should show 9.x.x or 10.x.x +curl -L https://foundry.paradigm.xyz | bash +foundryup ``` -Then install the project dependencies: +Then fetch the contract dependencies (git submodules): ```bash -npm install +git submodule update --init --recursive lib/forge-std \ + lib/openzeppelin-contracts-upgradeable lib/openzeppelin-foundry-upgrades ``` ### Step 3: Load Credentials @@ -117,36 +106,26 @@ You need at least 0.01 ETH (shown in wei: `10000000000000000`). If insufficient, ### Step 5: Compile Contracts ```bash -npx hardhat compile -``` - -Expected output (compiler version and file count may vary): - -``` -Downloading compiler 0.8.22 -Generating typings for: 19 artifacts in dir: typechain-types for target: ethers-v6 -Successfully generated 72 typings! -Compiled 19 Solidity files successfully (evm target: paris). +forge build ``` -This generates the contract artifacts (ABI and bytecode) needed for deployment. +This generates the contract artifacts (ABI and bytecode) under `out/`. ### Step 6: Deploy Contracts ```bash -npx hardhat kms:deploy --with-app-impl --network custom +forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url $RPC_URL ``` Expected output: ``` Deploying with account: 0xYourAddress -Account balance: 0.123456789 ETH -Step 1: Deploying DstackApp implementation... +Account balance: 123456789000000000 DstackApp implementation deployed to: 0x... -Step 2: Deploying DstackKms... -DstackKms Proxy deployed to: 0x... -Complete KMS setup deployed successfully! +DstackKms implementation deployed to: 0x... +DstackKms proxy deployed to: 0x... +Deployment complete! ``` ### Step 7: Save Contract Addresses @@ -252,5 +231,5 @@ With contracts deployed, you're ready to build and configure the KMS: ## Additional Resources - [Sepolia Etherscan](https://sepolia.etherscan.io/) -- [Hardhat Deployment Guide](https://hardhat.org/hardhat-runner/docs/guides/deploying) +- [Foundry scripting guide](https://book.getfoundry.sh/guides/scripting-with-solidity) - [dstack GitHub Repository](https://github.com/Dstack-TEE/dstack) diff --git a/docs/tutorials/gateway-build-configuration.md b/docs/tutorials/gateway-build-configuration.md index 53afb1e27..dd29fcc32 100644 --- a/docs/tutorials/gateway-build-configuration.md +++ b/docs/tutorials/gateway-build-configuration.md @@ -278,7 +278,7 @@ echo "OS image hash: 0x$OS_IMAGE_HASH" ``` ```bash -cast send "$KMS_CONTRACT_ADDR" "addOsImageHash(bytes32)" "0x$OS_IMAGE_HASH" --rpc-url "$ETH_RPC_URL" --private-key "$PRIVATE_KEY" +cast send "$KMS_CONTRACT_ADDR" "addOsImageHashAtEpoch(uint256,bytes32)" 0 "0x$OS_IMAGE_HASH" --rpc-url "$ETH_RPC_URL" --private-key "$PRIVATE_KEY" ``` Verify it was added: @@ -510,7 +510,9 @@ export GATEWAY_APP_ID=$(cat ~/.dstack/secrets/gateway-app-id) Add the compose hash to the gateway app's allowed list: ```bash -cast send "$GATEWAY_APP_ID" "addComposeHash(bytes32)" "0x$COMPOSE_HASH" --rpc-url "$ETH_RPC_URL" --private-key "$PRIVATE_KEY" +# App contracts deployed before app epochs (0.6.x or earlier) take "addComposeHash(bytes32)" "0x$COMPOSE_HASH"; +# see onchain-governance.md#apps-deployed-before-app-epochs. +cast send "$GATEWAY_APP_ID" "addComposeHashAtEpoch(uint256,bytes32)" 0 "0x$COMPOSE_HASH" --rpc-url "$ETH_RPC_URL" --private-key "$PRIVATE_KEY" ``` Verify the hash was added: diff --git a/docs/tutorials/hello-world-app.md b/docs/tutorials/hello-world-app.md index 51d1ce656..31fa493ab 100644 --- a/docs/tutorials/hello-world-app.md +++ b/docs/tutorials/hello-world-app.md @@ -236,9 +236,11 @@ COMPOSE_HASH="" HELLO_APP_ID=$(cat ~/.dstack/secrets/hello-world-app-id) +# App contracts deployed before app epochs (0.6.x or earlier) take "addComposeHash(bytes32)" "0x$COMPOSE_HASH"; +# see onchain-governance.md#apps-deployed-before-app-epochs. cast send "$HELLO_APP_ID" \ - "addComposeHash(bytes32)" \ - "0x$COMPOSE_HASH" \ + "addComposeHashAtEpoch(uint256,bytes32)" \ + 0 "0x$COMPOSE_HASH" \ --rpc-url "$ETH_RPC_URL" \ --private-key "$PRIVATE_KEY" ``` diff --git a/docs/tutorials/kms-build-configuration.md b/docs/tutorials/kms-build-configuration.md index 42ec94243..bb7d045fb 100644 --- a/docs/tutorials/kms-build-configuration.md +++ b/docs/tutorials/kms-build-configuration.md @@ -41,7 +41,7 @@ The dstack KMS provides: | Component | Purpose | |-----------|---------| | **dstack-kms** | Main KMS binary - generates and stores cryptographic keys | -| **auth-eth** | Node.js service - verifies app permissions via smart contract | +| **auth-eth** | Bun webhook service - verifies app permissions via smart contract | | **kms.toml** | Configuration file for KMS settings | | **auth-eth.env** | Environment file with Ethereum RPC credentials | | **Docker image** | Containerized KMS for deployment in a CVM | @@ -204,6 +204,19 @@ type = "webhook" [core.auth_api.webhook] url = "http://127.0.0.1:9200" +# Admin listener, which serves the key handover to other KMS nodes; a serving +# KMS requires it. ADMIN_TOKEN_HASH is the token's SHA-256, filled in by the +# KMS CVM deployment tutorial. +[core.admin] +enabled = true +address = "0.0.0.0" +port = 9101 +auth_token_hash = "ADMIN_TOKEN_HASH" + +[core.admin.tls] +key = "/etc/kms/certs/rpc.key" +certs = "/etc/kms/certs/rpc.crt" + # Onboarding Configuration [core.onboard] enabled = true @@ -223,58 +236,23 @@ EOF | `[rpc]` | `port` | RPC server port (9100) | | `[core]` | `cert_dir` | Directory for certificates | | `[core]` | `pccs_url` | PCCS endpoint for quote verification | -| `[core.auth_api]` | `url` | Auth-eth webhook service URL | +| `[core.auth_api]` | `url` | auth-eth webhook service URL | +| `[core.admin]` | `auth_token_hash` | SHA-256 of the admin token; the admin listener with `[core.admin.tls]` is required to serve | | `[core.onboard]` | `enabled` | Enable bootstrap/onboard mode | -## Step 5: Build Auth-ETH Service - -The KMS requires the auth-eth service to query the Ethereum contract for authorization. - -### Install Node.js +## Step 5: Prepare the auth-eth Service -The auth-eth service requires Node.js. Install Node.js 20.x from NodeSource: +The KMS requires the [auth-eth](https://github.com/Dstack-TEE/dstack/tree/next/dstack/kms/auth-eth) webhook service to query the Ethereum contract for authorization. -```bash -curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -sudo apt-get install -y nodejs -``` - -Verify the installation: +auth-eth runs directly from its TypeScript source with [Bun](https://bun.sh), so there is no build step. The Docker image in Step 7 installs Bun and the locked dependencies. Check that the source is present: ```bash -node --version -npm --version +ls ~/dstack/dstack/kms/auth-eth/index.ts ~/dstack/dstack/kms/auth-eth/package.json ~/dstack/dstack/kms/auth-eth/bun.lock ``` -You should see Node.js v20.x and npm v10.x (or later). - -### Navigate to auth-eth directory +The same directory also holds the Foundry project for the contracts; the server needs only these three files. -```bash -cd ~/dstack/dstack/kms/auth-eth -``` - -### Install dependencies - -```bash -npm install -``` - -### Build TypeScript - -```bash -npx tsc --project tsconfig.json -``` - -### Verify build - -```bash -ls -la dist/src/ -``` - -You should see `main.js` and other compiled files. - -## Step 6: Create Auth-ETH Configuration +## Step 6: Create auth-eth Configuration Create environment configuration for the auth-eth service. @@ -299,15 +277,17 @@ If you've lost the address, find it on [Sepolia Etherscan](https://sepolia.ether ```bash cat > /etc/kms/auth-eth.env << EOF -# Auth-ETH Service Configuration +# auth-eth Service Configuration -# Server settings -HOST=127.0.0.1 +# Server port (must match the kms.toml webhook URL) PORT=9200 # Ethereum RPC endpoint (Sepolia testnet) ETH_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +# Reject decisions if the RPC serves a different chain (Sepolia) +ETH_CHAIN_ID=11155111 + # KMS Authorization Contract Address KMS_CONTRACT_ADDR=$KMS_CONTRACT_ADDRESS EOF @@ -368,6 +348,8 @@ The Dockerfile bakes all configuration into the image for reliable CVM deploymen ```bash cat > Dockerfile << 'EOF' # KMS Docker Image for CVM Deployment +FROM oven/bun:1.4.4 AS bun + FROM ubuntu:24.04 # Install runtime dependencies @@ -375,10 +357,8 @@ RUN apt-get update && \ apt-get install -y ca-certificates curl && \ rm -rf /var/lib/apt/lists/* -# Install Node.js 20.x for auth-eth -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ - apt-get install -y nodejs && \ - rm -rf /var/lib/apt/lists/* +# Install Bun for auth-eth +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun # Create directories RUN mkdir -p /etc/kms/certs /etc/kms/images /var/run/kms /var/log/kms @@ -389,12 +369,13 @@ RUN chmod 755 /usr/local/bin/dstack-kms # Copy configuration files (baked into image) COPY kms.toml /etc/kms/kms.toml -COPY auth-eth.env /etc/kms/auth-eth.env COPY sgx_default_qcnl.conf /etc/sgx_default_qcnl.conf -# Copy auth-eth service and install dependencies +# Copy auth-eth and install its locked dependencies. Bun loads .env from +# the working directory; variables set in docker-compose take precedence. COPY auth-eth /opt/auth-eth -RUN cd /opt/auth-eth && npm install --production +COPY auth-eth.env /opt/auth-eth/.env +RUN cd /opt/auth-eth && bun install --frozen-lockfile --production --ignore-scripts # Copy startup script COPY start-kms.sh /usr/local/bin/start-kms.sh @@ -417,8 +398,7 @@ set -e # Start auth-eth in background cd /opt/auth-eth -node dist/src/main.js & -AUTH_ETH_PID=$! +bun index.ts & # Wait for auth-eth to be ready sleep 2 @@ -499,8 +479,9 @@ EOF # Copy KMS binary cp ~/dstack/dstack/target/release/dstack-kms . -# Copy auth-eth service -cp -r ~/dstack/dstack/kms/auth-eth auth-eth +# Copy the auth-eth webhook server (not the Foundry project next to it) +mkdir -p auth-eth +cp ~/dstack/dstack/kms/auth-eth/{index.ts,package.json,bun.lock} auth-eth/ # Copy auth-eth environment config cp /etc/kms/auth-eth.env . @@ -568,7 +549,6 @@ services: volumes: # Mount config file from local directory - ./kms.toml:/etc/kms/kms.toml:ro - - ./auth-eth.env:/etc/kms/auth-eth.env:ro # Named volume for persistent certificates - kms-certs:/etc/kms/certs environment: @@ -590,11 +570,11 @@ ls -la ~/kms-deployment/ You should have: - `Dockerfile` - Container build definition - `dstack-kms` - KMS binary -- `auth-eth/` - Auth-eth service directory +- `auth-eth/` - auth-eth webhook server (`index.ts`, `package.json`, `bun.lock`) - `start-kms.sh` - Startup script - `docker-compose.yml` - Deployment manifest - `kms.toml` - KMS configuration -- `auth-eth.env` - Auth-eth environment +- `auth-eth.env` - auth-eth environment - `sgx_default_qcnl.conf` - QCNL configuration for CVM PCCS access ## Step 9: Verify Configuration @@ -647,7 +627,7 @@ curl -s -X POST "$ETH_RPC_URL" \ ``` ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ -│ TEE App │────►│ KMS │────►│ Auth-ETH │ +│ TEE App │────►│ KMS │────►│ auth-eth │ └─────────────┘ └─────────────┘ └──────────────┘ │ │ │ │ │ ▼ @@ -665,8 +645,8 @@ curl -s -X POST "$ETH_RPC_URL" \ ### Data Flow 1. **TEE App** requests key from **KMS** -2. **KMS** calls **Auth-ETH** webhook to verify authorization -3. **Auth-ETH** queries **Ethereum** smart contract +2. **KMS** calls **auth-eth** webhook to verify authorization +3. **auth-eth** queries **Ethereum** smart contract 4. If authorized, **KMS** returns key to app 5. **VMM** orchestrates the overall TEE environment @@ -676,7 +656,7 @@ For detailed solutions, see the [KMS Deployment Troubleshooting Guide](/tutorial - [Build fails with missing dependencies](/tutorial/troubleshooting-kms-deployment#build-fails-with-missing-dependencies) - [Configuration file not found](/tutorial/troubleshooting-kms-deployment#configuration-file-not-found) -- [Auth-eth npm install fails](/tutorial/troubleshooting-kms-deployment#auth-eth-npm-install-fails) +- [auth-eth install fails](/tutorial/troubleshooting-kms-deployment#auth-eth-install-fails) - [Invalid TOML syntax](/tutorial/troubleshooting-kms-deployment#invalid-toml-syntax) - [RPC connection failed](/tutorial/troubleshooting-kms-deployment#rpc-connection-failed) - [Contract address not set](/tutorial/troubleshooting-kms-deployment#contract-address-not-set) diff --git a/docs/tutorials/kms-cvm-deployment.md b/docs/tutorials/kms-cvm-deployment.md index 24c68287e..a8a16ad53 100644 --- a/docs/tutorials/kms-cvm-deployment.md +++ b/docs/tutorials/kms-cvm-deployment.md @@ -73,8 +73,6 @@ Inside the CVM at `/etc/kms/certs/`: | `root-ca.key` | Root CA signing key (P256 ECDSA) | | `rpc.crt` | TLS certificate for RPC server | | `rpc.key` | RPC server private key | -| `tmp-ca.crt` | Temp CA, served by the legacy `GetTempCaCert` bootstrap RPC | -| `tmp-ca.key` | Temp CA private key, served alongside it | | `root-k256.key` | Ethereum signing key (secp256k1) | | `bootstrap-info.json` | Public keys and TDX attestation quote | @@ -137,6 +135,7 @@ services: image: registry.yourdomain.com/dstack-kms:fixed ports: - "9100:9100" + - "9101:9101" volumes: - /var/run/dstack.sock:/var/run/dstack.sock - kms-certs:/etc/kms/certs @@ -186,6 +185,18 @@ configs: [core.auth_api.webhook] url = "http://127.0.0.1:9200" + # Admin listener: serves the key handover to other KMS nodes, so a + # KMS requires it. Only the token's SHA-256 goes into the compose. + [core.admin] + enabled = true + address = "0.0.0.0" + port = 9101 + auth_token_hash = "ADMIN_TOKEN_HASH" + + [core.admin.tls] + key = "/etc/kms/certs/rpc.key" + certs = "/etc/kms/certs/rpc.crt" + [core.onboard] enabled = true auto_bootstrap_domain = "" @@ -205,6 +216,10 @@ sed -i 's|kms.yourdomain.com|kms.your-actual-domain.com|g' docker-compose.yaml # KMS contract address (from contract deployment tutorial) sed -i "s|YOUR_CONTRACT_ADDRESS|$(cat ~/.dstack/secrets/kms-contract-address)|g" docker-compose.yaml + +# KMS admin token: keep the token, put only its SHA-256 in the compose +(umask 077 && openssl rand -hex 32 > ~/.dstack/secrets/kms-admin-token) +sed -i "s|ADMIN_TOKEN_HASH|$(tr -d '\n' < ~/.dstack/secrets/kms-admin-token | sha256sum | cut -d' ' -f1)|g" docker-compose.yaml ``` This docker-compose uses a Docker `configs` section to inject a complete `kms.toml` into the container at `/etc/kms/kms.toml`, overriding the config baked into the image. This approach lets you change KMS configuration without rebuilding the Docker image. @@ -217,6 +232,7 @@ This docker-compose uses a Docker `configs` section to inject a complete `kms.to | `[rpc.tls]` | TLS certificate paths for HTTPS | | `[core.image]` | OS image verification — downloads images from `download.dstack.org` to compute expected TDX measurements | | `[core.auth_api]` | Authentication via auth-eth webhook on localhost:9200 | +| `[core.admin]` | Admin listener on port 9101 with TLS; other KMS nodes onboard through it with the admin token. Without it (or `core.onboard.public_key_handover = true`) the KMS does not start | | `[core.onboard]` | Bootstrap settings — `auto_bootstrap_domain` is empty so KMS enters onboard mode for manual bootstrap | > **Why manual bootstrap?** With `auto_bootstrap_domain` left empty, KMS starts in "onboard mode" — a plain HTTP server on port 9100 that waits for you to trigger bootstrap via an RPC call. This ensures `bootstrap-info.json` (containing the TDX attestation quote and public keys) is written to disk. You'll need this file later to register the KMS on-chain. @@ -318,8 +334,8 @@ Onboarding > > **Critical prerequisite:** before bootstrap can succeed, the KMS must already be authorized by your auth backend. > -> - For `auth-simple`, add the KMS `mrAggregated` to `kms.mrAggregated` -> - For `auth-eth`, add the KMS `mrAggregated` on-chain with `addKmsAggregatedMr(...)` +> - For `dstack-auth`, add the KMS `mrAggregated` to `kms.mrAggregated` +> - For `auth-eth`, add the KMS `mrAggregated` on-chain with `script/Manage.s.sol:AddKmsAggregatedMr` > > You can fetch the value before bootstrap with: > @@ -480,7 +496,7 @@ The TDX quote can be verified by: │ │ │ │ Docker Container │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────┐ ┌──────────────┐ │ │ │ │ -│ │ │ │ │ KMS │◄──│ auth-eth │ │ │ │ │ +│ │ │ │ │ KMS │◄──│ auth-eth │ │ │ │ │ │ │ │ │ └────┬────┘ └──────┬───────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ▼ ▼ │ │ │ │ diff --git a/docs/tutorials/troubleshooting-gateway-deployment.md b/docs/tutorials/troubleshooting-gateway-deployment.md index 650b30ba8..053c42c57 100644 --- a/docs/tutorials/troubleshooting-gateway-deployment.md +++ b/docs/tutorials/troubleshooting-gateway-deployment.md @@ -67,7 +67,7 @@ export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token) **Symptom:** `curl -sk https://localhost:9100/prpc/KMS.GetMeta | jq '.gateway_app_id'` returns the wrong app ID, an empty string, or KMS is unreachable. -**Cause:** The KMS auth-eth service queries the blockchain directly (via `eth_call`) — it does not cache state. If KMS was deployed with a different `ETH_RPC_URL` or the KMS CVM is having connectivity issues, it may fail to read on-chain changes. Alternatively, you may need to redeploy KMS after port binding changes from the KMS tutorial. +**Cause:** The KMS auth-eth service queries the blockchain directly (via `eth_call`) — it does not cache state. If KMS was deployed with a different `ETH_RPC_URL` or the KMS CVM is having connectivity issues, it may fail to read on-chain changes. By default it reads the `finalized` block, so a transaction takes effect only once finalized; set `ETH_BLOCK_TAG=latest` on development chains. Alternatively, you may need to redeploy KMS after port binding changes from the KMS tutorial. **Solution:** Redeploy the KMS CVM: @@ -141,8 +141,8 @@ echo "OS image hash: 0x$OS_IMAGE_HASH" # Whitelist it on the KMS contract export KMS_CONTRACT_ADDR=$(cat ~/.dstack/secrets/kms-contract-address) cast send "$KMS_CONTRACT_ADDR" \ - "addOsImageHash(bytes32)" \ - "0x$OS_IMAGE_HASH" \ + "addOsImageHashAtEpoch(uint256,bytes32)" \ + 0 "0x$OS_IMAGE_HASH" \ --rpc-url "https://ethereum-sepolia-rpc.publicnode.com" \ --private-key "$(cat ~/.dstack/secrets/sepolia-private-key)" ``` @@ -196,9 +196,11 @@ cast call "$(cat ~/.dstack/secrets/gateway-app-id)" \ --rpc-url "https://ethereum-sepolia-rpc.publicnode.com" # If false, add it +# App contracts deployed before app epochs (0.6.x or earlier) take "addComposeHash(bytes32)" "0x$COMPOSE_HASH"; +# see onchain-governance.md#apps-deployed-before-app-epochs. cast send "$(cat ~/.dstack/secrets/gateway-app-id)" \ - "addComposeHash(bytes32)" \ - "0x$COMPOSE_HASH" \ + "addComposeHashAtEpoch(uint256,bytes32)" \ + 0 "0x$COMPOSE_HASH" \ --rpc-url "https://ethereum-sepolia-rpc.publicnode.com" \ --private-key "$(cat ~/.dstack/secrets/sepolia-private-key)" ``` diff --git a/docs/tutorials/troubleshooting-kms-deployment.md b/docs/tutorials/troubleshooting-kms-deployment.md index 7282db94d..0e92efda1 100644 --- a/docs/tutorials/troubleshooting-kms-deployment.md +++ b/docs/tutorials/troubleshooting-kms-deployment.md @@ -27,13 +27,15 @@ This appendix consolidates troubleshooting content from the KMS Deployment tutor ### Artifact not found ``` -Error HH700: Artifact for contract "DstackApp" not found. +Error: could not find artifact ``` -Contracts must be compiled before deployment. Run: +Contracts must be compiled, with their dependencies checked out, before deployment. In `dstack/kms/auth-eth`, run: ```bash -npx hardhat compile +git submodule update --init --recursive lib/forge-std \ + lib/openzeppelin-contracts-upgradeable lib/openzeppelin-foundry-upgrades +forge build ``` ### Insufficient funds @@ -104,19 +106,18 @@ Verify the file exists and has correct permissions: ls -la /etc/kms/kms.toml ``` -### Auth-eth npm install fails +### auth-eth install fails ``` -Error: EACCES permission denied +error: lockfile had changes, but lockfile is frozen ``` -Fix npm permissions: +The copied `auth-eth/` directory does not match its `bun.lock`. Copy it again from a clean checkout of the dstack repository and rebuild the Docker image: ```bash -mkdir -p ~/.npm-global -npm config set prefix '~/.npm-global' -export PATH=~/.npm-global/bin:$PATH -npm install +rm -rf ~/kms-deployment/auth-eth +mkdir -p ~/kms-deployment/auth-eth +cp ~/dstack/dstack/kms/auth-eth/{index.ts,package.json,bun.lock} ~/kms-deployment/auth-eth/ ``` ### Invalid TOML syntax @@ -303,12 +304,12 @@ sudo systemctl restart qgsd ### GetMeta Returns "Connection refused" on Port 9200 -**Symptom:** KMS responds to `GetTempCaCert` but GetMeta returns: +**Symptom:** The KMS health endpoint responds but GetMeta returns: ```json {"error": "error sending request for url (http://127.0.0.1:9200/): ...Connection refused (os error 111)"} ``` -**Root Cause:** auth-eth defaults to port 8000, but kms.toml expects the webhook at port 9200. +**Root Cause:** auth-eth listens on `PORT` (3000 when run from source, 8000 in its release image), but kms.toml expects the webhook at port 9200. **Solution:** Ensure your docker-compose.yaml includes `PORT=9200` in the environment section: @@ -334,7 +335,7 @@ Then regenerate the app-compose.json and redeploy: {"error": "error decoding response body: missing field `status` at line 1 column ..."} ``` -**Root Cause:** auth-eth is running and reachable (port 9200 is correct), but it cannot connect to Ethereum RPC. Without `ETH_RPC_URL` and `KMS_CONTRACT_ADDR`, auth-eth defaults to `http://localhost:8545` (nothing there) and returns a Fastify error instead of the expected `{status: 'ok', ...}` response. +**Root Cause:** auth-eth is reachable (port 9200 is correct), but it cannot read the contract. Without `ETH_RPC_URL` and `KMS_CONTRACT_ADDR`, auth-eth defaults to `http://localhost:8545` (nothing there) and the zero address, and returns `{status: 'error', ...}` instead of the expected `{status: 'ok', ...}` response. If the RPC is unreachable at startup, auth-eth exits instead, and GetMeta reports "Connection refused". **Solution:** Ensure your docker-compose.yaml includes both Ethereum configuration variables: diff --git a/docs/upgrading-to-0.6.md b/docs/upgrading-to-0.6.md index 942a4a7e2..ce5c0aeae 100644 --- a/docs/upgrading-to-0.6.md +++ b/docs/upgrading-to-0.6.md @@ -217,6 +217,10 @@ Both sides must authorize each other first: the new KMS allows the old `os_image_hash`. After onboarding, compare the KMS CA public keys; the CA certificate itself is reissued. The source must be 0.5.8 or later. +KMS releases after 0.6 can join only from a 0.6+ source: a 0.5.x certificate +carries no OS image hash, and they refuse such a source. Upgrade a 0.5.x +cluster to 0.6.x first, then onboard later releases from it. + Keep the old KMS running until every client has moved. While a 0.5.x KMS still verifies 0.6.0 guests, it can only measure guests with exactly 2 GiB or at least 2816 MiB of memory, and it must be able to download the 0.6.0 image. @@ -228,14 +232,27 @@ Configuration changes: - Key release on AMD SEV-SNP, AWS NitroTPM, and AWS Nitro Enclaves stays off until you set `sev_snp_key_release`, `aws_nitro_tpm_key_release`, or `nitro_enclave_key_release`. -- Keep `core.onboard.public_key_handover = true` while any KMS still onboards - through the public port, and turn it off afterwards. +- `core.onboard.public_key_handover` now defaults to `false`, which removes + `KMS.GetKmsKey` from the public port. While a 0.5.x KMS still onboards from + this cluster, it can only use that public entry, so set the option to `true` + on the source node, and remove it once every node is upgraded. +- A KMS without a key handover path does not start. Configure the admin + listener with `[core.admin.tls]` (`key` and `certs`) and an admin token, or + set `core.onboard.public_key_handover = true`. The built-in default enables + the admin listener with TLS, so it needs `core.admin.auth_token`, + `DSTACK_KMS_ADMIN_TOKEN` or `core.admin.auth_token_hash` to start. In a KMS + app compose, which is public, use `auth_token_hash` with a high-entropy random + token (`openssl rand -hex 32`), since its hash can be brute-forced offline. - Auto-bootstrap refuses to overwrite existing root keys. -If you use `auth-simple`, three defaults now deny boots that 0.5.x allowed: an -empty `devices` list denies every device unless `allowAnyDevice` is true, -`allowedTcbStatuses` defaults to `["UpToDate"]`, and any advisory ID on a -quote denies unless listed in `allowedAdvisoryIds`. +If you use `auth-simple`, replace it with `dstack-auth`, which reads the same +config file; see +[Migrating from auth-simple](./dstack-auth-operations.md#migrating-from-auth-simple). +Compared with 0.5.x auth-simple, an empty `devices` list now denies every device +unless `allowAnyDevice` is true. To also deny a non-`UpToDate` TCB or any +advisory ID, as auth-simple did by default, set +`"allowedTcbStatuses": ["UpToDate"]` and `"allowedAdvisoryIds": []`; without +them dstack-auth checks neither. ### Gateway diff --git a/docs/vmm-cli-user-guide.md b/docs/vmm-cli-user-guide.md index c2953d42f..3ab00d98f 100644 --- a/docs/vmm-cli-user-guide.md +++ b/docs/vmm-cli-user-guide.md @@ -526,6 +526,12 @@ Manage trusted KMS public keys for enhanced security: The whitelist is stored in `~/.dstack-vmm/kms-whitelist.json`. +After a KMS root rotation, first remove the old signer with `kms remove`, then +add the new one with `kms add`. The whitelist is a flat set and the CLI cannot +tell which epoch is current, so a signer left in it stays trusted: anyone +holding the rotated, possibly leaked, root can sign env public keys that the +CLI accepts, whatever root epoch the response claims. + ### Updating Running VMs #### Update Environment Variables diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index 877a7a98c..ca00bfe48 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -27,6 +27,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -34,24 +44,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", +] + [[package]] name = "ahash" version = "0.8.12" @@ -625,7 +660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ "byteorder", - "cipher", + "cipher 0.4.4", ] [[package]] @@ -832,6 +867,8 @@ dependencies = [ "dstack-types", "hex", "http-client", + "mock-attestation", + "prpc", "ra-rpc", "ra-tls", "rocket", @@ -968,10 +1005,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.1" @@ -1347,7 +1395,16 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -1376,6 +1433,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -1899,9 +1971,12 @@ version = "0.6.3" dependencies = [ "anyhow", "clap", + "dstack-types", + "hex", "rocket", "serde", "serde_json", + "tempfile", ] [[package]] @@ -2140,7 +2215,9 @@ dependencies = [ "http-client", "k256", "load_config", + "mock-attestation", "moka", + "p256 0.13.2", "parity-scale-codec", "ra-rpc", "ra-tls", @@ -2159,7 +2236,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "x25519-dalek", + "x25519-dalek 2.0.1", "x509-parser 0.16.0", "yasna 0.5.2", ] @@ -2317,7 +2394,7 @@ dependencies = [ name = "dstack-util" version = "0.6.3" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "anyhow", "base64 0.22.1", "binrw", @@ -2326,7 +2403,7 @@ dependencies = [ "cert-client", "clap", "cmd_lib", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "dcap-qvl", "dstack-api-auth", "dstack-attest", @@ -2341,6 +2418,7 @@ dependencies = [ "hex", "hex_fmt", "host-api", + "hpke", "k256", "libc", "lspci", @@ -2373,7 +2451,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", - "x25519-dalek", + "x25519-dalek 2.0.1", "x509-parser 0.16.0", "yaml-rust2", ] @@ -2423,6 +2501,7 @@ dependencies = [ "base64 0.22.1", "bon", "clap", + "dcap-qvl", "dirs 6.0.0", "dstack-api-auth", "dstack-build-info", @@ -2453,6 +2532,7 @@ dependencies = [ "or-panic", "path-absolutize", "ra-rpc", + "ra-tls", "rand 0.8.6", "rocket", "rocket-vsock-listener", @@ -2464,9 +2544,11 @@ dependencies = [ "sha2 0.10.9", "shared_child", "size-parser", + "sodiumbox", "strip-ansi-escapes", "supervisor-client", "tailf", + "tdx-attest", "tempfile", "tokio", "tracing", @@ -2585,7 +2667,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519", "rand_core 0.6.4", "serde", @@ -2612,7 +2694,7 @@ dependencies = [ "ff 0.13.1", "generic-array", "group 0.13.0", - "hkdf", + "hkdf 0.12.4", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -3173,7 +3255,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", ] [[package]] @@ -3481,6 +3572,15 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3521,6 +3621,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hpke" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f512e325a1a71bf3273fb9b7fc9e5d96dac136fb4a95d0e6637001b12ea3e09" +dependencies = [ + "aead 0.6.1", + "aes-gcm 0.11.1", + "curve25519-dalek 5.0.0", + "getrandom 0.4.2", + "hkdf 0.13.0", + "hybrid-array", + "rand_core 0.10.1", + "sha2 0.11.1", + "subtle", + "x25519-dalek 3.0.0", + "zeroize", +] + [[package]] name = "http" version = "1.4.1" @@ -3912,6 +4031,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "insta" version = "1.47.2" @@ -4372,7 +4500,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "x25519-dalek", + "x25519-dalek 2.0.1", "xsalsa20poly1305", ] @@ -5470,7 +5598,7 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -5482,7 +5610,18 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -5942,7 +6081,7 @@ dependencies = [ "fs-err", "hex", "hex_fmt", - "hkdf", + "hkdf 0.12.4", "k256", "or-panic", "p256 0.13.2", @@ -6807,7 +6946,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -6883,10 +7022,10 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" dependencies = [ - "aead", + "aead 0.5.2", "arrayref", "arrayvec", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "getrandom_or_panic", "merlin", "rand_core 0.6.4", @@ -7473,7 +7612,7 @@ dependencies = [ "or-panic", "rand_core 0.6.4", "salsa20", - "x25519-dalek", + "x25519-dalek 2.0.1", "xsalsa20poly1305", ] @@ -8349,6 +8488,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -9332,12 +9481,23 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "x509-cert" version = "0.2.5" @@ -9413,7 +9573,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02a6dad357567f81cd78ee75f7c61f1b30bb2fe4390be8fb7c69e2ac8dffb6c7" dependencies = [ - "aead", + "aead 0.5.2", "poly1305", "salsa20", "subtle", diff --git a/dstack/Cargo.toml b/dstack/Cargo.toml index 2e8753148..7a98c2471 100644 --- a/dstack/Cargo.toml +++ b/dstack/Cargo.toml @@ -170,6 +170,7 @@ base64 = "0.22.1" bcrypt = "0.17.1" hex = { version = "0.4.3", default-features = false } hex_fmt = "0.3.0" +hpke = { version = "0.15.1", default-features = false, features = ["alloc", "getrandom", "x25519", "aes"] } hex-literal = "1.0.0" prost = "0.13.5" prost-types = "0.13.5" diff --git a/dstack/cert-client/Cargo.toml b/dstack/cert-client/Cargo.toml index 092824e28..558389531 100644 --- a/dstack/cert-client/Cargo.toml +++ b/dstack/cert-client/Cargo.toml @@ -20,10 +20,13 @@ tdx-attest.workspace = true dstack-guest-agent-rpc.workspace = true http-client = { workspace = true, features = ["prpc"] } tokio.workspace = true +x509-parser = { workspace = true, features = ["verify"] } [dev-dependencies] +prpc.workspace = true +ra-rpc = { workspace = true, features = ["rocket", "client"] } +mock-attestation = { path = "../crates/mock-attestation" } hex.workspace = true rocket.workspace = true tempfile.workspace = true -x509-parser.workspace = true tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync"] } diff --git a/dstack/cert-client/src/lib.rs b/dstack/cert-client/src/lib.rs index b7fdda86a..3082ee9ce 100644 --- a/dstack/cert-client/src/lib.rs +++ b/dstack/cert-client/src/lib.rs @@ -2,15 +2,15 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use anyhow::{bail, Context, Result}; -use dstack_kms_rpc::{kms_client::KmsClient, SignCertRequest}; +use dstack_kms_rpc::{kms_client::KmsClient, AppKeyResponse, GetAppKeyRequest, SignCertRequest}; use dstack_types::{AppKeys, KeyProvider}; use ra_rpc::client::{CertInfo, RaClient, RaClientConfig}; use ra_tls::{ attestation::{AppInfo, AttestationVerifier, VersionedAttestation}, - cert::{generate_self_signed_ra_cert, CaCert, CertSigningRequestV2}, + cert::{generate_self_signed_ra_cert, CaCert, CertPair, CertSigningRequestV2}, }; pub enum CertRequestClient { @@ -49,6 +49,77 @@ pub fn validate_kms_rpc_cert(cert: Option) -> Result<()> { Ok(()) } +/// Fetch keys directly over the attested connection, without a metadata/CA preflight. +/// A supplied CA always pins the connection, independently of the epoch selector. +/// Callers retain the startup KMS CA for the CVM lifetime, including later key +/// requests. Without a supplied CA, bind the response CA to this connection and +/// establish its identity through the provider pin or launch-measurement policy. +pub async fn request_app_keys( + url: String, + request: GetAppKeyRequest, + client_cert: CertPair, + attestation_verifier: Arc, + trusted_ca: Option, +) -> Result { + let server_cert = Arc::new(Mutex::new(None)); + let captured = server_cert.clone(); + let client = RaClientConfig::builder() + .remote_uri(url) + .tls_client_cert(client_cert.cert_pem) + .tls_client_key(client_cert.key_pem) + .tls_skip_ca_verification(trusted_ca.is_none()) + .tls_built_in_root_certs(false) + .maybe_tls_ca_cert(trusted_ca.clone()) + .attestation_verifier(attestation_verifier) + .cert_validator(Box::new(move |info| { + let info = info.context("missing KMS server certificate")?; + if info.attestation.is_none() { + bail!("KMS server certificate has no verified attestation"); + } + let cert_der = info.cert_der.clone(); + validate_kms_rpc_cert(Some(info))?; + *captured + .lock() + .map_err(|_| anyhow::anyhow!("KMS certificate lock poisoned"))? = Some(cert_der); + Ok(()) + })) + .build() + .into_client()?; + let requested_root_epoch = request.root_epoch; + let requested_app_epoch = request.app_epoch; + let response = KmsClient::new(client).get_app_key(request).await?; + if requested_root_epoch.is_some_and(|epoch| epoch != response.root_epoch) { + bail!("KMS returned a different root epoch than requested"); + } + if requested_app_epoch.is_some_and(|epoch| epoch != response.app_epoch) { + bail!("KMS returned a different app epoch than requested"); + } + let cert = server_cert + .lock() + .map_err(|_| anyhow::anyhow!("KMS certificate lock poisoned"))?; + validate_kms_root( + cert.as_deref().context("missing KMS server certificate")?, + trusted_ca.as_deref().unwrap_or(&response.ca_cert), + )?; + Ok(response) +} + +fn validate_kms_root(server_cert: &[u8], root_ca: &str) -> Result<()> { + let (_, pem) = + x509_parser::pem::parse_x509_pem(root_ca.as_bytes()).context("invalid KMS root CA")?; + let root = pem.parse_x509().context("invalid KMS root CA")?; + let (_, leaf) = + x509_parser::parse_x509_certificate(server_cert).context("invalid KMS RPC certificate")?; + if !root.is_ca() || !root.validity().is_valid() || !leaf.validity().is_valid() { + bail!("invalid or expired KMS certificate"); + } + if leaf.issuer() != root.subject() { + bail!("KMS RPC certificate issuer does not match the root CA"); + } + leaf.verify_signature(Some(root.public_key())) + .context("KMS root CA did not sign the attested RPC certificate") +} + fn kms_client( url: String, ca_cert: String, @@ -101,6 +172,32 @@ impl CertRequestClient { } } + /// Fetch app keys from the startup KMS, using its current epochs for omitted selectors. + pub async fn get_app_key( + &self, + root_epoch: Option, + app_epoch: Option, + ) -> Result { + let CertRequestClient::Kms { client, vm_config } = self else { + bail!("only KMS-provided keys have older root epochs"); + }; + let response = client + .get_app_key(GetAppKeyRequest { + api_version: 1, + vm_config: vm_config.clone(), + root_epoch, + app_epoch, + }) + .await?; + if root_epoch.is_some_and(|epoch| epoch != response.root_epoch) { + bail!("KMS returned a different root epoch than requested"); + } + if app_epoch.is_some_and(|epoch| epoch != response.app_epoch) { + bail!("KMS returned a different app epoch than requested"); + } + Ok(response) + } + pub async fn get_root_ca(&self) -> Result { match self { CertRequestClient::Local { ca } => Ok(ca.pem_cert.clone()), @@ -374,6 +471,340 @@ mod tests { ) } + #[test] + fn generated_epoch_selectors_preserve_omission_and_explicit_zero() { + use prpc::Message; + + let default = GetAppKeyRequest::default(); + assert_eq!((default.root_epoch, default.app_epoch), (None, None)); + for json in ["{}", r#"{"api_version":1,"vm_config":""}"#] { + let request: GetAppKeyRequest = serde_json::from_str(json).unwrap(); + assert_eq!((request.root_epoch, request.app_epoch), (None, None)); + } + for root_epoch in [None, Some(0), Some(7)] { + for app_epoch in [None, Some(0), Some(9)] { + let request = GetAppKeyRequest { + root_epoch, + app_epoch, + ..Default::default() + }; + let json = serde_json::to_vec(&request).unwrap(); + let from_json: GetAppKeyRequest = serde_json::from_slice(&json).unwrap(); + let from_proto = + GetAppKeyRequest::decode(request.encode_to_vec().as_slice()).unwrap(); + for decoded in [from_json, from_proto] { + assert_eq!( + (decoded.root_epoch, decoded.app_epoch), + (root_epoch, app_epoch) + ); + } + } + } + let explicit: GetAppKeyRequest = + serde_json::from_str(r#"{"root_epoch":0,"app_epoch":0}"#).unwrap(); + assert_eq!( + (explicit.root_epoch, explicit.app_epoch), + (Some(0), Some(0)) + ); + } + + #[rocket::post("/GetAppKey", data = "")] + fn direct_app_keys( + request: String, + _cert: rocket::mtls::Certificate<'_>, + state: &rocket::State<(Arc>, Arc>>)>, + ) -> rocket::response::content::RawJson { + state + .1 + .lock() + .unwrap() + .push(serde_json::from_str(&request).unwrap()); + rocket::response::content::RawJson(state.0.lock().unwrap().clone()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn direct_key_fetch_verifies_the_response_connection_without_preflight() { + use ra_tls::attestation::{ + AttestationVerifierConfig, DstackNitroQuote, QuoteContentType, RootCaPaths, + }; + use rocket::tls::Resolver as _; + let dir = tempfile::tempdir().unwrap(); + let attester = mock_attestation::nsm::NsmGenerator::new().unwrap(); + let attestation_root = dir.path().join("attestation-root.pem"); + std::fs::write(&attestation_root, attester.root_ca_pem()).unwrap(); + let verifier = Arc::new( + AttestationVerifier::load(&AttestationVerifierConfig { + insecure_allow_external_trust_anchors: true, + root_ca: RootCaPaths { + aws_nitro_enclave: Some(attestation_root), + ..Default::default() + }, + ..Default::default() + }) + .unwrap(), + ); + let root = kms_root(); + let rpc_key = p256_key(); + let report_data = QuoteContentType::RaTlsCert.to_report_data(&rpc_key.public_key_der()); + let pcrs = [0, 1, 2, 4] + .into_iter() + .map(|i| (i, vec![i as u8; 48])) + .collect(); + let evidence = attester.attest_with_pcrs(&report_data, pcrs).unwrap(); + let attestation = Attestation { + quote: AttestationQuote::DstackNitroEnclave(DstackNitroQuote { + nsm_quote: evidence, + }), + runtime_events: Vec::new(), + report_data, + config: String::new(), + report: (), + } + .into_versioned(); + let rpc_cert = root + .sign( + CertRequest::builder() + .subject("localhost") + .alt_names(&["127.0.0.1".into()]) + .key(&rpc_key) + .special_usage("kms:rpc") + .usage_server_auth(true) + .attestation(&attestation) + .build(), + ) + .unwrap(); + let cert_path = dir.path().join("rpc.crt"); + let key_path = dir.path().join("rpc.key"); + std::fs::write(&cert_path, rpc_cert.pem()).unwrap(); + std::fs::write(&key_path, rpc_key.serialize_pem()).unwrap(); + let response = AppKeyResponse { + ca_cert: root.pem_cert.clone(), + root_epoch: 1, + ..Default::default() + }; + let figment = rocket::Config::figment() + .merge(("port", 0)) + .merge(("address", "127.0.0.1")) + .merge(("log_level", "off")) + .merge(("shutdown.ctrlc", false)) + .merge(("tls.certs", cert_path)) + .merge(("tls.key", key_path)); + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = Mutex::new(Some(tx)); + let response_body = Arc::new(Mutex::new(serde_json::to_string(&response).unwrap())); + let requests = Arc::new(Mutex::new(Vec::::new())); + let server = rocket::custom(figment) + .attach(ra_rpc::ratls_client_verifier::RaTlsClientAuth::fairing()) + .manage((response_body.clone(), requests.clone())) + .mount("/", rocket::routes![direct_app_keys]) + .attach(rocket::fairing::AdHoc::on_liftoff("port", move |rocket| { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(rocket.endpoints().find_map(|e| e.port()).unwrap()); + } + Box::pin(async {}) + })) + .ignite() + .await + .unwrap(); + let shutdown = server.shutdown(); + let task = tokio::spawn(server.launch()); + let url = format!("https://127.0.0.1:{}", rx.await.unwrap()); + let client_cert = || { + let key = p256_key(); + let report_data = QuoteContentType::RaTlsCert.to_report_data(&key.public_key_der()); + let pcrs = [0, 1, 2, 4] + .into_iter() + .map(|i| (i, vec![i as u8; 48])) + .collect(); + let evidence = attester.attest_with_pcrs(&report_data, pcrs).unwrap(); + let attestation = Attestation { + quote: AttestationQuote::DstackNitroEnclave(DstackNitroQuote { + nsm_quote: evidence, + }), + runtime_events: Vec::new(), + report_data, + config: String::new(), + report: (), + } + .into_versioned(); + let cert = CertRequest::builder() + .subject("guest") + .key(&key) + .attestation(&attestation) + .usage_client_auth(true) + .build() + .self_signed() + .unwrap(); + CertPair { + cert_pem: cert.pem(), + key_pem: key.serialize_pem(), + } + }; + let request = || GetAppKeyRequest { + api_version: 1, + vm_config: String::new(), + root_epoch: None, + app_epoch: None, + }; + let result = request_app_keys( + url.clone(), + request(), + client_cert(), + verifier.clone(), + None, + ) + .await + .unwrap(); + assert_eq!(result.ca_cert, response.ca_cert); + assert_eq!(result.root_epoch, 1); + let received = requests.lock().unwrap().last().unwrap().clone(); + assert_eq!((received.root_epoch, received.app_epoch), (None, None)); + + let other_ca = kms_root().pem_cert; + 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, + }; + let selected = AppKeyResponse { + root_epoch: requested_root_epoch.unwrap_or(1), + ..response.clone() + }; + *response_body.lock().unwrap() = serde_json::to_string(&selected).unwrap(); + // Omission and explicit epoch selectors have the same trust semantics. + assert!(request_app_keys( + url.clone(), + request(), + client_cert(), + verifier.clone(), + None + ) + .await + .is_ok()); + // An unrelated pin must fail in TLS, before the RPC handler runs. + assert!(request_app_keys( + url.clone(), + request(), + client_cert(), + verifier.clone(), + Some(other_ca.clone()) + ) + .await + .is_err()); + + let different_epoch_ca = AppKeyResponse { + ca_cert: other_ca.clone(), + ..selected + }; + *response_body.lock().unwrap() = serde_json::to_string(&different_epoch_ca).unwrap(); + // A key response cannot replace the startup CA pin, even for default requests. + let result = request_app_keys( + url.clone(), + request(), + client_cert(), + verifier.clone(), + Some(root.pem_cert.clone()), + ) + .await + .unwrap(); + assert_eq!(result.ca_cert, other_ca); + // First-contact validation still requires the response CA to sign the RPC cert. + assert!(request_app_keys( + url.clone(), + request(), + client_cert(), + verifier.clone(), + None + ) + .await + .is_err()); + } + assert_eq!(requests.lock().unwrap().len(), 10); + + let cert = client_cert(); + let client = CertRequestClient::Kms { + client: kms_client( + url.clone(), + root.pem_cert.clone(), + cert.cert_pem, + cert.key_pem, + verifier.clone(), + ) + .unwrap(), + vm_config: "selector-test".into(), + }; + for root_epoch in [None, Some(0), Some(1)] { + for app_epoch in [None, Some(0), Some(2)] { + let selected = AppKeyResponse { + root_epoch: root_epoch.unwrap_or(3), + app_epoch: app_epoch.unwrap_or(4), + ..response.clone() + }; + *response_body.lock().unwrap() = serde_json::to_string(&selected).unwrap(); + let result = client.get_app_key(root_epoch, app_epoch).await.unwrap(); + assert_eq!( + (result.root_epoch, result.app_epoch), + (selected.root_epoch, selected.app_epoch) + ); + let received = requests.lock().unwrap().last().unwrap().clone(); + assert_eq!( + (received.root_epoch, received.app_epoch), + (root_epoch, app_epoch) + ); + assert_eq!(received.api_version, 1); + assert_eq!(received.vm_config, "selector-test"); + + for (wrong_root, wrong_app, message) in [ + (selected.root_epoch + 1, selected.app_epoch, "root epoch"), + (selected.root_epoch, selected.app_epoch + 1, "app epoch"), + ] { + let mismatched = AppKeyResponse { + root_epoch: wrong_root, + app_epoch: wrong_app, + ..selected.clone() + }; + *response_body.lock().unwrap() = serde_json::to_string(&mismatched).unwrap(); + let result = client.get_app_key(root_epoch, app_epoch).await; + if (message == "root epoch" && root_epoch.is_some()) + || (message == "app epoch" && app_epoch.is_some()) + { + assert!(result.unwrap_err().to_string().contains(message)); + } else { + assert!(result.is_ok()); + } + } + } + } + *response_body.lock().unwrap() = serde_json::to_string(&response).unwrap(); + let production = Arc::new(AttestationVerifier::new_prod(None).unwrap()); + assert!( + request_app_keys(url, request(), client_cert(), production, None) + .await + .is_err() + ); + shutdown.notify(); + task.await.unwrap().unwrap(); + assert_eq!(requests.lock().unwrap().len(), 38); + } + + #[test] + fn the_returned_root_must_sign_the_actual_rpc_certificate() { + let root = kms_root(); + let (leaf, _) = kms_rpc_cert(&root, "kms.example"); + let (_, leaf) = x509_parser::pem::parse_x509_pem(leaf.as_bytes()).unwrap(); + validate_kms_root(&leaf.contents, &root.pem_cert).unwrap(); + let unrelated = kms_root(); + assert!(validate_kms_root(&leaf.contents, &unrelated.pem_cert).is_err()); + assert!(validate_kms_root(&leaf.contents, "not a certificate").is_err()); + assert!(validate_kms_root(b"not a certificate", &root.pem_cert).is_err()); + + let (app_leaf, _) = app_cert(&root, &[0xa1; 20], "kms.example"); + let (_, app_leaf) = x509_parser::pem::parse_x509_pem(app_leaf.as_bytes()).unwrap(); + assert!(validate_kms_root(&app_leaf.contents, &root.pem_cert).is_err()); + } + #[rocket::post("/GetMeta")] fn get_meta() -> &'static str { r#"{"ca_cert":"served ca cert"}"# diff --git a/dstack/crates/dstack-auth/Cargo.toml b/dstack/crates/dstack-auth/Cargo.toml index d9fb1879b..518c712c2 100644 --- a/dstack/crates/dstack-auth/Cargo.toml +++ b/dstack/crates/dstack-auth/Cargo.toml @@ -14,7 +14,12 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +dstack-types.workspace = true +hex.workspace = true clap.workspace = true rocket = { workspace = true, features = ["json"] } serde.workspace = true serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/dstack/crates/dstack-auth/src/main.rs b/dstack/crates/dstack-auth/src/main.rs index 2a752e4b0..06efdb8bb 100644 --- a/dstack/crates/dstack-auth/src/main.rs +++ b/dstack/crates/dstack-auth/src/main.rs @@ -2,26 +2,33 @@ // // SPDX-License-Identifier: Apache-2.0 -//! `dstack-auth` — the single-operator KMS auth webhook (Rust reimplementation -//! of `auth-simple`). +//! `dstack-auth` — the file-based KMS auth webhook. //! //! Runs on the host as `dstack-auth.service`; the KMS-in-CVM reaches it at //! `http://10.0.2.2:` under user-mode networking and POSTs `BootInfo` to -//! `/bootAuth/app` (compose-hash allowlist) and `/bootAuth/kms` (mrAggregated -//! allowlist). The allowlist JSON is re-read on every request, so `dstack run` -//! can add an app without a restart. Fails closed: a missing/invalid allowlist -//! denies everything. +//! `/authorizeApp` (compose-hash allowlist) and `/authorizeKms` (mrAggregated +//! allowlist). Legacy `/bootAuth/*` routes remain available for bootstrap. The +//! allowlist JSON is re-read on every request, so `dstack run` can add an app +//! without a restart. Fails closed: a missing/invalid allowlist denies +//! everything. //! -//! Deliberate single-node deviation from `auth-simple`: it does NOT enforce -//! `tcbStatus == UpToDate`. Real TDX hosts routinely report a non-`UpToDate` -//! TCB (microcode / TDX-module behind), and in the single-node model the -//! operator already controls and trusts their own host, so a hard TCB gate -//! would be friction without a corresponding trust gain here. Re-add the check -//! (capture `tcbStatus`, deny unless `UpToDate`) if this grows into a -//! multi-tenant / hosted deployment. - -use anyhow::Result; +//! The allowlist format is the one the removed `auth-simple` server used, so +//! its config files load unchanged. One default differs: the TCB status and +//! advisory IDs are checked only when the allowlist sets `allowedTcbStatuses` +//! / `allowedAdvisoryIds` (auth-simple defaulted to `["UpToDate"]` and `[]`). +//! Real TDX hosts routinely report a non-`UpToDate` TCB (microcode / TDX +//! module behind), and in the single-node model the operator already controls +//! and trusts their own host, so a hard TCB gate would be friction without a +//! corresponding trust gain there. Deployments that serve other parties should +//! set both fields. +//! +//! KMS boots are not checked against `osImages`: `kms.mrAggregated` already +//! pins the OS image measurements. + +use anyhow::{bail, Context, Result}; use clap::Parser; +use dstack_types::authorization::AuthorizationOperation as Operation; +use rocket::http::Status; use rocket::serde::json::Json; use rocket::{get, post, routes, State}; use serde::{Deserialize, Serialize}; @@ -58,6 +65,16 @@ struct BootInfo { app_id: String, compose_hash: String, device_id: String, + /// verifier-derived TCB status, e.g. `UpToDate`; checked only when the + /// allowlist sets `allowedTcbStatuses`. + tcb_status: String, + /// security advisories reported for the platform; checked only when the + /// allowlist sets `allowedAdvisoryIds`. + advisory_ids: Vec, + /// the root epoch the asking KMS serves and its chain hash; absent from a + /// KMS that predates root epochs, and from KMS checks made while onboarding. + root_epoch: Option, + root_epoch_hash: Option, } #[derive(Serialize)] @@ -68,13 +85,114 @@ struct BootResponse { reason: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthorizationRequest { + operation: Operation, + #[serde(flatten)] + boot_info: BootInfo, + requested_root_epoch: Option, + requested_app_epoch: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AuthorizationResult { + authorized: bool, + rejection_reason: String, + gateway_app_id: String, + current_root_epoch: usize, + current_root_epoch_hash: String, + current_app_epoch: u32, + latest_root_epoch: usize, + latest_root_epoch_hash: String, +} + +/// a KMS root epoch and its chain hash, which commits to the root public keys +/// of epochs 0..=number. +struct EpochInfo { + number: usize, + hash: String, +} + #[derive(Deserialize, Default)] #[serde(rename_all = "camelCase", default)] struct Allowlist { os_images: Vec, + /// when set, deny unless the reported `tcbStatus` is listed; an empty list + /// denies everything. Absent: the TCB status is not checked. + allowed_tcb_statuses: Option>, + /// when set, deny any reported advisory ID that is not listed; an empty + /// list denies any advisory. Absent: advisories are not checked. + allowed_advisory_ids: Option>, + kms_contract_addr: String, gateway_app_id: String, + /// informational, reported by `GET /`. + chain_id: u64, + /// informational, reported by `GET /`. + app_implementation: String, kms: KmsRules, apps: HashMap, + /// root public keys of each KMS root epoch, indexed by epoch; append only. + root_epochs: Vec, + /// the root epoch KMS instances serve; raise it once every node holds it. + current_root_epoch: usize, +} + +#[derive(Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase", default)] +struct RootEpoch { + ca_pubkey: String, + k256_pubkey: String, +} + +impl Allowlist { + fn app(&self, app_id: &str) -> Option<&AppRules> { + let app_id = norm(app_id); + self.apps + .iter() + .find(|(id, _)| norm(id) == app_id) + .map(|(_, app)| app) + } + + /// `None` only when no epoch is configured; invalid configuration is an error. + fn current_root_epoch(&self) -> Result> { + Ok(self.root_epoch_info()?.map(|(current, _)| current)) + } + + /// the current and the latest configured root epochs; configured epochs + /// beyond the current one are pending activation. + fn root_epoch_info(&self) -> Result> { + if self.root_epochs.is_empty() && self.current_root_epoch == 0 { + return Ok(None); + } + u32::try_from(self.root_epochs.len()).context("root epoch exceeds uint32")?; + if self.current_root_epoch >= self.root_epochs.len() { + bail!("currentRootEpoch is not a configured root epoch"); + } + let keys = self + .root_epochs + .iter() + .map(|e| { + Ok(( + hex::decode(norm(&e.ca_pubkey)).context("invalid root epoch CA public key")?, + hex::decode(norm(&e.k256_pubkey)) + .context("invalid root epoch k256 public key")?, + )) + }) + .collect::>>()?; + let hashes = dstack_types::root_epoch::chain_hashes( + keys.iter().map(|(ca, k256)| (&ca[..], &k256[..])), + ); + let info = |number: usize| EpochInfo { + number, + hash: format!("0x{}", hex::encode(hashes[number])), + }; + Ok(Some(( + info(self.current_root_epoch), + info(self.root_epochs.len() - 1), + ))) + } } #[derive(Deserialize, Default)] @@ -88,6 +206,7 @@ struct KmsRules { #[derive(Deserialize, Default)] #[serde(rename_all = "camelCase", default)] struct AppRules { + current_app_epoch: u32, compose_hashes: Vec, devices: Vec, allow_any_device: bool, @@ -111,10 +230,9 @@ fn contains(list: &[String], value: &str) -> bool { list.iter().any(|x| norm(x) == v) } -/// matches auth-simple: an empty `devices` list means "any device" even when -/// `allowAnyDevice` is false (it only enforces a non-empty list). +/// 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 || devices.is_empty() || contains(devices, device_id) + allow_any || contains(devices, device_id) } fn deny(al: &Allowlist, reason: &str) -> BootResponse { @@ -125,25 +243,68 @@ fn deny(al: &Allowlist, reason: &str) -> BootResponse { } } -fn allow(al: &Allowlist) -> BootResponse { - BootResponse { - is_allowed: true, - gateway_app_id: al.gateway_app_id.clone(), - reason: "ok".to_string(), +/// allow, provided the asking KMS serves the current root epoch with the +/// registered keys up to it. A KMS that predates root epochs reports none and +/// holds only epoch 0, so it is served only until another epoch is activated. +fn allow(info: &BootInfo, al: &Allowlist) -> BootResponse { + let current = match al.current_root_epoch() { + Ok(current) => current, + Err(error) => return deny(al, &format!("invalid root epoch configuration: {error}")), + }; + let current_epoch = current.as_ref().map_or(0, |c| c.number); + let denial = match (info.root_epoch, ¤t) { + (None, _) if current_epoch == 0 => None, + (None, _) => Some(format!( + "root epoch {current_epoch} is current, but the KMS reports none" + )), + (Some(0), None) => None, + (Some(epoch), None) => Some(format!("root epoch {epoch} is not registered")), + (Some(epoch), Some(_)) if epoch != current_epoch => Some(format!( + "the KMS serves root epoch {epoch}, but {current_epoch} is current" + )), + (Some(_), Some(c)) if info.root_epoch_hash.as_deref().map(norm) != Some(norm(&c.hash)) => { + Some(format!( + "the KMS root keys up to epoch {current_epoch} are not the registered ones" + )) + } + _ => None, + }; + match denial { + Some(reason) => deny(al, &reason), + None => BootResponse { + is_allowed: true, + gateway_app_id: al.gateway_app_id.clone(), + reason: "ok".to_string(), + }, } } -fn check_app(info: &BootInfo, al: &Allowlist) -> BootResponse { - if !al.os_images.is_empty() && !contains(&al.os_images, &info.os_image_hash) { +/// the opt-in platform checks shared by app and KMS policy. +fn tcb_denial(info: &BootInfo, al: &Allowlist) -> Option<&'static str> { + if let Some(allowed) = &al.allowed_tcb_statuses { + if !allowed.contains(&info.tcb_status) { + return Some("tcb status not allowed"); + } + } + if let Some(allowed) = &al.allowed_advisory_ids { + if info.advisory_ids.iter().any(|id| !allowed.contains(id)) { + return Some("advisory id not allowed"); + } + } + None +} + +fn app_policy(info: &BootInfo, al: &Allowlist) -> BootResponse { + if let Some(reason) = tcb_denial(info, al) { + return deny(al, reason); + } + if al.os_images.is_empty() { + return deny(al, "os image allowlist is empty"); + } + if !contains(&al.os_images, &info.os_image_hash) { return deny(al, "os image not allowed"); } - let app_id = norm(&info.app_id); - let Some(app) = al - .apps - .iter() - .find(|(k, _)| norm(k) == app_id) - .map(|(_, v)| v) - else { + let Some(app) = al.app(&info.app_id) else { return deny(al, "app not registered"); }; if !contains(&app.compose_hashes, &info.compose_hash) { @@ -152,17 +313,181 @@ fn check_app(info: &BootInfo, al: &Allowlist) -> BootResponse { if !device_ok(app.allow_any_device, &app.devices, &info.device_id) { return deny(al, "device not allowed"); } - allow(al) + BootResponse { + is_allowed: true, + gateway_app_id: al.gateway_app_id.clone(), + reason: String::new(), + } } -fn check_kms(info: &BootInfo, al: &Allowlist) -> BootResponse { +fn kms_policy(info: &BootInfo, al: &Allowlist) -> BootResponse { + if let Some(reason) = tcb_denial(info, al) { + return deny(al, reason); + } if !contains(&al.kms.mr_aggregated, &info.mr_aggregated) { return deny(al, "kms mrAggregated not allowed"); } if !device_ok(al.kms.allow_any_device, &al.kms.devices, &info.device_id) { return deny(al, "device not allowed"); } - allow(al) + BootResponse { + is_allowed: true, + gateway_app_id: al.gateway_app_id.clone(), + reason: String::new(), + } +} + +fn check_app(info: &BootInfo, al: &Allowlist) -> BootResponse { + if al + .app(&info.app_id) + .is_some_and(|app| app.current_app_epoch != 0) + { + return deny(al, "app epoch requires epoch-aware authorization"); + } + let result = app_policy(info, al); + if result.is_allowed { + allow(info, al) + } else { + result + } +} + +fn check_kms(info: &BootInfo, al: &Allowlist) -> BootResponse { + let result = kms_policy(info, al); + if result.is_allowed { + allow(info, al) + } else { + result + } +} + +fn authorize( + request: &AuthorizationRequest, + is_kms: bool, + al: &Allowlist, +) -> Result { + let epochs = al.root_epoch_info()?; + let current = epochs.as_ref().map(|(current, _)| current); + let latest = epochs.as_ref().map(|(_, latest)| latest); + let zero_hash = || format!("0x{}", "00".repeat(32)); + let mut result = AuthorizationResult { + authorized: false, + rejection_reason: String::new(), + gateway_app_id: al.gateway_app_id.clone(), + current_root_epoch: current.map_or(0, |epoch| epoch.number), + current_root_epoch_hash: current.map_or_else(zero_hash, |epoch| epoch.hash.clone()), + latest_root_epoch: latest.map_or(0, |epoch| epoch.number), + latest_root_epoch_hash: latest.map_or_else(zero_hash, |epoch| epoch.hash.clone()), + current_app_epoch: if is_kms { + 0 + } else { + al.app(&request.boot_info.app_id) + .map_or(0, |app| app.current_app_epoch) + }, + }; + let info = &request.boot_info; + // key handover may carry a registered but not yet active epoch, so the + // keyring can be distributed before activation; key-issuing operations + // stay on active epochs. + let handover = matches!( + request.operation, + Operation::KmsExportKeys | Operation::KmsImportKeys + ); + result.rejection_reason = if request.operation.is_kms() != is_kms { + "invalid authorization operation".into() + } else if current.is_none() && request.operation != Operation::KmsBootstrap { + "root epoch not registered".into() + } else if request + .requested_root_epoch + .is_some_and(|epoch| epoch as usize > result.latest_root_epoch) + { + "requested root epoch is not registered".into() + } else if !handover + && request + .requested_root_epoch + .is_some_and(|epoch| epoch as usize > result.current_root_epoch) + { + "requested root epoch is not active".into() + } else if request + .requested_app_epoch + .is_some_and(|epoch| epoch > result.current_app_epoch) + { + "requested app epoch is not active".into() + } else if request.operation != Operation::KmsBootstrap && norm(&al.kms_contract_addr).is_empty() + { + "kmsContractAddr is not configured".into() + } else if request.operation != Operation::KmsBootstrap + && (norm(&info.app_id) == norm(&al.kms_contract_addr)) != is_kms + { + "caller does not match authorization scope".into() + } else { + let policy = if is_kms { + kms_policy(info, al) + } else { + app_policy(info, al) + }; + result.authorized = policy.is_allowed; + policy.reason + }; + Ok(result) +} + +fn unavailable(error: anyhow::Error) -> Status { + rocket::warn!("invalid authorization configuration: {error}"); + Status::ServiceUnavailable +} + +/// authorize and log the decision with the identities an operator needs to +/// extend the allowlist (device ID, measurements, TCB status). +fn authorize_logged( + request: &AuthorizationRequest, + is_kms: bool, + config: &PathBuf, +) -> Result, Status> { + let result = authorize(request, is_kms, &load(config)).map_err(unavailable)?; + let info = &request.boot_info; + rocket::info!( + "authorize {:?} app={} compose={} mr={} os={} device={} tcb={} advisories={:?} -> authorized={} ({})", + request.operation, + norm(&info.app_id), + norm(&info.compose_hash), + norm(&info.mr_aggregated), + norm(&info.os_image_hash), + norm(&info.device_id), + info.tcb_status, + info.advisory_ids, + result.authorized, + result.rejection_reason + ); + Ok(Json(result)) +} + +#[post("/authorizeApp", data = "")] +fn authorize_app( + request: Json, + cli: &State, +) -> Result, Status> { + authorize_logged(&request, false, &cli.config) +} + +#[post("/authorizeKms", data = "")] +fn authorize_kms( + request: Json, + cli: &State, +) -> Result, Status> { + authorize_logged(&request, true, &cli.config) +} + +#[get("/app/")] +fn app_epoch(app_id: &str, cli: &State) -> Result, Status> { + let id = norm(app_id); + if hex::decode(&id).map_or(true, |bytes| bytes.len() != 20) { + return Err(Status::BadRequest); + } + // An unknown app is not epoch zero: its epoch cannot be vouched for. + let app = load(&cli.config); + let app = app.app(&id).ok_or(Status::NotFound)?; + Ok(Json(json!({ "currentAppEpoch": app.current_app_epoch }))) } /// load the allowlist, failing closed (deny-all) if it's missing or invalid. @@ -183,9 +508,12 @@ fn load(path: &PathBuf) -> Allowlist { fn boot_app(info: Json, cli: &State) -> Json { let r = check_app(&info, &load(&cli.config)); rocket::info!( - "bootAuth/app app={} compose={} -> allowed={} ({})", + "bootAuth/app app={} compose={} os={} device={} tcb={} -> allowed={} ({})", norm(&info.app_id), norm(&info.compose_hash), + norm(&info.os_image_hash), + norm(&info.device_id), + info.tcb_status, r.is_allowed, r.reason ); @@ -196,36 +524,63 @@ fn boot_app(info: Json, cli: &State) -> Json { fn boot_kms(info: Json, cli: &State) -> Json { let r = check_kms(&info, &load(&cli.config)); rocket::info!( - "bootAuth/kms mr={} -> allowed={} ({})", + "bootAuth/kms mr={} device={} tcb={} -> allowed={} ({})", norm(&info.mr_aggregated), + norm(&info.device_id), + info.tcb_status, r.is_allowed, r.reason ); Json(r) } -/// info endpoint the KMS GETs to populate its metadata. Single-node: no chain. +/// info endpoint the KMS GETs to populate its metadata. #[get("/")] -fn info() -> Json { - Json(json!({ +fn info(cli: &State) -> Result, Status> { + info_json(&load(&cli.config)).map(Json).map_err(|error| { + rocket::warn!("invalid root epoch configuration: {error}"); + Status::ServiceUnavailable + }) +} + +fn info_json(al: &Allowlist) -> Result { + let mut info = json!({ "status": "ok", - "kmsContractAddr": "", + "kmsContractAddr": al.kms_contract_addr, "ethRpcUrl": "", - "gatewayAppId": "", - "chainId": 0, - "appImplementation": "" - })) + "gatewayAppId": al.gateway_app_id, + "chainId": al.chain_id, + // `appAuthImplementation` is the older name of `appImplementation`. + "appAuthImplementation": al.app_implementation, + "appImplementation": al.app_implementation, + }); + if let Some(epoch) = al.current_root_epoch()? { + info["rootEpoch"] = epoch.number.into(); + info["rootEpochHash"] = epoch.hash.into(); + } + Ok(info) } -#[rocket::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); +fn server(cli: Cli) -> rocket::Rocket { let figment = rocket::Config::figment() .merge(("address", cli.address.clone())) .merge(("port", cli.port)); - rocket::custom(figment) - .manage(cli) - .mount("/", routes![info, boot_app, boot_kms]) + rocket::custom(figment).manage(cli).mount( + "/", + routes![ + info, + boot_app, + boot_kms, + authorize_app, + authorize_kms, + app_epoch + ], + ) +} + +#[rocket::main] +async fn main() -> Result<()> { + server(Cli::parse()) .launch() .await .map_err(|e| anyhow::anyhow!("auth webhook failed: {e}"))?; @@ -236,6 +591,425 @@ async fn main() -> Result<()> { mod tests { use super::*; + #[rocket::async_test] + async fn authorization_routes_use_registered_roots_and_separate_caller_scopes() { + use rocket::{http::ContentType, local::asynchronous::Client}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("allowlist.json"); + let mut policy = json!({ + "kmsContractAddr": "0x1111111111111111111111111111111111111111", + "rootEpochs": [{"caPubkey": "aa", "k256Pubkey": "bb"}], + "osImages": ["aa"], + "kms": {"mrAggregated": ["bb"], "allowAnyDevice": true}, + "apps": {"0x2222222222222222222222222222222222222222": {"composeHashes": ["cc"], "allowAnyDevice": true}} + }); + std::fs::write(&path, policy.to_string()).unwrap(); + let client = Client::tracked(server(Cli { + config: path.clone(), + address: "127.0.0.1".into(), + port: 0, + })) + .await + .unwrap(); + let kms = json!({"operation": "kms_serve", "appId": "11".repeat(20), "mrAggregated": "bb"}); + let app = json!({"operation": "app_get_key", "appId": "22".repeat(20), "composeHash": "cc", "osImageHash": "aa"}); + let with_operation = |body: &serde_json::Value, operation: &str| { + let mut body = body.clone(); + body["operation"] = operation.into(); + body + }; + for (route, body, allowed) in [ + ("/authorizeKms", kms.clone(), true), + ("/authorizeApp", app.clone(), true), + ("/authorizeKms", app.clone(), false), + ("/authorizeApp", kms.clone(), false), + // The caller matches the route, but the operation belongs to the other scope. + ("/authorizeKms", with_operation(&kms, "app_get_key"), false), + ("/authorizeApp", with_operation(&app, "kms_serve"), false), + ] { + let response = client + .post(route) + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let response: serde_json::Value = response.into_json().await.unwrap(); + assert_eq!(response["authorized"], allowed); + assert_eq!(response["currentRootEpoch"], 0); + assert_eq!(response["currentAppEpoch"], 0); + assert_eq!(response["latestRootEpoch"], 0); + assert_eq!( + response["latestRootEpochHash"], + response["currentRootEpochHash"] + ); + assert_ne!( + response["currentRootEpochHash"], + format!("0x{}", "00".repeat(32)) + ); + } + for operation in [serde_json::Value::Null, json!("unknown")] { + let mut body = app.clone(); + body["operation"] = operation; + let response = client + .post("/authorizeApp") + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert!(matches!( + response.status(), + Status::BadRequest | Status::UnprocessableEntity + )); + } + for field in ["requestedRootEpoch", "requestedAppEpoch"] { + let mut body = app.clone(); + body[field] = 1.into(); + let response: serde_json::Value = client + .post("/authorizeApp") + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(response["authorized"], false); + } + policy["rootEpochs"] = json!([]); + std::fs::write(&path, policy.to_string()).unwrap(); + let response: serde_json::Value = client + .post("/authorizeKms") + .header(ContentType::JSON) + .body(kms.to_string()) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(response["authorized"], false); + assert_eq!( + response["currentRootEpochHash"], + format!("0x{}", "00".repeat(32)) + ); + assert_eq!(response["latestRootEpoch"], 0); + assert_eq!( + response["latestRootEpochHash"], + format!("0x{}", "00".repeat(32)) + ); + policy["currentRootEpoch"] = 1.into(); + std::fs::write(&path, policy.to_string()).unwrap(); + assert_eq!( + client + .post("/authorizeApp") + .header(ContentType::JSON) + .body(app.to_string()) + .dispatch() + .await + .status(), + Status::ServiceUnavailable + ); + } + + #[rocket::async_test] + async fn app_epochs_share_normalized_policy_and_reject_legacy_boot() { + use rocket::{http::ContentType, local::asynchronous::Client}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("allowlist.json"); + let client = Client::tracked(server(Cli { + config: path.clone(), + address: "127.0.0.1".into(), + port: 0, + })) + .await + .unwrap(); + let id = "ab".repeat(20); + for configured_id in [ + id.clone(), + format!("0X{}", id.to_uppercase()), + format!("0x{id}"), + ] { + let policy = json!({ + "kmsContractAddr": "11".repeat(20), + "rootEpochs": [{"caPubkey": "aa", "k256Pubkey": "bb"}, {"caPubkey": "cc", "k256Pubkey": "dd"}], + "currentRootEpoch": 1, + "osImages": ["aa"], + "apps": {configured_id: {"currentAppEpoch": 2, "composeHashes": ["ee"], "allowAnyDevice": true}} + }); + std::fs::write(&path, policy.to_string()).unwrap(); + let response: serde_json::Value = client + .get(format!("/app/0x{id}")) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(response["currentAppEpoch"], 2); + for selectors in [ + json!({}), + json!({"requestedRootEpoch": 0, "requestedAppEpoch": 0}), + json!({"requestedRootEpoch": 1, "requestedAppEpoch": 2}), + json!({"requestedAppEpoch": 3}), + json!({"requestedRootEpoch": 2}), + ] { + let allowed = + selectors["requestedAppEpoch"] != 3 && selectors["requestedRootEpoch"] != 2; + let mut request = selectors; + request["operation"] = "app_get_key".into(); + request["appId"] = id.clone().into(); + request["composeHash"] = "ee".into(); + request["osImageHash"] = "aa".into(); + let response: serde_json::Value = client + .post("/authorizeApp") + .header(ContentType::JSON) + .body(request.to_string()) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(response["authorized"], allowed); + assert_eq!(response["currentAppEpoch"], 2); + assert_eq!(response["currentRootEpoch"], 1); + } + let body = json!({"appId": id, "composeHash": "ee"}); + let response: serde_json::Value = client + .post("/bootAuth/app") + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(response["isAllowed"], false); + } + assert_eq!( + client.get("/app/not-hex").dispatch().await.status(), + Status::BadRequest + ); + assert_eq!( + client + .get(format!("/app/0x{}", "cd".repeat(20))) + .dispatch() + .await + .status(), + Status::NotFound + ); + std::fs::remove_file(&path).unwrap(); + assert_eq!( + client.get(format!("/app/0x{id}")).dispatch().await.status(), + Status::NotFound + ); + } + + async fn post_json( + client: &rocket::local::asynchronous::Client, + route: &str, + body: &serde_json::Value, + ) -> serde_json::Value { + let response = client + .post(route.to_string()) + .header(rocket::http::ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok, "{route}"); + response.into_json().await.unwrap() + } + + async fn client_for(path: &std::path::Path) -> rocket::local::asynchronous::Client { + rocket::local::asynchronous::Client::tracked(server(Cli { + config: path.to_path_buf(), + address: "127.0.0.1".into(), + port: 0, + })) + .await + .unwrap() + } + + #[rocket::async_test] + async fn tcb_status_and_advisories_are_checked_only_when_configured() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("allowlist.json"); + let client = client_for(&path).await; + let base = json!({ + "kmsContractAddr": "11".repeat(20), + "rootEpochs": [{"caPubkey": "aa", "k256Pubkey": "bb"}], + "osImages": ["aa"], + "kms": {"mrAggregated": ["bb"], "allowAnyDevice": true}, + "apps": {"22".repeat(20): {"composeHashes": ["cc"], "allowAnyDevice": true}} + }); + let kms = json!({"operation": "kms_serve", "appId": "11".repeat(20), "mrAggregated": "bb", "osImageHash": "aa"}); + let app = json!({"operation": "app_get_key", "appId": "22".repeat(20), "composeHash": "cc", "osImageHash": "aa"}); + let legacy = json!({"appId": "22".repeat(20), "composeHash": "cc", "osImageHash": "aa"}); + let platform = + |tcb: &str, advisories: &[&str]| json!({"tcbStatus": tcb, "advisoryIds": advisories}); + let cases = [ + // Absent fields: neither the TCB status nor advisories are checked. + (json!({}), platform("OutOfDate", &["INTEL-SA-00001"]), true), + (json!({}), platform("", &[]), true), + ( + json!({"allowedTcbStatuses": ["UpToDate"]}), + platform("UpToDate", &[]), + true, + ), + ( + json!({"allowedTcbStatuses": ["UpToDate"]}), + platform("OutOfDate", &[]), + false, + ), + ( + json!({"allowedTcbStatuses": ["UpToDate"]}), + platform("", &[]), + false, + ), + ( + json!({"allowedTcbStatuses": ["UpToDate", "SWHardeningNeeded"]}), + platform("SWHardeningNeeded", &[]), + true, + ), + ( + json!({"allowedTcbStatuses": []}), + platform("UpToDate", &[]), + false, + ), + ( + json!({"allowedAdvisoryIds": []}), + platform("OutOfDate", &[]), + true, + ), + ( + json!({"allowedAdvisoryIds": []}), + platform("UpToDate", &["INTEL-SA-00001"]), + false, + ), + ( + json!({"allowedAdvisoryIds": ["INTEL-SA-00001"]}), + platform("UpToDate", &["INTEL-SA-00001"]), + true, + ), + ( + json!({"allowedAdvisoryIds": ["INTEL-SA-00001"]}), + platform("UpToDate", &["INTEL-SA-00001", "INTEL-SA-00002"]), + false, + ), + ]; + for (policy_fields, platform, allowed) in cases { + let mut policy = base.clone(); + policy + .as_object_mut() + .unwrap() + .extend(policy_fields.as_object().unwrap().clone()); + std::fs::write(&path, policy.to_string()).unwrap(); + let with_platform = |body: &serde_json::Value| { + let mut body = body.clone(); + body.as_object_mut() + .unwrap() + .extend(platform.as_object().unwrap().clone()); + body + }; + let context = format!("{policy_fields} {platform}"); + let response = post_json(&client, "/authorizeKms", &with_platform(&kms)).await; + assert_eq!(response["authorized"], allowed, "kms {context}"); + let response = post_json(&client, "/authorizeApp", &with_platform(&app)).await; + assert_eq!(response["authorized"], allowed, "app {context}"); + let response = post_json(&client, "/bootAuth/app", &with_platform(&legacy)).await; + assert_eq!(response["isAllowed"], allowed, "bootAuth/app {context}"); + if !allowed { + let reason = response["reason"].as_str().unwrap(); + assert!( + reason.starts_with("tcb status") || reason.starts_with("advisory"), + "{reason}" + ); + } + } + } + + /// A config written for the removed auth-simple server, with every field it + /// read, keeps loading and authorizing. + #[rocket::async_test] + async fn an_auth_simple_config_file_still_authorizes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth-config.json"); + let app_id = "0x7a3b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5B"; + let gateway_id = "0x75537828f2ce51be7289709686A69CbFDbB714F1"; + let kms_id = "0x1111111111111111111111111111111111111111"; + let config = json!({ + "gatewayAppId": gateway_id, + "kmsContractAddr": kms_id, + "chainId": 2035, + "appImplementation": "0x3333333333333333333333333333333333333333", + "osImages": ["0x0B327BCD"], + "allowedTcbStatuses": ["UpToDate"], + "allowedAdvisoryIds": [], + "kms": {"mrAggregated": ["0xAbCd"], "devices": ["0xD1"], "allowAnyDevice": false}, + "apps": { + app_id: {"currentAppEpoch": 0, "composeHashes": ["0x700A"], "devices": ["0xD2"], "allowAnyDevice": false}, + gateway_id: {"composeHashes": ["0x0123"], "allowAnyDevice": true} + }, + "rootEpochs": [{"caPubkey": "0xaa", "k256Pubkey": "0xbb"}], + "currentRootEpoch": 0 + }); + std::fs::write(&path, config.to_string()).unwrap(); + let client = client_for(&path).await; + + let info: serde_json::Value = client.get("/").dispatch().await.into_json().await.unwrap(); + assert_eq!(info["gatewayAppId"], gateway_id); + assert_eq!(info["kmsContractAddr"], kms_id); + assert_eq!(info["chainId"], 2035); + assert_eq!( + info["appImplementation"], + "0x3333333333333333333333333333333333333333" + ); + assert_eq!(info["appAuthImplementation"], info["appImplementation"]); + assert_eq!(info["rootEpoch"], 0); + + // Hex values match regardless of `0x` prefix and case, as in auth-simple. + let app = json!({ + "operation": "app_get_key", "appId": app_id.to_lowercase(), "composeHash": "700a", + "osImageHash": "0b327bcd", "deviceId": "d2", "tcbStatus": "UpToDate", "advisoryIds": [] + }); + let response = post_json(&client, "/authorizeApp", &app).await; + assert_eq!(response["authorized"], true, "{response}"); + assert_eq!(response["gatewayAppId"], gateway_id); + let mut other_device = app.clone(); + other_device["deviceId"] = "d1".into(); + let response = post_json(&client, "/authorizeApp", &other_device).await; + assert_eq!(response["authorized"], false); + assert_eq!(response["rejectionReason"], "device not allowed"); + + let kms = json!({ + "operation": "kms_serve", "appId": kms_id, "mrAggregated": "abcd", + "deviceId": "0xD1", "tcbStatus": "UpToDate", "advisoryIds": [] + }); + let response = post_json(&client, "/authorizeKms", &kms).await; + assert_eq!(response["authorized"], true, "{response}"); + let mut legacy = kms.clone(); + legacy.as_object_mut().unwrap().remove("operation"); + legacy["rootEpoch"] = 0.into(); + legacy["rootEpochHash"] = response["currentRootEpochHash"].clone(); + let response = post_json(&client, "/bootAuth/kms", &legacy).await; + assert_eq!(response["isAllowed"], true, "{response}"); + legacy["tcbStatus"] = "OutOfDate".into(); + let response = post_json(&client, "/bootAuth/kms", &legacy).await; + assert_eq!(response["isAllowed"], false); + assert_eq!(response["reason"], "tcb status not allowed"); + + let epoch: serde_json::Value = client + .get(format!( + "/app/{}", + app_id.to_uppercase().replacen("0X", "0x", 1) + )) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(epoch["currentAppEpoch"], 0); + } + fn allowlist() -> Allowlist { serde_json::from_str( r#"{ @@ -271,6 +1045,58 @@ mod tests { assert!(!check_app(&boot("0xApp1", "0xHASH", "0xnope"), &al).is_allowed); } + #[test] + fn empty_lists_allow_nothing() { + let device = |devices: &[&str], allow_any| -> Allowlist { + let devices: Vec<_> = devices.iter().map(|d| d.to_string()).collect(); + serde_json::from_value(json!({ + "osImages": ["0xIMG"], + "kms": {"mrAggregated": ["0xMR"], "devices": devices, "allowAnyDevice": allow_any}, + "apps": {"0xApp1": {"composeHashes": ["0xHASH"], "devices": devices, "allowAnyDevice": allow_any}} + })) + .unwrap() + }; + let mut app = boot("0xApp1", "0xHASH", "0xIMG"); + app.device_id = "0xDEV".into(); + let kms = BootInfo { + mr_aggregated: "0xMR".into(), + device_id: "0xDEV".into(), + ..Default::default() + }; + for al in [device(&[], false), device(&["0xOTHER"], false)] { + assert_eq!(check_app(&app, &al).reason, "device not allowed"); + assert_eq!(check_kms(&kms, &al).reason, "device not allowed"); + } + for al in [device(&["0xdev"], false), device(&[], true)] { + assert!(check_app(&app, &al).is_allowed); + assert!(check_kms(&kms, &al).is_allowed); + } + let mut al = allowlist(); + al.os_images.clear(); + assert_eq!(check_app(&app, &al).reason, "os image allowlist is empty"); + } + + /// The shape `dstack-cli-core` generates (`auth_allowlist_json` plus an app + /// entry added by `register_app_in_allowlist`) keeps working. + #[test] + fn the_generated_default_allowlist_still_allows() { + let al: Allowlist = serde_json::from_value(json!({ + "osImages": ["0xIMG"], + "kms": {"mrAggregated": ["0xMR"], "devices": [], "allowAnyDevice": true}, + "apps": {"app1": {"composeHashes": ["hash"], "devices": [], "allowAnyDevice": true}} + })) + .unwrap(); + let mut app = boot("0xAPP1", "0xHASH", "img"); + app.device_id = "0xANY".into(); + assert!(check_app(&app, &al).is_allowed); + let kms = BootInfo { + mr_aggregated: "0xmr".into(), + device_id: "0xANY".into(), + ..Default::default() + }; + assert!(check_kms(&kms, &al).is_allowed); + } + #[test] fn kms_allowlist_and_empty_default() { let al = allowlist(); @@ -283,6 +1109,166 @@ mod tests { assert!(!check_kms(&info, &Allowlist::default()).is_allowed); } + #[test] + fn authorization_reports_the_latest_configured_root_epoch() { + let al: Allowlist = serde_json::from_value(json!({ + "rootEpochs": [ + {"caPubkey": "aa", "k256Pubkey": "bb"}, + {"caPubkey": "cc", "k256Pubkey": "dd"}, + {"caPubkey": "ee", "k256Pubkey": "ff"} + ], + "currentRootEpoch": 1 + })) + .unwrap(); + let request: AuthorizationRequest = + serde_json::from_value(json!({"operation": "kms_serve", "appId": "11".repeat(20)})) + .unwrap(); + let result = authorize(&request, true, &al).unwrap(); + let hashes = dstack_types::root_epoch::chain_hashes([ + (&[0xaa][..], &[0xbb][..]), + (&[0xcc], &[0xdd]), + (&[0xee], &[0xff]), + ]); + assert_eq!(result.current_root_epoch, 1); + assert_eq!( + result.current_root_epoch_hash, + format!("0x{}", hex::encode(hashes[1])) + ); + assert_eq!(result.latest_root_epoch, 2); + assert_eq!( + result.latest_root_epoch_hash, + format!("0x{}", hex::encode(hashes[2])) + ); + } + + #[test] + fn a_pending_root_epoch_is_authorized_for_kms_key_handover() { + // epoch 1 is registered (latest) but not current: pending + let al: Allowlist = serde_json::from_value(json!({ + "kmsContractAddr": "0x1111111111111111111111111111111111111111", + "kms": {"mrAggregated": ["bb"], "allowAnyDevice": true}, + "rootEpochs": [ + {"caPubkey": "aa", "k256Pubkey": "bb"}, + {"caPubkey": "cc", "k256Pubkey": "dd"} + ], + "currentRootEpoch": 0 + })) + .unwrap(); + let ask = |operation: &str, requested: u32| { + let request: AuthorizationRequest = serde_json::from_value(json!({ + "operation": operation, + "appId": "11".repeat(20), + "mrAggregated": "bb", + "requestedRootEpoch": requested, + })) + .unwrap(); + authorize(&request, true, &al).unwrap() + }; + + let current = ask("kms_export_keys", 0); + assert!(current.authorized, "{}", current.rejection_reason); + assert_eq!(current.current_root_epoch, 0); + assert_eq!(current.latest_root_epoch, 1); + + for operation in ["kms_import_keys", "kms_export_keys"] { + let pending = ask(operation, 1); + assert!( + pending.authorized, + "{operation} at the pending epoch 1: {}", + pending.rejection_reason + ); + assert!(!ask(operation, 2).authorized); + } + + let serve = ask("kms_serve", 1); + assert!(!serve.authorized); + assert_eq!(serve.rejection_reason, "requested root epoch is not active"); + } + + #[test] + fn allows_only_a_kms_serving_the_current_root_epoch() { + // Shared with the DstackKms contract tests. + let h0 = "0x10ab6b6c8e7601901b0f1767985bf40d35bf3129e0866fe12dcc7c8f04a298f2"; + let h1 = "0x50152a82023f2219a31723b476c13bc5f608ab4147946dabdec15d4f3d648ed4"; + let mut al: Allowlist = serde_json::from_str( + r#"{"kms":{"mrAggregated":["0xMR"],"allowAnyDevice":true}, + "rootEpochs":[{"caPubkey":"0x3059301306072a8648ce3d020106082a8648ce3d03010703420004a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","k256Pubkey":"0x02b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"}, + {"caPubkey":"0x3059301306072a8648ce3d020106082a8648ce3d03010703420004a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1","k256Pubkey":"0x02b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1"}]}"#, + ) + .unwrap(); + let info = |epoch: Option, hash: &str| BootInfo { + mr_aggregated: "0xMR".into(), + root_epoch: epoch, + root_epoch_hash: Some(hash.into()), + ..Default::default() + }; + assert_eq!(info_json(&al).unwrap()["rootEpochHash"], h0); + assert!(check_kms(&info(Some(0), h0), &al).is_allowed); + // A KMS predating root epochs is served only while epoch 0 is current. + assert!(check_kms(&info(None, ""), &al).is_allowed); + + al.current_root_epoch = 1; + assert_eq!(info_json(&al).unwrap()["rootEpoch"], 1); + assert_eq!(info_json(&al).unwrap()["rootEpochHash"], h1); + assert!(check_kms(&info(Some(1), &h1.to_uppercase()), &al).is_allowed); + assert!(!check_kms(&info(Some(0), h0), &al).is_allowed); + assert!(!check_kms(&info(Some(1), h0), &al).is_allowed); + assert!(!check_kms(&info(None, ""), &al).is_allowed); + + al.current_root_epoch = 2; + assert!(al.current_root_epoch().is_err()); + assert!(info_json(&al).is_err()); + assert!(!check_kms(&info(Some(1), h1), &al).is_allowed); + } + + #[test] + fn a_kms_predating_root_epochs_is_cut_off_once_one_is_current() { + let mut al = allowlist(); + let info = boot("0xapp1", "0xhash", "0ximg"); + assert!(check_app(&info, &al).is_allowed); + al.root_epochs = vec![ + RootEpoch { + ca_pubkey: "0xa0".into(), + k256_pubkey: "0xb0".into(), + }, + RootEpoch { + ca_pubkey: "0xa1".into(), + k256_pubkey: "0xb1".into(), + }, + ]; + assert!(check_app(&info, &al).is_allowed); + al.current_root_epoch = 1; + assert!(!check_app(&info, &al).is_allowed); + } + + #[test] + fn invalid_root_epochs_deny_legacy_and_epoch_aware_kms() { + let invalid_configs = [ + json!({"currentRootEpoch": 1}), + json!({"currentRootEpoch": 1, "rootEpochs": [{"caPubkey": "a0", "k256Pubkey": "b0"}]}), + json!({"rootEpochs": [{"caPubkey": "invalid", "k256Pubkey": "b0"}]}), + json!({"rootEpochs": [{"caPubkey": "a0", "k256Pubkey": "invalid"}]}), + ]; + for config in invalid_configs { + let epochs: Allowlist = serde_json::from_value(config).unwrap(); + let al = Allowlist { + root_epochs: epochs.root_epochs, + current_root_epoch: epochs.current_root_epoch, + ..allowlist() + }; + assert!(info_json(&al).is_err()); + for epoch in [None, Some(0), Some(1)] { + let info = BootInfo { + mr_aggregated: "0xMR".into(), + root_epoch: epoch, + ..boot("0xapp1", "0xhash", "0ximg") + }; + assert!(!check_app(&info, &al).is_allowed); + assert!(!check_kms(&info, &al).is_allowed); + } + } + } + // wire-contract snapshot: BootInfo as the KMS serializes it (camelCase). // Keep these field names in sync with the kms BootInfo. `#[serde(default)]` // means extra fields are ignored AND a renamed field deserializes to "" — @@ -301,7 +1287,7 @@ mod tests { "deviceId": "0xDEV", "keyProviderInfo": "kp", "tcbStatus": "UpToDate", - "advisoryIds": [] + "advisoryIds": ["INTEL-SA-00001"] }"#; let info: BootInfo = serde_json::from_str(wire).expect("kms BootInfo must deserialize"); assert_eq!(norm(&info.mr_aggregated), "aabb"); @@ -309,6 +1295,8 @@ mod tests { assert_eq!(norm(&info.app_id), "app1"); assert_eq!(norm(&info.compose_hash), "hash"); assert_eq!(norm(&info.device_id), "dev"); + assert_eq!(info.tcb_status, "UpToDate"); + assert_eq!(info.advisory_ids, ["INTEL-SA-00001"]); // a check using this payload should pass against a matching allowlist. let info2: BootInfo = serde_json::from_str(wire).unwrap(); let al: Allowlist = serde_json::from_str( diff --git a/dstack/crates/dstack-cli-core/src/config.rs b/dstack/crates/dstack-cli-core/src/config.rs index d98364580..03aaee22c 100644 --- a/dstack/crates/dstack-cli-core/src/config.rs +++ b/dstack/crates/dstack-cli-core/src/config.rs @@ -100,6 +100,9 @@ pub struct HostConfig { pub image_download_url: String, /// confidential-computing platform (selects SNP-specific KMS settings). pub platform: Platform, + /// hex SHA-256 of the KMS admin token; the KMS refuses to start without + /// an admin credential. + pub kms_admin_token_hash: String, } impl Default for HostConfig { @@ -110,6 +113,7 @@ impl Default for HostConfig { os_image_hash: String::new(), image_download_url: DEFAULT_IMAGE_DOWNLOAD_URL.to_string(), platform: Platform::Tdx, + kms_admin_token_hash: String::new(), } } } @@ -148,6 +152,17 @@ type = "webhook" [core.auth_api.webhook] url = "{webhook_url}" +# the key handover path a serving KMS requires; the token stays on the host. +[core.admin] +enabled = true +address = "127.0.0.1" +port = 8001 +auth_token_hash = "{admin_token_hash}" + +[core.admin.tls] +key = "/kms/certs/rpc.key" +certs = "/kms/certs/rpc.crt" + [core.onboard] enabled = true auto_bootstrap_domain = "{bootstrap_domain}" @@ -164,6 +179,7 @@ port = 8000 download_url = cfg.image_download_url, webhook_url = cfg.auth_webhook_url, bootstrap_domain = cfg.kms_bootstrap_domain, + admin_token_hash = cfg.kms_admin_token_hash, ) } @@ -478,10 +494,20 @@ mod tests { let cfg = HostConfig { auth_webhook_url: "http://10.0.2.2:8001".into(), kms_bootstrap_domain: "10.0.2.2".into(), + kms_admin_token_hash: "ab".repeat(32), ..Default::default() }; let toml = kms_toml(&cfg); assert!(toml.contains("enforce_self_authorization = false")); + let v: toml::Value = toml::from_str(&toml).unwrap(); + let admin = &v["core"]["admin"]; + assert_eq!(admin["enabled"].as_bool(), Some(true)); + assert_eq!( + admin["auth_token_hash"].as_str(), + Some("ab".repeat(32).as_str()) + ); + assert_eq!(admin["tls"]["key"].as_str(), Some("/kms/certs/rpc.key")); + assert_eq!(admin["tls"]["certs"].as_str(), Some("/kms/certs/rpc.crt")); assert!(toml.contains(r#"auto_bootstrap_domain = "10.0.2.2""#)); assert!(toml.contains(r#"type = "webhook""#)); assert!(toml.contains(r#"url = "http://10.0.2.2:8001""#)); diff --git a/dstack/crates/dstackup/src/install.rs b/dstack/crates/dstackup/src/install.rs index b07544830..14158c60d 100644 --- a/dstack/crates/dstackup/src/install.rs +++ b/dstack/crates/dstackup/src/install.rs @@ -14,6 +14,7 @@ use dstack_cli_core::host::Platform; use dstack_cli_core::layout::{path_string, InstallLayout}; use dstack_cli_core::vmm::Vmm; use dstack_cli_core::{host, ports, rpc}; +use sha2::{Digest, Sha256}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -137,9 +138,16 @@ pub(crate) async fn cmd_install(mut o: InstallOpts, release_api_base_url: &str) // install state, so it authenticates automatically. let vmm_token = match existing_token { Some(t) => t, - None => generate_vmm_token()?, + None => generate_token()?, }; write_token_file(&token_path, &vmm_token)?; + // the KMS admin token, which the KMS config holds only as a hash. + let kms_admin_token_path = layout.config_dir.join("kms-admin-token"); + let kms_admin_token = match read_token_file(&kms_admin_token_path) { + Some(t) => t, + None => generate_token()?, + }; + write_token_file(&kms_admin_token_path, &kms_admin_token)?; // 8. resolve the key provider - run our own unless told to use an existing // one (TDX only; SNP has no SGX local provider). @@ -183,6 +191,7 @@ pub(crate) async fn cmd_install(mut o: InstallOpts, release_api_base_url: &str) auth_webhook_url: format!("http://10.0.2.2:{}", o.auth_port), os_image_hash: os_image_hash.unwrap_or_default(), platform, + kms_admin_token_hash: hex::encode(Sha256::digest(kms_admin_token.as_bytes())), ..Default::default() }; let kms = config::kms_toml(&host_cfg); @@ -1216,8 +1225,8 @@ async fn wait_ready(client_url: &str, token: Option<&str>, timeout: Duration) -> } } -/// mint a 256-bit management-API token, hex-encoded, from the OS RNG. -fn generate_vmm_token() -> Result { +/// mint a 256-bit API token, hex-encoded, from the OS RNG. +fn generate_token() -> Result { let mut buf = [0u8; 32]; let mut f = fs::File::open("/dev/urandom").context("opening /dev/urandom")?; std::io::Read::read_exact(&mut f, &mut buf).context("reading /dev/urandom")?; diff --git a/dstack/dstack-types/src/authorization.rs b/dstack/dstack-types/src/authorization.rs new file mode 100644 index 000000000..1fdb48228 --- /dev/null +++ b/dstack/dstack-types/src/authorization.rs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Operations performed by the attested subject of an authorization request. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorizationOperation { + KmsBootstrap, + KmsServe, + KmsExportKeys, + KmsImportKeys, + AppGetKey, + AppGetCert, +} + +impl AuthorizationOperation { + pub fn is_kms(self) -> bool { + matches!( + self, + Self::KmsBootstrap | Self::KmsServe | Self::KmsExportKeys | Self::KmsImportKeys + ) + } +} diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 452a2ce59..f1496c502 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -14,6 +14,8 @@ use size_parser::human_size; /// infrastructure-provider initialization stages. pub const MAX_INIT_SCRIPTS: usize = 5; +pub mod authorization; + pub mod init_script_hashes { use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_human_bytes::ByteBuf; @@ -2398,6 +2400,8 @@ pub struct AppKeys { pub disk_crypt_key: Vec, #[serde(with = "hex_bytes", default)] pub env_crypt_key: Vec, + #[serde(with = "hex_bytes", default)] + pub sealed_env_key: Vec, #[serde(with = "hex_bytes")] pub k256_key: Vec, #[serde(with = "hex_bytes")] @@ -2407,6 +2411,24 @@ pub struct AppKeys { pub key_provider: KeyProvider, } +impl AppKeys { + pub fn app_epoch(&self) -> u32 { + match self.key_provider { + KeyProvider::Kms { app_epoch, .. } => app_epoch, + _ => 0, + } + } + + /// KMS root epoch the keys were derived from; 0 for other key providers, + /// which have a single root. + pub fn root_epoch(&self) -> u32 { + match self.key_provider { + KeyProvider::Kms { root_epoch, .. } => root_epoch, + _ => 0, + } + } +} + /// Maximum length of a framed message exchanged with the local key provider. pub const KEY_PROVIDER_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024; @@ -2429,6 +2451,11 @@ pub enum KeyProvider { url: String, #[serde(with = "hex_bytes")] pubkey: Vec, + /// KMS root key epoch the keys were derived from. + #[serde(default)] + root_epoch: u32, + #[serde(default)] + app_epoch: u32, }, } @@ -2491,7 +2518,7 @@ mod key_provider_tests { assert_eq!( serde_json::to_value(provider).unwrap(), serde_json::json!({ - "Kms": { "url": "https://kms.example/prpc", "pubkey": "abcd" } + "Kms": { "url": "https://kms.example/prpc", "pubkey": "abcd", "root_epoch": 0, "app_epoch": 0 } }) ); } @@ -2508,8 +2535,10 @@ mod key_provider_tests { assert_eq!(tpm.kind(), KeyProviderKind::Tpm); let kms = KeyProvider::Kms { + app_epoch: 0, url: "https://kms.example".into(), pubkey: vec![0xab; 32], + root_epoch: 0, }; assert_eq!(kms.id(), &[0xab; 32]); } @@ -2541,6 +2570,7 @@ pub struct ImageInfo { } pub mod mr_config; +pub mod root_epoch; pub mod sha256sum; pub mod shared_filenames; pub mod version; diff --git a/dstack/dstack-types/src/root_epoch.rs b/dstack/dstack-types/src/root_epoch.rs new file mode 100644 index 000000000..a289d5be1 --- /dev/null +++ b/dstack/dstack-types/src/root_epoch.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! KMS root epoch chain hashes, computed as the DstackKms contract does. + +use sha3::{Digest, Keccak256}; + +/// Chain hashes of root epochs 0, 1, ... given each epoch's +/// `(ca_pubkey, k256_pubkey)`. Epoch `n`'s hash is +/// `keccak256(previous || keccak256(ca_pubkey) || keccak256(k256_pubkey))` +/// with a zero previous hash for epoch 0, so it commits to every key up to `n`; +/// the position in the chain fixes the epoch number. +pub fn chain_hashes<'a>(epochs: impl IntoIterator) -> Vec<[u8; 32]> { + let mut previous = [0u8; 32]; + epochs + .into_iter() + .map(|(ca_pubkey, k256_pubkey)| { + previous = Keccak256::new() + .chain_update(previous) + .chain_update(Keccak256::digest(ca_pubkey)) + .chain_update(Keccak256::digest(k256_pubkey)) + .finalize() + .into(); + previous + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Well-formed root public keys whose point bytes are all `fill`. + fn test_keys(ca_fill: u8, k256_fill: u8) -> (Vec, Vec) { + let mut ca = hex::decode("3059301306072a8648ce3d020106082a8648ce3d03010703420004").unwrap(); + ca.resize(91, ca_fill); + let mut k256 = vec![0x02]; + k256.resize(33, k256_fill); + (ca, k256) + } + + /// Shared with the DstackKms contract and dstack-auth tests. + #[test] + fn matches_the_contract_vectors() { + let epochs = [test_keys(0xa0, 0xb0), test_keys(0xa1, 0xb1)]; + let hashes = chain_hashes(epochs.iter().map(|(ca, k256)| (&ca[..], &k256[..]))); + assert_eq!( + hex::encode(hashes[0]), + "10ab6b6c8e7601901b0f1767985bf40d35bf3129e0866fe12dcc7c8f04a298f2" + ); + assert_eq!( + hex::encode(hashes[1]), + "50152a82023f2219a31723b476c13bc5f608ab4147946dabdec15d4f3d648ed4" + ); + } +} diff --git a/dstack/dstack-types/src/shared_filenames.rs b/dstack/dstack-types/src/shared_filenames.rs index 79944cff6..77c9d0cf6 100644 --- a/dstack/dstack-types/src/shared_filenames.rs +++ b/dstack/dstack-types/src/shared_filenames.rs @@ -8,6 +8,7 @@ pub const SYS_CONFIG: &str = ".sys-config.json"; pub const TEE_SIMULATOR_CONFIG: &str = ".tee-simulator.json"; pub const USER_CONFIG: &str = ".user-config"; pub const ENCRYPTED_ENV: &str = ".encrypted-env"; +pub const SEALED_ENV: &str = ".sealed-env"; pub const DECRYPTED_ENV: &str = ".decrypted-env"; pub const DECRYPTED_ENV_JSON: &str = ".decrypted-env.json"; pub const INSTANCE_INFO: &str = ".instance_info"; diff --git a/dstack/dstack-util/Cargo.toml b/dstack/dstack-util/Cargo.toml index b7fff59a5..f2401bc40 100644 --- a/dstack/dstack-util/Cargo.toml +++ b/dstack/dstack-util/Cargo.toml @@ -19,6 +19,7 @@ fs-err.workspace = true getrandom = { workspace = true, features = ["std"] } hex.workspace = true hex_fmt.workspace = true +hpke.workspace = true regex.workspace = true scale = { workspace = true, features = ["derive"] } schnorrkel.workspace = true diff --git a/dstack/dstack-util/src/crypto.rs b/dstack/dstack-util/src/crypto.rs index 1f8227f5b..4f492b5a3 100644 --- a/dstack/dstack-util/src/crypto.rs +++ b/dstack/dstack-util/src/crypto.rs @@ -68,6 +68,35 @@ pub fn dh_decrypt(secret: [u8; 32], ciphertext: &[u8]) -> Result> { .map_err(|e| anyhow!("Decryption failed: {}", e)) } +/// Encrypt to `remote_public_key` in the format [`dh_decrypt`] reads: +/// ephemeral public key, IV, then the AES-GCM ciphertext. +#[cfg(test)] +pub fn dh_encrypt(remote_public_key: [u8; 32], plaintext: &[u8]) -> Result> { + let mut ephemeral_secret = [0u8; 32]; + getrandom::fill(&mut ephemeral_secret).context("failed to generate ephemeral secret")?; + let ephemeral_secret = StaticSecret::from(ephemeral_secret); + let shared_secret = ephemeral_secret + .diffie_hellman(&PublicKey::from(remote_public_key)) + .to_bytes(); + ensure!( + !shared_secret.iter().all(|byte| *byte == 0), + "invalid X25519 shared secret" + ); + let cipher = Aes256Gcm::new_from_slice(&shared_secret) + .map_err(|e| anyhow!("failed to create cipher: {e}"))?; + let mut iv = [0u8; 12]; + getrandom::fill(&mut iv).context("failed to generate IV")?; + let ciphertext = cipher + .encrypt(Nonce::::from_slice(&iv), plaintext) + .map_err(|e| anyhow!("encryption failed: {e}"))?; + Ok([ + PublicKey::from(&ephemeral_secret).as_bytes(), + &iv[..], + &ciphertext, + ] + .concat()) +} + fn stream_nonce(prefix: &[u8; 8], index: u32) -> [u8; 12] { let mut nonce = [0u8; 12]; nonce[..8].copy_from_slice(prefix); @@ -266,6 +295,15 @@ pub fn dh_decrypt_stream( mod tests { use super::*; + #[test] + fn dh_encrypt_round_trips_through_dh_decrypt() { + let secret = [7u8; 32]; + let public = PublicKey::from(&StaticSecret::from(secret)).to_bytes(); + let ciphertext = dh_encrypt(public, b"env").unwrap(); + assert_eq!(dh_decrypt(secret, &ciphertext).unwrap(), b"env"); + assert!(dh_decrypt([8u8; 32], &ciphertext).is_err()); + } + #[test] fn test_dh_agree() { use rand::Rng; @@ -361,3 +399,95 @@ mod tests { .is_err()); } } + +/// Encrypt a sealed environment with HPKE DHKEM(X25519, HKDF-SHA256) and AES-256-GCM. +pub fn sealed_env_encrypt(public_key: &[u8], info: &[u8], plaintext: &[u8]) -> Result> { + use hpke::{ + aead::AesGcm256, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, OpModeS, + Serializable, + }; + let public_key = ::PublicKey::from_bytes(public_key) + .map_err(|_| anyhow!("invalid sealed env public key"))?; + let (enc, ciphertext) = hpke::single_shot_seal::( + &OpModeS::Base, + &public_key, + info, + plaintext, + &[], + ) + .map_err(|e| anyhow!("sealed env encryption failed: {e}"))?; + Ok([enc.to_bytes().as_ref(), ciphertext.as_slice()].concat()) +} + +/// Decrypt a sealed environment HPKE payload. +pub fn sealed_env_decrypt(secret: &[u8], info: &[u8], payload: &[u8]) -> Result> { + use hpke::{aead::AesGcm256, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, OpModeR}; + let enc = payload + .get(..32) + .ok_or_else(|| anyhow!("sealed env payload is missing encapsulated key"))?; + let ciphertext = payload + .get(32..) + .ok_or_else(|| anyhow!("sealed env payload is missing ciphertext"))?; + let secret = ::PrivateKey::from_bytes(secret) + .map_err(|_| anyhow!("invalid sealed env private key"))?; + let enc = ::EncappedKey::from_bytes(enc) + .map_err(|_| anyhow!("invalid sealed env encapsulated key"))?; + hpke::single_shot_open::( + &OpModeR::Base, + &secret, + &enc, + info, + ciphertext, + &[], + ) + .map_err(|e| anyhow!("sealed env decryption failed: {e}")) +} + +#[cfg(test)] +mod sealed_env_tests { + use super::*; + + #[test] + fn opens_sdk_envelopes() { + use base64::Engine as _; + for source in [ + include_str!("../../../sdk/fixtures/sealed-env/python.json"), + include_str!("../../../sdk/fixtures/sealed-env/javascript.json"), + ] { + let fixture: serde_json::Value = serde_json::from_str(source).unwrap(); + let envelope = &fixture["sealed_env"]; + let decode = |name: &str| { + base64::engine::general_purpose::STANDARD + .decode(envelope[name].as_str().unwrap()) + .unwrap() + }; + let payload = [decode("enc"), decode("ciphertext")].concat(); + let plaintext = sealed_env_decrypt( + &hex::decode(fixture["private_key"].as_str().unwrap()).unwrap(), + b"dstack-sealed-env-v1:3:0", + &payload, + ) + .unwrap(); + let decoded: serde_json::Value = serde_json::from_slice(&plaintext).unwrap(); + assert_eq!(decoded["env"], fixture["env"]); + } + } + + #[test] + fn sealed_env_round_trips_and_binds_info() { + let secret = StaticSecret::random_from_rng(rand::thread_rng()); + let public = PublicKey::from(&secret).to_bytes(); + let info = b"dstack-sealed-env-v1:3:0"; + let payload = sealed_env_encrypt(&public, info, b"secret env").unwrap(); + assert_eq!( + sealed_env_decrypt(&secret.to_bytes(), info, &payload).unwrap(), + b"secret env" + ); + assert!( + sealed_env_decrypt(&secret.to_bytes(), b"dstack-sealed-env-v1:4:0", &payload).is_err() + ); + let mut tampered = payload; + *tampered.last_mut().unwrap() ^= 1; + assert!(sealed_env_decrypt(&secret.to_bytes(), info, &tampered).is_err()); + } +} diff --git a/dstack/dstack-util/src/host_api.rs b/dstack/dstack-util/src/host_api.rs index e038ea3b2..c33ea2de2 100644 --- a/dstack/dstack-util/src/host_api.rs +++ b/dstack/dstack-util/src/host_api.rs @@ -10,7 +10,7 @@ use dstack_types::{ Platform, }; use host_api::{ - client::{new_client, DefaultClient}, + client::{new_client, DefaultClient, StatusError}, Notification, }; use ra_tls::attestation::validate_tcb; @@ -80,6 +80,29 @@ impl HostApi { Ok(()) } + /// Close the host's env update window for this boot, handing it the env + /// re-encrypted to the current root epoch, or an empty one when nothing + /// changed. An error means the window may still be open. + pub async fn update_sealed_env(&self, sealed_env: Vec) -> Result<()> { + if !matches!(Platform::detect_or_dstack(), Platform::Dstack) { + return Ok(()); + } + let Some(client) = &self.client else { + return Ok(()); + }; + let response = tokio::time::timeout( + HOST_API_TIMEOUT, + client.update_sealed_env(host_api::SealedEnv { sealed_env }), + ) + .await + .context("Timed out updating the sealed env on the host")?; + match response { + // A VMM predating UpdateSealedEnv has no window to close. + Err(err) if is_unknown_method(&err) => Ok(()), + response => response.map(drop), + } + } + pub async fn notify_q(&self, event: &str, payload: &str) { if let Err(err) = self.notify(event, payload).await { warn!("Failed to notify event {event} to host: {:?}", err); @@ -141,3 +164,80 @@ impl HostApi { Ok(KeyProvision { sk, mr }) } } + +/// Whether the host does not implement the method at all: a VMM since 0.6 +/// answers 404, and older ones 400 with the pRPC dispatcher's fixed +/// "Service not found" message. +fn is_unknown_method(err: &anyhow::Error) -> bool { + err.chain() + .find_map(|cause| cause.downcast_ref::()) + .is_some_and(|err| { + err.status == 404 + || (err.status == 400 + && err + .message + .as_deref() + .is_some_and(|message| message.starts_with("Service not found: "))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + /// A host API answering one request with `status` and a JSON `body`. + fn host(status: u16, body: String) -> HostApi { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 4096]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let read = stream.read(&mut chunk).unwrap(); + request.extend_from_slice(&chunk[..read]); + } + write!( + stream, + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + HostApi::new(Some(url), None) + } + + #[tokio::test] + async fn only_an_answer_or_a_host_without_a_window_closes_the_env_window() { + assert!(matches!(Platform::detect_or_dstack(), Platform::Dstack)); + let unknown = r#"{"error":"Service not found: UpdateSealedEnv"}"#; + for (status, body, closed) in [ + (200, "", true), + // A VMM since 0.6, and one before, that does not know the method. + (404, unknown, true), + (400, unknown, true), + // Anything else may leave the window open. + (400, r#"{"error":"VM not found"}"#, false), + (500, "{}", false), + ] { + let result = host(status, body.into()) + .update_sealed_env(b"env".to_vec()) + .await; + assert_eq!(result.is_ok(), closed, "{status} {body}"); + } + // No host listening at all. + let gone = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", gone.local_addr().unwrap()); + drop(gone); + assert!(HostApi::new(Some(url), None) + .update_sealed_env(Vec::new()) + .await + .is_err()); + // No host API configured means no window. + HostApi::new(None, None) + .update_sealed_env(Vec::new()) + .await + .unwrap(); + } +} diff --git a/dstack/dstack-util/src/main.rs b/dstack/dstack-util/src/main.rs index 1046d35d1..e6c49f271 100644 --- a/dstack/dstack-util/src/main.rs +++ b/dstack/dstack-util/src/main.rs @@ -639,9 +639,6 @@ fn cmd_attest_strip(args: AttestStripArgs) -> Result<()> { } async fn cmd_get_keys(args: GetKeysArgs) -> Result<()> { - use dstack_kms_rpc::kms_client::KmsClient; - use ra_rpc::client::RaClientConfig; - let kms_url = normalize_prpc_url(&args.kms_url); // Load root CA if provided for TLS pinning @@ -653,57 +650,25 @@ async fn cmd_get_keys(args: GetKeysArgs) -> Result<()> { None }; - // Step 1: Fetch the KMS root CA - eprintln!("Connecting to KMS: {kms_url}"); - let tls_skip_ca_verification = root_ca_pem.is_none(); - if tls_skip_ca_verification { - eprintln!("Warning: no --root-ca provided, TLS certificate verification is disabled for initial connection"); - } - let root_ca = { - let client = RaClientConfig::builder() - .remote_uri(kms_url.clone()) - .tls_skip_ca_verification(tls_skip_ca_verification) - .tls_built_in_root_certs(false) - .maybe_tls_ca_cert(root_ca_pem.clone()) - .build() - .into_client() - .context("failed to create client")?; - KmsClient::new(client) - .get_meta() - .await - .context("failed to get KMS meta")? - .ca_cert - }; - - // Step 2: Generate a self-issued RA-TLS client certificate. The quote inside it - // is the identity the KMS authenticates, so no CA material is needed. let app_id = decode_app_id(args.app_id.as_deref())?; let cert_pair = generate_self_signed_ra_cert(app_id).context("Failed to generate RA cert")?; - - // Step 3: Create authenticated client and request app keys - let ra_client = RaClientConfig::builder() - .tls_skip_ca_verification(false) - .tls_built_in_root_certs(false) - .remote_uri(kms_url.clone()) - .tls_client_cert(cert_pair.cert_pem) - .tls_client_key(cert_pair.key_pem) - .tls_ca_cert(root_ca.clone()) - .build() - .into_client() - .context("Failed to create RA client")?; - - let kms_client = KmsClient::new(ra_client); - let response = kms_client - .get_app_key(dstack_kms_rpc::GetAppKeyRequest { + let response = cert_client::request_app_keys( + kms_url.clone(), + dstack_kms_rpc::GetAppKeyRequest { + app_epoch: None, api_version: 1, - vm_config: "".to_string(), - }) - .await - .context( - "failed to get app key (the KMS must be 0.6.0 or later to accept a self-issued RA-TLS client certificate)", - )?; + vm_config: String::new(), + root_epoch: None, + }, + cert_pair, + std::sync::Arc::new(ra_tls::attestation::AttestationVerifier::new_prod(None)?), + root_ca_pem.clone(), + ) + .await + .context("Failed to get app keys from the attested KMS")?; + let root_ca = root_ca_pem.unwrap_or(response.ca_cert); - // Step 4: Build AppKeys structure + // Build AppKeys from the authenticated response. let (_, ca_pem) = x509_parser::pem::parse_x509_pem(root_ca.as_bytes()).context("Failed to parse CA cert")?; let x509 = ca_pem.parse_x509().context("Failed to parse CA cert")?; @@ -713,16 +678,19 @@ async fn cmd_get_keys(args: GetKeysArgs) -> Result<()> { ca_cert: root_ca, disk_crypt_key: response.disk_crypt_key, env_crypt_key: response.env_crypt_key, + sealed_env_key: response.sealed_env_key, k256_key: response.k256_key, k256_signature: response.k256_signature, gateway_app_id: response.gateway_app_id, key_provider: KeyProvider::Kms { + app_epoch: response.app_epoch, url: kms_url, pubkey: root_pubkey, + root_epoch: response.root_epoch, }, }; - // Step 5: Output result + // Output result. let json = serde_json::to_string_pretty(&keys).context("Failed to serialize app keys")?; if let Some(output_path) = args.output { safe_write_with_mode(&output_path, &json, 0o600).context("Failed to write app keys")?; @@ -1226,6 +1194,7 @@ fn make_app_keys( Ok(AppKeys { disk_crypt_key: sha256(&disk_key.serialize_der()).to_vec(), env_crypt_key: vec![], + sealed_env_key: vec![], k256_key: k256_key.to_bytes().to_vec(), k256_signature: vec![], gateway_app_id: "".to_string(), diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 0d604076b..6b224cdf8 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -22,14 +22,13 @@ use dstack_types::{ gpu_policy_hash, shared_filenames::{ APP_COMPOSE, APP_KEYS, DECRYPTED_ENV, DECRYPTED_ENV_JSON, ENCRYPTED_ENV, - HOST_SHARED_DIR_NAME, INSTANCE_INFO, SYS_CONFIG, USER_CONFIG, + HOST_SHARED_DIR_NAME, INSTANCE_INFO, SEALED_ENV, SYS_CONFIG, USER_CONFIG, }, GpuPolicy, KeyProvider, KeyProviderInfo, GPU_ATTESTATION_OUTPUT, }; use fs_err as fs; use luks::{ - LuksAf, LuksArea, LuksConfig, LuksDigest, LuksHeader, LuksJson, LuksKdf, LuksKeyslot, - LuksSegment, BINARY_HEADER_SIZE, + load_luks_header, sync_luks_epochs, write_luks_epochs, LUKS_INITIALIZING_LABEL, PAYLOAD_OFFSET, }; use ra_rpc::{ client::{CertInfo, RaClient, RaClientConfig}, @@ -55,7 +54,7 @@ use crate::{ deserialize_json_file, sha256, sha256_file, AppCompose, AppKeys, KeyProviderKind, SysConfig, }, }; -use cert_client::{validate_kms_rpc_cert, CertRequestClient}; +use cert_client::{request_app_keys, CertRequestClient}; use cmd_lib::run_fun as cmd; use dstack_api_auth::jwt; use dstack_gateway_rpc::{ @@ -96,6 +95,11 @@ async fn sign_cert_request( mod config_id_verifier; mod luks; +mod sealed_env; + +#[cfg(test)] +use sealed_env::seal_env; +use sealed_env::{env_crypt_key, seal_env_at_epochs, sealed_env_info_at_epochs, SealedEnv}; #[derive(clap::Parser)] /// Prepare full disk encryption @@ -229,6 +233,10 @@ impl HostShareDir { self.base_dir.join(ENCRYPTED_ENV) } + fn sealed_env_file(&self) -> PathBuf { + self.base_dir.join(SEALED_ENV) + } + fn sys_config_file(&self) -> PathBuf { self.base_dir.join(SYS_CONFIG) } @@ -242,11 +250,34 @@ impl HostShareDir { } } +fn env_file_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(true), + Ok(_) => bail!("env {} is not a regular file", path.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => { + Err(error).with_context(|| format!("failed to inspect env {}", path.display())) + } + } +} + +fn read_optional_env(path: &Path) -> Result>> { + if !env_file_exists(path)? { + return Ok(None); + } + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to read env {}", path.display())), + } +} + struct HostShared { dir: HostShareDir, sys_config: SysConfig, app_compose: AppCompose, encrypted_env: Vec, + sealed_env: Option>, instance_info: InstanceInfo, } @@ -261,12 +292,18 @@ impl HostShared { } else { InstanceInfo::default() }; - let encrypted_env = fs::read(host_shared_dir.encrypted_env_file()).unwrap_or_default(); + let sealed_env = read_optional_env(&host_shared_dir.sealed_env_file())?; + let encrypted_env = if sealed_env.is_some() { + Vec::new() + } else { + read_optional_env(&host_shared_dir.encrypted_env_file())?.unwrap_or_default() + }; Ok(Self { dir: host_shared_dir.clone(), sys_config, app_compose, encrypted_env, + sealed_env, instance_info, }) } @@ -315,7 +352,16 @@ impl HostShared { copy(APP_COMPOSE, SZ_1MB * 50, false)?; copy(SYS_CONFIG, SZ_1KB * 32, false)?; copy(INSTANCE_INFO, SZ_1KB * 10, true)?; - copy(ENCRYPTED_ENV, SZ_1KB * 256, true)?; + if env_file_exists(&host_shared_dir.join(SEALED_ENV))? { + copy(SEALED_ENV, SZ_1KB * 384, false)?; + } else { + match fs::remove_file(host_shared_copy_dir.join(SEALED_ENV)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("failed to remove stale sealed env copy"), + } + copy(ENCRYPTED_ENV, SZ_1KB * 256, true)?; + } copy(USER_CONFIG, SZ_1MB * 50, true)?; info!("Unmounting host-shared"); unmount_host_shared(host_shared_dir)?; @@ -2353,6 +2399,87 @@ struct AppInfo { gpu_attestation_event: Option>, } +impl HostShared { + /// Request app keys of `root_epoch` (`None` = current) from one KMS. + async fn request_app_keys_from_kms( + &self, + kms_url: String, + root_epoch: Option, + app_epoch: Option, + trusted_ca: Option, + ) -> Result<(AppKeys, Vec)> { + info!("Requesting app keys of root epoch {root_epoch:?} from KMS: {kms_url}"); + let cert_pair = + generate_self_signed_ra_cert(None).context("Failed to generate RA-TLS cert")?; + let response = request_app_keys( + kms_url.clone(), + rpc::GetAppKeyRequest { + api_version: 1, + vm_config: self.sys_config.vm_config.clone(), + root_epoch, + app_epoch, + }, + cert_pair, + attestation_verifier(&self.sys_config)?, + trusted_ca.clone(), + ) + .await + .context("Failed to get app keys from the attested KMS")?; + let served = response.root_epoch; + let root_ca = trusted_ca.unwrap_or(response.ca_cert); + + let (_, ca_pem) = x509_parser::pem::parse_x509_pem(root_ca.as_bytes()) + .context("Failed to parse ca cert")?; + let x509 = ca_pem.parse_x509().context("Failed to parse ca cert")?; + let root_pubkey = x509.public_key().raw.to_vec(); + + let keys = AppKeys { + ca_cert: root_ca, + disk_crypt_key: response.disk_crypt_key, + env_crypt_key: response.env_crypt_key, + sealed_env_key: response.sealed_env_key, + k256_key: response.k256_key, + k256_signature: response.k256_signature, + gateway_app_id: response.gateway_app_id, + key_provider: KeyProvider::Kms { + url: kms_url, + pubkey: root_pubkey, + root_epoch: served, + app_epoch: response.app_epoch, + }, + }; + Ok((keys, response.os_image_hash)) + } + + /// Request the app keys of an older root epoch from the KMS that served + /// `keys`. + async fn request_historical_keys( + &self, + keys: &AppKeys, + epoch: u32, + app_epoch: u32, + ) -> Result { + let KeyProvider::Kms { url, .. } = &keys.key_provider else { + bail!("only KMS-provided keys have older root epochs"); + }; + Ok(self + .request_app_keys_from_kms( + url.clone(), + Some(epoch), + Some(app_epoch), + Some(keys.ca_cert.clone()), + ) + .await + .with_context(|| { + format!( + "failed to get the keys of root epoch {epoch}, app epoch {app_epoch}; \ + the policy rows of those epochs must still allow this instance" + ) + })? + .0) + } +} + struct Stage0<'a> { args: &'a SetupArgs, shared: HostShared, @@ -2415,70 +2542,6 @@ impl<'a> Stage0<'a> { self.shared.dir.join(APP_KEYS) } - async fn request_app_keys_from_kms_url(&self, kms_url: String) -> Result<(AppKeys, Vec)> { - info!("Requesting app keys from KMS: {kms_url}"); - // Learn the KMS root CA over an unauthenticated connection, as with - // `GetTempCaCert` before. Relying parties bind the KMS through the - // `key-provider` launch event; `verify_key_provider_id` also enforces it - // in-guest when the app pins `key_provider_id`. - let root_ca = { - info!("Getting KMS root CA"); - let client = RaClient::new(kms_url.clone(), true)?; - let kms_client = dstack_kms_rpc::kms_client::KmsClient::new(client); - kms_client - .get_meta() - .await - .context("failed to get KMS meta")? - .ca_cert - }; - // Self-issued: the quote inside the certificate is the identity, so there is - // no CA to fetch and no shared private key to hold. - let cert_pair = - generate_self_signed_ra_cert(None).context("Failed to generate RA-TLS cert")?; - let attestation_verifier = attestation_verifier(&self.shared.sys_config)?; - let ra_client = RaClientConfig::builder() - .tls_skip_ca_verification(false) - .tls_built_in_root_certs(false) - .remote_uri(kms_url.clone()) - .tls_client_cert(cert_pair.cert_pem) - .tls_client_key(cert_pair.key_pem) - .tls_ca_cert(root_ca.clone()) - .attestation_verifier(attestation_verifier) - .cert_validator(Box::new(validate_kms_rpc_cert)) - .build() - .into_client() - .context("Failed to create client")?; - let kms_client = dstack_kms_rpc::kms_client::KmsClient::new(ra_client); - let response = kms_client - .get_app_key(rpc::GetAppKeyRequest { - api_version: 1, - vm_config: self.shared.sys_config.vm_config.clone(), - }) - .await - .context( - "failed to get app key (the KMS must be 0.6.0 or later to accept a self-issued RA-TLS client certificate)", - )?; - - let (_, ca_pem) = x509_parser::pem::parse_x509_pem(root_ca.as_bytes()) - .context("Failed to parse ca cert")?; - let x509 = ca_pem.parse_x509().context("Failed to parse ca cert")?; - let root_pubkey = x509.public_key().raw.to_vec(); - - let keys = AppKeys { - ca_cert: root_ca, - disk_crypt_key: response.disk_crypt_key, - env_crypt_key: response.env_crypt_key, - k256_key: response.k256_key, - k256_signature: response.k256_signature, - gateway_app_id: response.gateway_app_id, - key_provider: KeyProvider::Kms { - url: kms_url, - pubkey: root_pubkey, - }, - }; - Ok((keys, response.os_image_hash)) - } - async fn request_app_keys_from_kms(&self) -> Result { if self.shared.sys_config.kms_urls.is_empty() { bail!("No KMS URLs are set"); @@ -2486,7 +2549,11 @@ impl<'a> Stage0<'a> { let mut errors = vec![]; for kms_url in &self.shared.sys_config.kms_urls { let kms_url = kms_rpc_url(kms_url); - match self.request_app_keys_from_kms_url(kms_url.clone()).await { + match self + .shared + .request_app_keys_from_kms(kms_url.clone(), None, None, None) + .await + { Ok((keys, os_image_hash)) => { // Measured here, outside the per-URL request, so that a // failover can never extend os-image-hash twice. @@ -2729,7 +2796,7 @@ impl<'a> Stage0<'a> { initialized } - async fn mount_data_disk(&self, disk_crypt_key: &str, opts: &DstackOptions) -> Result<()> { + async fn mount_data_disk(&self, keys: &AppKeys, opts: &DstackOptions) -> Result<()> { let name = "dstack_data_disk"; let mount_point = &self.args.mount_point; @@ -2750,12 +2817,13 @@ impl<'a> Stage0<'a> { let has_filesystem = if opts.storage_encrypted && disk_initialized { info!("Mounting encrypted data disk"); - let initializing = - self.open_encrypted_volume(disk_crypt_key, name, opts.storage_discard)?; - if initializing { + let opened = self + .open_encrypted_volume(keys, name, opts.storage_discard, false) + .await?; + if !opened { warn!("data disk initialization was interrupted, initializing it again"); } - !initializing + opened } else { disk_initialized }; @@ -2767,9 +2835,9 @@ impl<'a> Stage0<'a> { if !opts.storage_encrypted { info!("Skipping disk encryption as requested by kernel cmdline"); - } else if !disk_initialized { + } else { info!("Setting up disk encryption"); - self.luks_setup(disk_crypt_key, name, opts.storage_discard)?; + self.luks_setup(keys, name, opts.storage_discard).await?; } match opts.storage_fs { @@ -2898,7 +2966,8 @@ impl<'a> Stage0<'a> { Ok(()) } - fn luks_setup(&self, disk_crypt_key: &str, name: &str, discard: bool) -> Result<()> { + async fn luks_setup(&self, keys: &AppKeys, name: &str, discard: bool) -> Result<()> { + let disk_crypt_key = hex::encode(&keys.disk_crypt_key); let root_hd = &self.args.device; let sector_offset = PAYLOAD_OFFSET / 512; info!("Formatting encrypted disk"); @@ -2936,19 +3005,28 @@ impl<'a> Stage0<'a> { { bail!("Failed to setup luks volume"); } - self.open_encrypted_volume(disk_crypt_key, name, discard)?; + if let Some(epoch) = kms_root_epoch(keys).filter(|epoch| *epoch > 0 || keys.app_epoch() > 0) + { + write_luks_epochs(root_hd, 0, epoch, keys.app_epoch())?; + } + self.open_encrypted_volume(keys, name, discard, true) + .await?; Ok(()) } - /// Returns whether the header still carries the initializing label. - fn open_encrypted_volume( + /// Open the data disk after bringing it to the current key epochs. + /// + /// Returns `false`, leaving the disk closed, when an earlier + /// initialization was interrupted, unless the disk was just formatted. + async fn open_encrypted_volume( &self, - disk_crypt_key: &str, + keys: &AppKeys, name: &str, discard: bool, + formatted: bool, ) -> Result { let root_hd = &self.args.device; - let disk_crypt_key = disk_crypt_key.trim(); + let disk_crypt_key = hex::encode(&keys.disk_crypt_key); // Create a private tmpfs mount to ensure the header stays in-memory. let tmp_hdr_dir = "/tmp/dstack-luks-header"; let in_mem_hdr = format!("{tmp_hdr_dir}/luks-header"); @@ -2965,14 +3043,24 @@ impl<'a> Stage0<'a> { info "Mounting tmpfs for in-memory LUKS header"; mkdir -p $tmp_hdr_dir; mount -t tmpfs -o size=64M,mode=0700,nosuid,nodev,noexec tmpfs $tmp_hdr_dir; - info "Loading the LUKS2 header"; - cryptsetup luksHeaderBackup --header-backup-file=$in_mem_hdr $root_hd; } - .context("Failed to load LUKS2 header")?; + .context("Failed to mount tmpfs for the LUKS2 header")?; - let hdr_file = fs::File::open(&in_mem_hdr).context("Failed to open LUKS2 header")?; - let initializing = - validate_luks2_headers(hdr_file).context("Failed to validate LUKS2 header")?; + let hdr = Path::new(&in_mem_hdr); + let header = load_luks_header(root_hd, hdr)?; + if header.initializing && !formatted { + return Ok(false); + } + if let Some(current) = kms_root_epoch(keys) { + let old_key = async |root, app| { + let old = self.shared.request_historical_keys(keys, root, app).await?; + Ok(hex::encode(&old.disk_crypt_key)) + }; + let target = (current, keys.app_epoch()); + sync_luks_epochs(root_hd, hdr, header, &disk_crypt_key, target, old_key) + .await + .context("failed to migrate the data disk key")?; + } info!("Opening the device"); let mut command = Command::new("cryptsetup"); @@ -3016,7 +3104,7 @@ impl<'a> Stage0<'a> { info!("Waiting for device mapper {}...", dm_path); std::thread::sleep(std::time::Duration::from_millis(500)); } - Ok(initializing) + Ok(true) } async fn measure_app_info(&self) -> Result { @@ -3201,8 +3289,7 @@ impl<'a> Stage0<'a> { opts.storage_encrypted, opts.storage_fs ); - self.mount_data_disk(&hex::encode(&app_keys.disk_crypt_key), &opts) - .await?; + self.mount_data_disk(&app_keys, &opts).await?; self.setup_swap(self.shared.app_compose.swap_size, &opts) .await?; self.vmm @@ -3227,25 +3314,76 @@ impl<'a> Stage0<'a> { } impl Stage1<'_> { - fn decrypt_env_vars( - &self, - key: &[u8], - ciphertext: &[u8], - allowed: &BTreeSet, - ) -> Result> { - let vars = if !ciphertext.is_empty() { - info!("Processing encrypted env"); - let env_crypt_key: [u8; 32] = key.try_into().ok().context( - "encrypted env is present but the key provider gave no valid env crypt key", + /// Decrypt the host-supplied env, preferring the sealed format. + async fn decrypt_env(&self) -> Result<(Vec, Vec)> { + let current_secret = self.keys.sealed_env_key.as_slice(); + if let Some(sealed_env) = &self.shared.sealed_env { + let envelope: SealedEnv = + serde_json::from_slice(sealed_env).context("failed to parse sealed env")?; + if envelope.version != 1 { + bail!("unsupported sealed env version: {}", envelope.version); + } + if envelope.enc.len() != 32 { + bail!("sealed env encapsulated key must be 32 bytes"); + } + let payload = [envelope.enc, envelope.ciphertext].concat(); + let (current_root, current_app) = (self.keys.root_epoch(), self.keys.app_epoch()); + if envelope.root_epoch > current_root || envelope.app_epoch > current_app { + bail!( + "sealed env epoch (root {}, app {}) exceeds current epoch (root {current_root}, app {current_app})", + envelope.root_epoch, + envelope.app_epoch + ); + } + let historical; + let secret = if envelope.root_epoch == current_root && envelope.app_epoch == current_app + { + current_secret + } else { + historical = self + .shared + .request_historical_keys(&self.keys, envelope.root_epoch, envelope.app_epoch) + .await?; + historical.sealed_env_key.as_slice() + }; + let plaintext = crate::crypto::sealed_env_decrypt( + secret, + &sealed_env_info_at_epochs(envelope.root_epoch, envelope.app_epoch), + &payload, )?; - let decrypted_json = - dh_decrypt(env_crypt_key, ciphertext).context("Failed to decrypt env file")?; - crate::parse_env_file::parse_env(&decrypted_json, allowed)? - } else { - info!("No encrypted env, using default"); - Default::default() + if envelope.root_epoch == current_root && envelope.app_epoch == current_app { + return Ok((plaintext, Vec::new())); + } + let sealed = seal_env_at_epochs(current_secret, current_root, current_app, &plaintext)?; + return Ok((plaintext, sealed)); + } + let ciphertext = &self.shared.encrypted_env; + let current_key = env_crypt_key(&self.keys.env_crypt_key)?; + let plaintext = match dh_decrypt(current_key, ciphertext) { + Ok(plaintext) => return Ok((plaintext, Vec::new())), + Err(_) => { + let current = self.keys.root_epoch(); + // Legacy encryption is only issued at app epoch zero. + let epochs = (0..=current) + .rev() + .filter(|root| *root != current || self.keys.app_epoch() != 0); + decrypt_historical_env(ciphertext, epochs, async |root| { + Ok(self + .shared + .request_historical_keys(&self.keys, root, 0) + .await? + .env_crypt_key) + }) + .await? + } }; - Ok(vars) + let sealed = seal_env_at_epochs( + current_secret, + self.keys.root_epoch(), + self.keys.app_epoch(), + &plaintext, + )?; + Ok((plaintext, sealed)) } fn write_env_file(&self, env_vars: &BTreeMap) -> Result<()> { @@ -3262,7 +3400,7 @@ impl Stage1<'_> { Ok(()) } - fn unseal_env_vars(&self) -> Result> { + async fn unseal_env_vars(&self) -> Result> { let allowed_envs: BTreeSet = self .shared .app_compose @@ -3270,18 +3408,32 @@ impl Stage1<'_> { .iter() .cloned() .collect(); - // Decrypt env file - let decrypted_env = self.decrypt_env_vars( - &self.keys.env_crypt_key, - &self.shared.encrypted_env, - &allowed_envs, - )?; + let (decrypted_env, sealed) = + if self.shared.sealed_env.is_none() && self.shared.encrypted_env.is_empty() { + info!("No encrypted env, using default"); + (Default::default(), Vec::new()) + } else { + info!("Processing encrypted env"); + let (plaintext, sealed) = self.decrypt_env().await?; + ( + crate::parse_env_file::parse_env(&plaintext, &allowed_envs)?, + sealed, + ) + }; self.write_env_file(&decrypted_env)?; + // Sent on every boot, empty when nothing changed: it closes the host's + // env update window, which must be shut before any application code + // runs, or that code could replace the env the next boot reads. + close_env_window( + || self.vmm.update_sealed_env(sealed.clone()), + &ENV_WINDOW_RETRY_DELAYS, + ) + .await?; Ok(decrypted_env) } async fn setup(&self) -> Result<()> { - let _envs = self.unseal_env_vars()?; + let _envs = self.unseal_env_vars().await?; self.link_files()?; self.setup_socket_dir()?; self.setup_guest_agent_config()?; @@ -3378,294 +3530,60 @@ impl Stage1<'_> { } } -macro_rules! const_pad { - ($s:expr, $len:expr) => { - const { - assert!($s.len() <= $len, "The s is too long"); - let mut padded: [u8; $len] = [0; $len]; - let mut i = 0; - while i < $s.len() { - padded[i] = $s[i]; - i += 1; - } - padded +/// Waits between attempts to close the env update window. +const ENV_WINDOW_RETRY_DELAYS: [Duration; 4] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), +]; + +/// Close the host's env update window, retrying `update` after each delay +/// while the host gives no answer. Fails if the window may still be open. +async fn close_env_window(mut update: F, delays: &[Duration]) -> Result<()> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for delay in delays { + match update().await { + Ok(()) => return Ok(()), + Err(err) => warn!("failed to close the env update window, retrying: {err:#}"), } - }; -} - -const PAYLOAD_OFFSET: u64 = 16777216; - -/// Set by luksFormat and cleared once the filesystem exists, so a disk still -/// carrying it was interrupted mid-initialization and holds no data. -const LUKS_INITIALIZING_LABEL: &str = "dstack-initializing"; - -/// Returns whether the primary header carries [`LUKS_INITIALIZING_LABEL`]. -fn validate_luks2_headers(mut reader: impl std::io::Read) -> Result { - let initializing = validate_single_luks2_header(&mut reader, 0)?; - validate_single_luks2_header(&mut reader, 1)?; - Ok(initializing) -} - -fn validate_single_luks2_header(mut reader: impl std::io::Read, hdr_ind: u64) -> Result { - let mut hdr_data = vec![0u8; BINARY_HEADER_SIZE as usize]; - reader - .read_exact(&mut hdr_data) - .context("Failed to read LUKS header")?; - let LuksHeader { - magic, - version, - hdr_size, - label, - csum_alg, - subsystem, - hdr_offset, - } = LuksHeader::parse(&hdr_data).context("Failed to decode LUKS header")?; - - let expected_magic = match hdr_ind { - 0 => [76, 85, 75, 83, 186, 190], - 1 => [83, 75, 85, 76, 186, 190], - _ => bail!("Invalid LUKS header index: {hdr_ind}"), - }; - if magic != expected_magic { - bail!("Invalid LUKS magic: {magic:?}"); - } - if version != 2 { - bail!("Invalid LUKS version: {version}"); + tokio::time::sleep(*delay).await; } - let initializing = label == const_pad!(LUKS_INITIALIZING_LABEL.as_bytes(), 48); - if !initializing && label != [0; 48] { - bail!("Invalid LUKS label: {:?}", label); - } - if csum_alg != const_pad!(b"sha256", 32) { - bail!("Invalid LUKS checksum algorithm"); - } - if subsystem != [0; 48] { - bail!("Invalid LUKS subsystem"); - } - if hdr_offset != hdr_ind * hdr_size { - bail!("Invalid LUKS header offset: {hdr_offset}"); - } - if !(4096..=1024 * 1024 * 16).contains(&hdr_size) { - bail!("Invalid LUKS header size: {hdr_size}"); - } - - // Check JSON - let json_size = hdr_size - BINARY_HEADER_SIZE; - let mut jsn_data = vec![0u8; json_size as usize]; - reader - .read_exact(&mut jsn_data) - .context("Failed to read LUKS JSON")?; - let json_end = jsn_data - .iter() - .position(|&b| b == 0) - .unwrap_or(jsn_data.len()); - jsn_data.truncate(json_end); - - let LuksJson { - keyslots, - tokens, - segments, - digests, - config: - LuksConfig { - keyslots_size, - flags, - requirements, - }, - } = serde_json::from_slice(&jsn_data).context("Failed to decode LUKS JSON")?; - - if keyslots.len() != 1 { - bail!("Invalid LUKS keyslots"); - } - if !tokens.is_empty() { - bail!("Invalid LUKS tokens"); - } - if segments.len() != 1 { - bail!("Invalid LUKS segments"); - } - if digests.len() != 1 { - bail!("Invalid LUKS digests"); - } - if keyslots_size % 4096 != 0 { - bail!("Invalid LUKS keyslots size: {keyslots_size}"); - } - if flags.is_some() { - bail!("Invalid LUKS flags"); - } - if requirements.is_some() { - bail!("Invalid LUKS requirements"); - } - - { - let LuksKeyslot::Luks2 { - key_size, - area: - LuksArea::Raw { - encryption, - key_size: area_key_size, - offset, - size, - }, - kdf, - af: - LuksAf::Luks1 { - hash: af_hash, - stripes, - }, - priority, - } = keyslots.get(&0).context("no LUKS keyslot")?; - if encryption != "aes-xts-plain64" { - bail!("Invalid LUKS keyslot encryption: {encryption}"); - } - // Pin where the encrypted key material is read from. The binary area - // must sit between the two header copies and the encrypted payload; - // otherwise a host with raw disk access could redirect it elsewhere. - let area_end = offset.checked_add(*size); - if *offset < 2 * hdr_size || area_end.is_none_or(|end| end > PAYLOAD_OFFSET) { - bail!("Invalid LUKS keyslot area: offset={offset} size={size}"); - } - if *key_size != 64 { - bail!("Invalid LUKS keyslot key size: {key_size}"); - } - if *area_key_size != 64 { - bail!("Invalid LUKS keyslot key size: {area_key_size}"); - } - let LuksKdf::Pbkdf2 { hash: kdf_hash } = kdf else { - bail!("Invalid LUKS keyslot KDF"); - }; - if kdf_hash != "sha256" { - bail!("Invalid LUKS keyslot hash: {kdf_hash}"); - } - if af_hash != "sha256" { - bail!("Invalid LUKS keyslot hash: {af_hash}"); - } - if *stripes != 4000 { - bail!("Invalid LUKS keyslot stripes: {stripes}"); - } - if priority.is_some() { - bail!("Invalid LUKS keyslot priority"); - } - } - - { - let LuksSegment::Crypt { - offset, - size, - iv_tweak, - encryption, - sector_size, - integrity, - flags, - } = segments.get(&0).context("no LUKS segment")?; - if *offset != PAYLOAD_OFFSET { - bail!("Invalid LUKS segment offset"); - } - if size != "dynamic" { - bail!("Invalid LUKS segment size"); - } - if *iv_tweak != 0 { - bail!("Invalid LUKS segment IV tweak"); - } - if encryption != "aes-xts-plain64" { - bail!("Invalid LUKS segment encryption"); - } - if *sector_size != 512 { - bail!("Invalid LUKS segment sector size"); - } - if integrity.is_some() { - bail!("Invalid LUKS segment integrity"); - } - if flags.is_some() { - bail!("Invalid LUKS segment flags"); - } - } - { - let LuksDigest::Pbkdf2 { - keyslots, - segments, - hash, - } = digests.get(&0).context("no LUKS digest")?; - if hash != "sha256" { - bail!("Invalid LUKS digest hash: {hash}"); - } - if keyslots != &[0] { - bail!("Invalid LUKS digest keyslots: {keyslots:?}"); - } - if segments != &[0] { - bail!("Invalid LUKS digest segments: {segments:?}"); - } - } - Ok(initializing) -} - -#[test] -fn test_validate_luks2_header() { - let mut header_data = include_bytes!("../tests/fixtures/luks_header_good").to_vec(); - assert!( - !validate_luks2_headers(&mut &header_data[..]).expect("Failed to validate LUKS2 header") - ); - header_data[24..24 + LUKS_INITIALIZING_LABEL.len()] - .copy_from_slice(LUKS_INITIALIZING_LABEL.as_bytes()); - assert!(validate_luks2_headers(&mut &header_data[..]).expect("Failed to validate LUKS2 header")); - let header_data = include_bytes!("../tests/fixtures/luks_header_cipher_null").to_vec(); - let error = validate_luks2_headers(&mut &header_data[..]).unwrap_err(); - assert!(error - .to_string() - .contains("Invalid LUKS keyslot encryption")); -} - -#[test] -fn test_validate_luks2_header_rejects_out_of_range_keyslot_area() { - // Redirect the keyslot binary area below the header region. Same length - // so the surrounding header stays intact; "00768" parses to 768, which is - // inside the header copies (< 2 * hdr_size) rather than the metadata gap. - let mut header = include_bytes!("../tests/fixtures/luks_header_good").to_vec(); - let patched = patch_luks_json(&mut header, br#""offset":"32768""#, br#""offset":"00768""#); - assert_eq!(patched, 2, "expected to patch both header copies"); - let error = validate_luks2_headers(&mut &header[..]).unwrap_err(); - assert!(error.to_string().contains("Invalid LUKS keyslot area")); + update().await.context( + "the host did not confirm that the env update window is closed; refusing to start applications", + ) } -/// Replace `needle` with `replacement` inside every LUKS JSON region, keeping -/// each region the same length by giving back trailing NUL padding. -#[cfg(test)] -fn patch_luks_json(header: &mut [u8], needle: &[u8], replacement: &[u8]) -> usize { - assert!(replacement.len() >= needle.len()); - let grow = replacement.len() - needle.len(); - let mut patched = 0; - let mut i = 0; - while i + needle.len() <= header.len() { - if &header[i..i + needle.len()] != needle { - i += 1; - continue; +async fn decrypt_historical_env( + ciphertext: &[u8], + epochs: I, + mut key_at: F, +) -> Result> +where + I: IntoIterator, + F: FnMut(u32) -> Fut, + Fut: std::future::Future>>, +{ + for epoch in epochs { + let decrypted = key_at(epoch) + .await + .and_then(|key| dh_decrypt(env_crypt_key(&key)?, ciphertext)); + match decrypted { + Ok(plaintext) => return Ok(plaintext), + Err(err) => warn!("failed to open legacy env at root epoch {epoch}: {err:#}"), } - let tail = &mut header[i..]; - let end = tail.iter().position(|b| *b == 0).expect("NUL padding"); - assert!(end + grow < tail.len(), "not enough NUL padding"); - tail.copy_within(needle.len()..end, replacement.len()); - tail[..replacement.len()].copy_from_slice(replacement); - patched += 1; - i += replacement.len(); - } - patched + } + bail!("failed to decrypt legacy env with accessible historical keys") } -#[test] -fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() { - let mut header = include_bytes!("../tests/fixtures/luks_header_good").to_vec(); - // 2**64 - 32768 + 1000: added to the accepted offset 32768 it wraps to - // 1000, which is below PAYLOAD_OFFSET. - let patched = patch_luks_json( - &mut header, - br#""size":"258048""#, - br#""size":"18446744073709519848""#, - ); - assert_eq!(patched, 2, "expected to patch both header copies"); - let error = validate_luks2_headers(&mut &header[..]).unwrap_err(); - assert!( - error.to_string().contains("Invalid LUKS keyslot area"), - "{error:#}" - ); +fn kms_root_epoch(keys: &AppKeys) -> Option { + match keys.key_provider { + KeyProvider::Kms { root_epoch, .. } => Some(root_epoch), + _ => None, + } } #[cfg(test)] @@ -4460,3 +4378,233 @@ Endpoint = [2001:db8::1]:51822 assert!(parse_wg_ip("10.0.0.2/32\n[Interface]").is_err()); } } + +#[cfg(test)] +mod sealed_env_file_tests { + use super::*; + + fn stage(args: &SetupArgs, sealed_env: Option>, encrypted_env: Vec) -> Stage1<'_> { + Stage1 { + args, + vmm: HostApi::default(), + shared: HostShared { + dir: HostShareDir::new(&args.work_dir), + sys_config: serde_json::from_value(serde_json::json!({"vm_config": ""})).unwrap(), + app_compose: serde_json::from_value(serde_json::json!({ + "manifest_version": "2", "name": "sealed-test", "runner": "docker-compose" + })) + .unwrap(), + instance_info: InstanceInfo::default(), + sealed_env, + encrypted_env, + }, + keys: AppKeys { + disk_crypt_key: vec![], + env_crypt_key: vec![9; 32], + sealed_env_key: vec![7; 32], + k256_key: vec![], + k256_signature: vec![], + gateway_app_id: String::new(), + ca_cert: String::new(), + key_provider: KeyProvider::Kms { + app_epoch: 0, + url: "http://127.0.0.1:1".into(), + pubkey: vec![], + root_epoch: 3, + }, + }, + } + } + + #[tokio::test] + async fn guest_preserves_env_at_the_current_epoch() { + let dir = tempfile::tempdir().unwrap(); + let args = SetupArgs { + work_dir: dir.path().into(), + device: "unused".into(), + mount_point: "unused".into(), + }; + let plaintext = br#"{"env":[{"key":"MESSAGE","value":"test"}]}"#; + let sealed = seal_env(&[7; 32], 3, plaintext).unwrap(); + let current = stage(&args, Some(sealed), b"invalid legacy".to_vec()); + let (opened, update) = current.decrypt_env().await.unwrap(); + assert_eq!(opened, plaintext); + assert!(update.is_empty()); + + let public = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([9; 32])); + let legacy = crate::crypto::dh_encrypt(public.to_bytes(), plaintext).unwrap(); + let old = stage(&args, None, legacy.clone()); + let (opened, update) = old.decrypt_env().await.unwrap(); + assert_eq!(opened, plaintext); + assert!(update.is_empty()); + let mut without_sealed_key = stage(&args, None, legacy.clone()); + without_sealed_key.keys.sealed_env_key.clear(); + let (opened, update) = without_sealed_key.decrypt_env().await.unwrap(); + assert_eq!(opened, plaintext); + assert!(update.is_empty()); + + for invalid in [ + Vec::new(), + b"invalid JSON".to_vec(), + br#"{"version":2,"root_epoch":3,"enc":"","ciphertext":""}"#.to_vec(), + ] { + assert!(stage(&args, Some(invalid), legacy.clone()) + .decrypt_env() + .await + .is_err()); + } + let future = seal_env(&[7; 32], 4, plaintext).unwrap(); + assert!(stage(&args, Some(future), legacy) + .decrypt_env() + .await + .unwrap_err() + .to_string() + .contains("exceeds current")); + } + + /// Run `close_env_window` against `answers`, returning its result and how + /// often the host was asked. + async fn close_with(answers: Vec>) -> (Result<()>, usize) { + let mut answers = answers.into_iter(); + let mut calls = 0; + let result = close_env_window( + || { + calls += 1; + std::future::ready(answers.next().unwrap()) + }, + &[Duration::ZERO; 2], + ) + .await; + (result, calls) + } + + #[tokio::test] + async fn applications_start_only_once_the_env_window_is_closed() { + let (result, calls) = close_with(vec![Ok(())]).await; + result.unwrap(); + assert_eq!(calls, 1); + // A lost answer is retried. + let (result, calls) = close_with(vec![Err(anyhow!("connection reset")), Ok(())]).await; + result.unwrap(); + assert_eq!(calls, 2); + // No answer within the retries fails the boot. + let (result, calls) = close_with(vec![ + Err(anyhow!("timed out")), + Err(anyhow!("timed out")), + Err(anyhow!("connection refused")), + ]) + .await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("refusing to start applications"), "{err}"); + assert!(err.contains("connection refused"), "{err}"); + assert_eq!(calls, 3); + } + + #[tokio::test] + async fn legacy_env_migration_skips_denied_intermediate_epochs() { + let secret = x25519_dalek::StaticSecret::from([8; 32]); + let public = x25519_dalek::PublicKey::from(&secret); + let plaintext = br#"{"env":[{"key":"SECRET","value":"synthetic"}]}"#; + let encrypted = crate::crypto::dh_encrypt(public.to_bytes(), plaintext).unwrap(); + let mut requested = Vec::new(); + let decrypted = decrypt_historical_env(&encrypted, (0..2).rev(), |epoch| { + requested.push(epoch); + std::future::ready(if epoch == 0 { + Ok(secret.to_bytes().to_vec()) + } else { + Err(anyhow::anyhow!("authorization denied")) + }) + }) + .await + .unwrap(); + assert_eq!(requested, [1, 0]); + assert_eq!(decrypted, plaintext); + assert!( + decrypt_historical_env(&encrypted, (0..2).rev(), |_| async { bail!("denied") }) + .await + .is_err() + ); + assert!( + decrypt_historical_env(&encrypted, (0..2).rev(), |_| async { Ok(vec![3; 32]) }) + .await + .is_err() + ); + } + + #[tokio::test] + async fn sealed_env_at_the_current_epochs_opens_and_binds_both_epochs() { + let dir = tempfile::tempdir().unwrap(); + let args = SetupArgs { + work_dir: dir.path().into(), + device: "unused".into(), + mount_point: "unused".into(), + }; + let plaintext = br#"{"env":[{"key":"VALUE","value":"epoch-test"}]}"#; + let sealed = seal_env_at_epochs(&[7; 32], 3, 2, plaintext).unwrap(); + let mut current = stage(&args, Some(sealed.clone()), vec![]); + if let KeyProvider::Kms { app_epoch, .. } = &mut current.keys.key_provider { + *app_epoch = 2; + } + let (opened, update) = current.decrypt_env().await.unwrap(); + assert_eq!(opened, plaintext); + assert!(update.is_empty()); + let mut tampered: Value = serde_json::from_slice(&sealed).unwrap(); + tampered["version"] = 2.into(); + current.shared.sealed_env = Some(serde_json::to_vec(&tampered).unwrap()); + let err = current.decrypt_env().await.unwrap_err(); + assert!( + err.to_string().contains("unsupported sealed env version"), + "{err}" + ); + let envelope: SealedEnv = serde_json::from_slice(&sealed).unwrap(); + assert_eq!(envelope.version, 1); + let payload = [envelope.enc.clone(), envelope.ciphertext.clone()].concat(); + assert!(crate::crypto::sealed_env_decrypt( + &[7; 32], + &sealed_env_info_at_epochs(3, 1), + &payload + ) + .is_err()); + assert!(crate::crypto::sealed_env_decrypt( + &[7; 32], + &sealed_env_info_at_epochs(2, 2), + &payload + ) + .is_err()); + } + + #[test] + fn sealed_file_selection_ignores_legacy_but_rejects_links() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(SYS_CONFIG), br#"{"vm_config":""}"#).unwrap(); + fs::write( + dir.path().join(APP_COMPOSE), + br#"{"manifest_version":"2","name":"test","runner":"docker-compose"}"#, + ) + .unwrap(); + let sealed_path = dir.path().join(SEALED_ENV); + fs::write(&sealed_path, b"sealed bytes").unwrap(); + fs::create_dir(dir.path().join(ENCRYPTED_ENV)).unwrap(); + let shared = HostShared::load(dir.path()).unwrap(); + assert_eq!(shared.sealed_env.unwrap(), b"sealed bytes"); + assert!(shared.encrypted_env.is_empty()); + fs::remove_file(&sealed_path).unwrap(); + std::os::unix::fs::symlink("missing-target", &sealed_path).unwrap(); + assert!(env_file_exists(&sealed_path).is_err()); + assert!(HostShared::load(dir.path()).is_err()); + } + + #[test] + fn missing_empty_and_unreadable_env_are_distinct() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SEALED_ENV); + assert_eq!(read_optional_env(&path).unwrap(), None); + fs::write(&path, []).unwrap(); + assert_eq!(read_optional_env(&path).unwrap(), Some(Vec::new())); + assert!( + serde_json::from_slice::(&read_optional_env(&path).unwrap().unwrap()) + .is_err() + ); + assert!(read_optional_env(dir.path()).is_err()); + } +} diff --git a/dstack/dstack-util/src/system_setup/luks.rs b/dstack/dstack-util/src/system_setup/luks.rs index 6d0381b9e..9d674dcba 100644 --- a/dstack/dstack-util/src/system_setup/luks.rs +++ b/dstack/dstack-util/src/system_setup/luks.rs @@ -2,20 +2,29 @@ // // SPDX-License-Identifier: Apache-2.0 -//! The subset of the LUKS2 on-disk format that `validate_luks2_headers` -//! checks. Fields it does not inspect are not modeled; serde ignores them. +//! LUKS2 header validation and key epoch migration. +//! Fields validation does not inspect are not modeled; serde ignores them. //! See . -use std::collections::HashMap; +use std::{ + collections::HashMap, + io::Write, + path::Path, + process::{Command, Stdio}, +}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use fs_err as fs; +use safe_write::safe_write_with_mode; +use scopeguard::defer; use serde::{de::IgnoredAny, Deserialize}; use serde_with::{serde_as, DisplayFromStr}; +use tracing::info; /// Size of the binary header that precedes each JSON area. -pub(super) const BINARY_HEADER_SIZE: u64 = 4096; +const BINARY_HEADER_SIZE: u64 = 4096; -pub(super) struct LuksHeader { +struct LuksHeader { pub magic: [u8; 6], pub version: u16, pub hdr_size: u64, @@ -47,17 +56,31 @@ impl LuksHeader { } #[derive(Deserialize)] -pub(super) struct LuksJson { +struct LuksJson { pub keyslots: HashMap, - pub tokens: HashMap, + pub tokens: HashMap, pub segments: HashMap, pub digests: HashMap, pub config: LuksConfig, } +/// Type of the only token dstack writes: the KMS key epochs of one keyslot. +const ROOT_EPOCH_TOKEN_TYPE: &str = "dstack-kms"; + +#[derive(Deserialize)] +struct LuksToken { + #[serde(rename = "type")] + pub kind: String, + pub keyslots: Vec, + #[serde(default)] + pub root_epoch: Option, + #[serde(default)] + pub app_epoch: u32, +} + #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub(super) enum LuksKeyslot { +enum LuksKeyslot { Luks2 { key_size: u16, area: LuksArea, @@ -71,7 +94,7 @@ pub(super) enum LuksKeyslot { #[serde_as] #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub(super) enum LuksArea { +enum LuksArea { Raw { encryption: String, key_size: u32, @@ -87,7 +110,7 @@ pub(super) enum LuksArea { /// it (see security report #552). #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub(super) enum LuksKdf { +enum LuksKdf { Pbkdf2 { hash: String, }, @@ -97,14 +120,14 @@ pub(super) enum LuksKdf { #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub(super) enum LuksAf { +enum LuksAf { Luks1 { hash: String, stripes: u16 }, } #[serde_as] #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub(super) enum LuksSegment { +enum LuksSegment { Crypt { #[serde_as(as = "DisplayFromStr")] offset: u64, @@ -123,7 +146,7 @@ pub(super) enum LuksSegment { #[serde_as] #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub(super) enum LuksDigest { +enum LuksDigest { Pbkdf2 { #[serde_as(as = "Vec")] keyslots: Vec, @@ -135,7 +158,7 @@ pub(super) enum LuksDigest { #[serde_as] #[derive(Deserialize)] -pub(super) struct LuksConfig { +struct LuksConfig { #[serde_as(as = "DisplayFromStr")] pub keyslots_size: u64, #[serde(default)] @@ -143,3 +166,823 @@ pub(super) struct LuksConfig { #[serde(default)] pub requirements: Option, } + +macro_rules! const_pad { + ($s:expr, $len:expr) => { + const { + assert!($s.len() <= $len, "The s is too long"); + let mut padded: [u8; $len] = [0; $len]; + let mut i = 0; + while i < $s.len() { + padded[i] = $s[i]; + i += 1; + } + padded + } + }; +} + +pub(super) const PAYLOAD_OFFSET: u64 = 16777216; + +fn cryptsetup(args: &[&str], device: &Path) -> Command { + let mut command = Command::new("cryptsetup"); + command.args(args).arg(device); + command +} + +/// Run `command` with `input` on stdin and return its exit code. +fn run_with_input(mut command: Command, input: &[u8]) -> Result> { + let mut child = command + .stdin(Stdio::piped()) + .spawn() + .context("failed to start cryptsetup")?; + child + .stdin + .take() + .context("cryptsetup stdin is unavailable")? + .write_all(input) + .context("failed to write to cryptsetup")?; + Ok(child + .wait() + .context("failed to wait for cryptsetup")? + .code()) +} + +/// Record that keyslot `slot` holds the key of `epoch` and `app_epoch`. +pub(super) fn write_luks_epochs(device: &Path, slot: u8, epoch: u32, app_epoch: u32) -> Result<()> { + let token = serde_json::json!({ + "type": ROOT_EPOCH_TOKEN_TYPE, + "keyslots": [slot.to_string()], + "root_epoch": epoch, + "app_epoch": app_epoch, + }); + let args = ["token", "import", "--token-id", "0", "--token-replace"]; + let code = run_with_input(cryptsetup(&args, device), token.to_string().as_bytes())?; + if code != Some(0) { + bail!("failed to record root epoch {epoch} in the LUKS header: {code:?}"); + } + Ok(()) +} + +/// Back up the LUKS header of `device` to `header` and validate the copy. +pub(super) fn load_luks_header(device: &Path, header: &Path) -> Result { + if header.exists() { + fs::remove_file(header)?; + } + let status = Command::new("cryptsetup") + .args(["luksHeaderBackup", "--header-backup-file"]) + .arg(header) + .arg(device) + .status() + .context("failed to start cryptsetup luksHeaderBackup")?; + if !status.success() { + bail!("failed to back up the LUKS header"); + } + validate_luks2_headers(fs::File::open(header)?).context("failed to validate the LUKS2 header") +} + +/// The keyslot of the validated `header` that `key` unlocks. +fn unlocked_keyslot(header: &Path, state: &LuksHeaderState, key: &str) -> Result> { + for &slot in &state.keyslots { + if unlocks(header, slot, key)? { + return Ok(Some(slot)); + } + } + Ok(None) +} + +/// Whether `key` unlocks keyslot `slot` of the validated `header`. +fn unlocks(header: &Path, slot: u8, key: &str) -> Result { + let slot_arg = slot.to_string(); + let test = cryptsetup( + &["open", "--test-passphrase", "--key-slot", &slot_arg, "-d-"], + header, + ); + match run_with_input(test, key.as_bytes())? { + Some(0) => Ok(true), + // No key available with this passphrase. + Some(2) => Ok(false), + code => bail!("failed to test the LUKS passphrase: {code:?}"), + } +} + +/// Add `new_key` in keyslot `slot`, authorized by `old_key`. The old key is +/// written next to `header`, which must be in a private in-memory directory. +fn add_luks_key( + device: &Path, + header: &Path, + old_key: &str, + new_key: &str, + slot: u8, +) -> Result<()> { + let old_file = header.with_file_name("old-key"); + defer! { + fs::remove_file(&old_file).ok(); + } + safe_write_with_mode(&old_file, old_key, 0o600)?; + let slot = slot.to_string(); + let args = [ + "luksAddKey", + "--batch-mode", + "--pbkdf", + "pbkdf2", + "--new-key-slot", + &slot, + ]; + let mut command = cryptsetup(&args, device); + command.arg("--key-file").arg(&old_file); + if run_with_input(command, new_key.as_bytes())? != Some(0) { + bail!("cryptsetup luksAddKey failed"); + } + Ok(()) +} + +fn kill_luks_slot(device: &Path, slot: u8) -> Result<()> { + let status = cryptsetup(&["luksKillSlot", "--batch-mode"], device) + .arg(slot.to_string()) + .stdin(Stdio::null()) + .status() + .context("failed to start cryptsetup luksKillSlot")?; + if !status.success() { + bail!("failed to remove LUKS keyslot {slot}"); + } + Ok(()) +} + +/// Bring the data disk to the `target` epochs, so that `key` unlocks its only +/// keyslot and the token records `target`. +/// +/// Each step is a single atomic LUKS2 metadata update: add the new key with +/// the old one, record the new epochs, then remove the old keyslot. Whatever +/// step a crash interrupts, the next boot finds either the old key alone or a +/// keyslot `key` already unlocks, and finishes from there. If another rotation +/// came first, `key` unlocks neither keyslot; the migration then restarts from +/// the keyslot the token records. `old_key` is only called when `key` unlocks +/// no keyslot. An unrotated epoch-0 disk is left +/// untouched, so it stays readable by older OS images. +/// +/// `header` holds the validated in-memory copy of `state` and is refreshed +/// after every change. +pub(super) async fn sync_luks_epochs( + device: &Path, + header: &Path, + mut state: LuksHeaderState, + key: &str, + target: (u32, u32), + old_key: F, +) -> Result +where + F: FnOnce(u32, u32) -> Fut, + Fut: std::future::Future>, +{ + let stored = (state.root_epoch.unwrap_or(0), state.app_epoch); + if stored.0 > target.0 || stored.1 > target.1 { + bail!("the data disk epochs {stored:?} exceed current {target:?}"); + } + let slot = match unlocked_keyslot(header, &state, key)? { + Some(slot) => slot, + None => { + if stored == target { + bail!("the data disk key does not unlock epochs {target:?}"); + } + // The keyslot holding the recorded epochs; a disk without a token + // is epoch 0 in slot 0. + let old_slot = state.token_keyslot.unwrap_or(0); + if !state.keyslots.contains(&old_slot) { + bail!("the recorded LUKS keyslot {old_slot} does not exist"); + } + info!("re-keying the data disk from epochs {stored:?} to {target:?}"); + let old_key = old_key(stored.0, stored.1).await?; + if !unlocks(header, old_slot, &old_key)? { + bail!( + "the key of epochs {stored:?} does not unlock the recorded keyslot {old_slot}" + ); + } + // A migration interrupted before a later rotation leaves a second + // keyslot whose epochs were never recorded, or are no longer the + // recorded ones. Drop it, now that the recorded key is confirmed, + // to make room for the current key. + for &stale in state.keyslots.iter().filter(|&&slot| slot != old_slot) { + kill_luks_slot(device, stale)?; + } + add_luks_key(device, header, &old_key, key, old_slot ^ 1)?; + state = load_luks_header(device, header)?; + unlocked_keyslot(header, &state, key)? + .context("the added LUKS keyslot does not unlock with the data disk key")? + } + }; + let mut changed = false; + let recorded = ( + state.token_keyslot, + state.root_epoch.unwrap_or(0), + state.app_epoch, + ); + let legacy = target == (0, 0) && state.token_keyslot.is_none(); + if !legacy && recorded != (Some(slot), target.0, target.1) { + write_luks_epochs(device, slot, target.0, target.1)?; + changed = true; + } + for &stale in state.keyslots.iter().filter(|&&stale| stale != slot) { + kill_luks_slot(device, stale)?; + changed = true; + } + if changed { + state = load_luks_header(device, header)?; + let recorded = (state.token_keyslot, state.root_epoch, state.app_epoch); + if state.keyslots != [slot] || recorded != (Some(slot), Some(target.0), target.1) { + bail!("the updated LUKS header does not record epochs {target:?}"); + } + } + Ok(state) +} + +/// Set by luksFormat and cleared once the filesystem exists, so a disk still +/// carrying it was interrupted mid-initialization and holds no data. +pub(super) const LUKS_INITIALIZING_LABEL: &str = "dstack-initializing"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct LuksHeaderState { + /// The header carries [`LUKS_INITIALIZING_LABEL`]. + pub(super) initializing: bool, + /// Root epoch recorded in the dstack token; `None` means epoch 0. + pub(super) root_epoch: Option, + pub(super) app_epoch: u32, + /// Keyslot the dstack token records the epochs of. + pub(super) token_keyslot: Option, + /// Sorted keyslot ids; a second one exists only mid-migration. + pub(super) keyslots: Vec, +} + +pub(super) fn validate_luks2_headers(mut reader: impl std::io::Read) -> Result { + let primary = validate_single_luks2_header(&mut reader, 0)?; + let secondary = validate_single_luks2_header(&mut reader, 1)?; + if primary != secondary { + bail!("LUKS header copies disagree"); + } + Ok(primary) +} + +fn validate_single_luks2_header( + mut reader: impl std::io::Read, + hdr_ind: u64, +) -> Result { + let mut hdr_data = vec![0u8; BINARY_HEADER_SIZE as usize]; + reader + .read_exact(&mut hdr_data) + .context("Failed to read LUKS header")?; + let LuksHeader { + magic, + version, + hdr_size, + label, + csum_alg, + subsystem, + hdr_offset, + } = LuksHeader::parse(&hdr_data).context("Failed to decode LUKS header")?; + + let expected_magic = match hdr_ind { + 0 => [76, 85, 75, 83, 186, 190], + 1 => [83, 75, 85, 76, 186, 190], + _ => bail!("Invalid LUKS header index: {hdr_ind}"), + }; + if magic != expected_magic { + bail!("Invalid LUKS magic: {magic:?}"); + } + if version != 2 { + bail!("Invalid LUKS version: {version}"); + } + let initializing = label == const_pad!(LUKS_INITIALIZING_LABEL.as_bytes(), 48); + if !initializing && label != [0; 48] { + bail!("Invalid LUKS label: {:?}", label); + } + if csum_alg != const_pad!(b"sha256", 32) { + bail!("Invalid LUKS checksum algorithm"); + } + if subsystem != [0; 48] { + bail!("Invalid LUKS subsystem"); + } + if hdr_offset != hdr_ind * hdr_size { + bail!("Invalid LUKS header offset: {hdr_offset}"); + } + if !(4096..=1024 * 1024 * 16).contains(&hdr_size) { + bail!("Invalid LUKS header size: {hdr_size}"); + } + + // Check JSON + let json_size = hdr_size - BINARY_HEADER_SIZE; + let mut jsn_data = vec![0u8; json_size as usize]; + reader + .read_exact(&mut jsn_data) + .context("Failed to read LUKS JSON")?; + let json_end = jsn_data + .iter() + .position(|&b| b == 0) + .unwrap_or(jsn_data.len()); + jsn_data.truncate(json_end); + + let LuksJson { + keyslots, + tokens, + segments, + digests, + config: + LuksConfig { + keyslots_size, + flags, + requirements, + }, + } = serde_json::from_slice(&jsn_data).context("Failed to decode LUKS JSON")?; + + let mut keyslot_ids: Vec = keyslots.keys().copied().collect(); + keyslot_ids.sort_unstable(); + if keyslot_ids != [0] && keyslot_ids != [1] && keyslot_ids != [0, 1] { + bail!("Invalid LUKS keyslots"); + } + let (root_epoch, app_epoch, token_keyslot) = match tokens.len() { + 0 => (None, 0, None), + 1 => { + let token = tokens.get(&0).context("Invalid LUKS token id")?; + let slot = match &token.keyslots[..] { + [slot] => slot.parse::().ok(), + _ => None, + }; + if token.kind != ROOT_EPOCH_TOKEN_TYPE + || !slot.is_some_and(|s| keyslots.contains_key(&s)) + { + bail!("Invalid LUKS token"); + } + let root_epoch = token.root_epoch.context("LUKS token has no root epoch")?; + (Some(root_epoch), token.app_epoch, slot) + } + _ => bail!("Invalid LUKS tokens"), + }; + if segments.len() != 1 { + bail!("Invalid LUKS segments"); + } + if digests.len() != 1 { + bail!("Invalid LUKS digests"); + } + if keyslots_size % 4096 != 0 { + bail!("Invalid LUKS keyslots size: {keyslots_size}"); + } + if flags.is_some() { + bail!("Invalid LUKS flags"); + } + if requirements.is_some() { + bail!("Invalid LUKS requirements"); + } + + for keyslot in keyslots.values() { + let LuksKeyslot::Luks2 { + key_size, + area: + LuksArea::Raw { + encryption, + key_size: area_key_size, + offset, + size, + }, + kdf, + af: + LuksAf::Luks1 { + hash: af_hash, + stripes, + }, + priority, + } = keyslot; + if encryption != "aes-xts-plain64" { + bail!("Invalid LUKS keyslot encryption: {encryption}"); + } + // Pin where the encrypted key material is read from. The binary area + // must sit between the two header copies and the encrypted payload; + // otherwise a host with raw disk access could redirect it elsewhere. + let area_end = offset.checked_add(*size); + if *offset < 2 * hdr_size || area_end.is_none_or(|end| end > PAYLOAD_OFFSET) { + bail!("Invalid LUKS keyslot area: offset={offset} size={size}"); + } + if *key_size != 64 { + bail!("Invalid LUKS keyslot key size: {key_size}"); + } + if *area_key_size != 64 { + bail!("Invalid LUKS keyslot key size: {area_key_size}"); + } + let LuksKdf::Pbkdf2 { hash: kdf_hash } = kdf else { + bail!("Invalid LUKS keyslot KDF"); + }; + if kdf_hash != "sha256" { + bail!("Invalid LUKS keyslot hash: {kdf_hash}"); + } + if af_hash != "sha256" { + bail!("Invalid LUKS keyslot hash: {af_hash}"); + } + if *stripes != 4000 { + bail!("Invalid LUKS keyslot stripes: {stripes}"); + } + if priority.is_some() { + bail!("Invalid LUKS keyslot priority"); + } + } + + { + let LuksSegment::Crypt { + offset, + size, + iv_tweak, + encryption, + sector_size, + integrity, + flags, + } = segments.get(&0).context("no LUKS segment")?; + if *offset != PAYLOAD_OFFSET { + bail!("Invalid LUKS segment offset"); + } + if size != "dynamic" { + bail!("Invalid LUKS segment size"); + } + if *iv_tweak != 0 { + bail!("Invalid LUKS segment IV tweak"); + } + if encryption != "aes-xts-plain64" { + bail!("Invalid LUKS segment encryption"); + } + if *sector_size != 512 { + bail!("Invalid LUKS segment sector size"); + } + if integrity.is_some() { + bail!("Invalid LUKS segment integrity"); + } + if flags.is_some() { + bail!("Invalid LUKS segment flags"); + } + } + { + let LuksDigest::Pbkdf2 { + keyslots, + segments, + hash, + } = digests.get(&0).context("no LUKS digest")?; + if hash != "sha256" { + bail!("Invalid LUKS digest hash: {hash}"); + } + let mut keyslots = keyslots.clone(); + keyslots.sort_unstable(); + if keyslots != keyslot_ids { + bail!("Invalid LUKS digest keyslots: {keyslots:?}"); + } + if segments != &[0] { + bail!("Invalid LUKS digest segments: {segments:?}"); + } + } + Ok(LuksHeaderState { + initializing, + root_epoch, + app_epoch, + token_keyslot, + keyslots: keyslot_ids, + }) +} + +#[test] +fn test_validate_luks2_header() { + let mut header_data = include_bytes!("../../tests/fixtures/luks_header_good").to_vec(); + let state = + validate_luks2_headers(&mut &header_data[..]).expect("Failed to validate LUKS2 header"); + assert_eq!( + state, + LuksHeaderState { + initializing: false, + root_epoch: None, + app_epoch: 0, + token_keyslot: None, + keyslots: vec![0], + } + ); + let hdr_size = LuksHeader::parse(&header_data).unwrap().hdr_size as usize; + for copy in [0, hdr_size] { + header_data[copy + 24..copy + 24 + LUKS_INITIALIZING_LABEL.len()] + .copy_from_slice(LUKS_INITIALIZING_LABEL.as_bytes()); + } + assert!( + validate_luks2_headers(&mut &header_data[..]) + .expect("Failed to validate LUKS2 header") + .initializing + ); + let header_data = include_bytes!("../../tests/fixtures/luks_header_cipher_null").to_vec(); + let error = validate_luks2_headers(&mut &header_data[..]).unwrap_err(); + assert!(error + .to_string() + .contains("Invalid LUKS keyslot encryption")); +} + +#[test] +fn test_validate_luks2_header_rejects_out_of_range_keyslot_area() { + // Redirect the keyslot binary area below the header region. Same length + // so the surrounding header stays intact; "00768" parses to 768, which is + // inside the header copies (< 2 * hdr_size) rather than the metadata gap. + let mut header = include_bytes!("../../tests/fixtures/luks_header_good").to_vec(); + let patched = patch_luks_json(&mut header, br#""offset":"32768""#, br#""offset":"00768""#); + assert_eq!(patched, 2, "expected to patch both header copies"); + let error = validate_luks2_headers(&mut &header[..]).unwrap_err(); + assert!(error.to_string().contains("Invalid LUKS keyslot area")); +} + +/// Replace `needle` with `replacement` inside every LUKS JSON region, keeping +/// each region the same length by giving back trailing NUL padding. +#[cfg(test)] +fn patch_luks_json(header: &mut [u8], needle: &[u8], replacement: &[u8]) -> usize { + assert!(replacement.len() >= needle.len()); + let grow = replacement.len() - needle.len(); + let mut patched = 0; + let mut i = 0; + while i + needle.len() <= header.len() { + if &header[i..i + needle.len()] != needle { + i += 1; + continue; + } + let tail = &mut header[i..]; + let end = tail.iter().position(|b| *b == 0).expect("NUL padding"); + assert!(end + grow < tail.len(), "not enough NUL padding"); + tail.copy_within(needle.len()..end, replacement.len()); + tail[..replacement.len()].copy_from_slice(replacement); + patched += 1; + i += replacement.len(); + } + patched +} + +#[cfg(test)] +struct TestDisk { + dir: tempfile::TempDir, + image: std::path::PathBuf, + header: std::path::PathBuf, +} + +#[cfg(test)] +impl TestDisk { + /// A disk formatted with `key` and recording `epochs`, or `None` without + /// cryptsetup. + fn new(key: &str, epochs: Option<(u32, u32)>) -> Option { + if Command::new("cryptsetup") + .arg("--version") + .output() + .is_err() + { + eprintln!("cryptsetup is unavailable; skipping"); + return None; + } + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("disk.img"); + let header = dir.path().join("header"); + fs::File::create(&image) + .unwrap() + .set_len(2 * PAYLOAD_OFFSET) + .unwrap(); + let offset = (PAYLOAD_OFFSET / 512).to_string(); + let format = cryptsetup( + &[ + "luksFormat", + "--batch-mode", + "--type", + "luks2", + "--offset", + &offset, + "--cipher", + "aes-xts-plain64", + "--pbkdf", + "pbkdf2", + "--sector-size", + "512", + "-d-", + ], + &image, + ); + assert_eq!(run_with_input(format, key.as_bytes()).unwrap(), Some(0)); + if let Some((root, app)) = epochs { + write_luks_epochs(&image, 0, root, app).unwrap(); + } + Some(Self { dir, image, header }) + } + + fn state(&self) -> LuksHeaderState { + load_luks_header(&self.image, &self.header).unwrap() + } + + fn unlocks(&self, key: &str) -> bool { + let test = cryptsetup(&["open", "--test-passphrase", "-d-"], &self.image); + run_with_input(test, key.as_bytes()).unwrap() == Some(0) + } + + fn add_key(&self, old: &str, new: &str, slot: u8) { + add_luks_key(&self.image, &self.header, old, new, slot).unwrap(); + } + + /// Sync to `target`; `old` is the only key the KMS would hand out. + async fn sync( + &self, + key: &str, + target: (u32, u32), + old: Option<(u32, u32, &str)>, + ) -> Result { + let old_key = |root, app| { + let key = old + .filter(|(r, a, _)| (*r, *a) == (root, app)) + .map(|(_, _, key)| key.to_owned()) + .context("the KMS does not serve this epoch"); + std::future::ready(key) + }; + sync_luks_epochs( + &self.image, + &self.header, + self.state(), + key, + target, + old_key, + ) + .await + } + + fn assert_synced(&self, key: &str, target: (u32, u32), slot: u8) { + let state = self.state(); + assert_eq!(state.keyslots, [slot]); + assert_eq!(state.token_keyslot, Some(slot)); + assert_eq!( + (state.root_epoch, state.app_epoch), + (Some(target.0), target.1) + ); + assert!(self.unlocks(key)); + assert!(!self.dir.path().join("old-key").exists()); + } +} + +#[tokio::test] +async fn test_luks_epoch_migration() { + let Some(disk) = TestDisk::new("k1", Some((1, 0))) else { + return; + }; + disk.sync("k2", (2, 0), Some((1, 0, "k1"))).await.unwrap(); + disk.assert_synced("k2", (2, 0), 1); + assert!(!disk.unlocks("k1")); + disk.sync("k3", (2, 1), Some((2, 0, "k2"))).await.unwrap(); + disk.assert_synced("k3", (2, 1), 0); + assert!(!disk.unlocks("k2")); + // Already current: no key is requested. + disk.sync("k3", (2, 1), None).await.unwrap(); + disk.assert_synced("k3", (2, 1), 0); +} + +#[tokio::test] +async fn test_luks_epoch_migration_resumes_after_each_step() { + // Crash after luksAddKey, then after the token import as well. + for record in [false, true] { + let Some(disk) = TestDisk::new("k1", Some((1, 0))) else { + return; + }; + disk.add_key("k1", "k2", 1); + if record { + write_luks_epochs(&disk.image, 1, 2, 0).unwrap(); + } + disk.sync("k2", (2, 0), None).await.unwrap(); + disk.assert_synced("k2", (2, 0), 1); + assert!(!disk.unlocks("k1")); + } +} + +#[tokio::test] +async fn test_luks_epoch_migration_resumes_after_a_later_rotation() { + // Interrupted while migrating to epoch 1, then rotated to epoch 2 before + // the next boot: the current key unlocks neither keyslot. + for record in [false, true] { + let Some(disk) = TestDisk::new("k0", None) else { + return; + }; + disk.add_key("k0", "k1", 1); + let old = if record { + write_luks_epochs(&disk.image, 1, 1, 0).unwrap(); + (1, 0, "k1") + } else { + (0, 0, "k0") + }; + disk.sync("k2", (2, 0), Some(old)).await.unwrap(); + disk.assert_synced("k2", (2, 0), if record { 0 } else { 1 }); + assert!(!disk.unlocks("k0")); + assert!(!disk.unlocks("k1")); + } +} + +#[tokio::test] +async fn test_luks_stale_keyslot_is_kept_until_the_recorded_key_is_confirmed() { + let Some(disk) = TestDisk::new("k0", None) else { + return; + }; + disk.add_key("k0", "k1", 1); + let interrupted = disk.state(); + // The KMS denies the recorded epoch, or hands out a key that does not + // unlock its keyslot: both keyslots stay. + assert!(disk.sync("k2", (2, 0), None).await.is_err()); + let err = disk + .sync("k2", (2, 0), Some((0, 0, "k1"))) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("does not unlock the recorded keyslot 0"), + "{err:#}" + ); + assert_eq!(disk.state(), interrupted); + assert!(disk.unlocks("k0") && disk.unlocks("k1")); +} + +#[tokio::test] +async fn test_luks_epoch_migration_with_disagreeing_header_copies() { + let Some(disk) = TestDisk::new("k1", Some((1, 0))) else { + return; + }; + disk.add_key("k1", "k2", 1); + let before = fs::read(&disk.image).unwrap(); + write_luks_epochs(&disk.image, 1, 2, 0).unwrap(); + let after = fs::read(&disk.image).unwrap(); + let hdr_size = LuksHeader::parse(&before).unwrap().hdr_size as usize; + // Interrupted between writing the two header copies, in either order. + for (primary, secondary) in [(&after, &before), (&before, &after)] { + let mut image = primary.clone(); + image[hdr_size..2 * hdr_size].copy_from_slice(&secondary[hdr_size..2 * hdr_size]); + fs::write(&disk.image, image).unwrap(); + disk.sync("k2", (2, 0), None).await.unwrap(); + disk.assert_synced("k2", (2, 0), 1); + } +} + +#[tokio::test] +async fn test_luks_epoch_migration_failures_keep_the_old_epochs() { + let Some(disk) = TestDisk::new("k1", Some((1, 0))) else { + return; + }; + let unchanged = disk.state(); + // The KMS denies the old epoch. + assert!(disk.sync("k2", (2, 0), None).await.is_err()); + // luksAddKey fails: the KMS answers with a key that does not unlock. + assert!(disk.sync("k2", (2, 0), Some((1, 0, "bad"))).await.is_err()); + // The disk records a root epoch newer than the current one. + let err = disk.sync("k0", (0, 3), None).await.unwrap_err(); + assert!(err.to_string().contains("exceed"), "{err:#}"); + assert_eq!(disk.state(), unchanged); + assert!(disk.unlocks("k1")); + + let app_ahead = TestDisk::new("k1", Some((1, 2))).unwrap(); + let err = app_ahead.sync("k1", (1, 1), None).await.unwrap_err(); + assert!(err.to_string().contains("exceed"), "{err:#}"); + + // A failed token import is an error. + let not_luks = disk.dir.path().join("not-luks"); + fs::write(¬_luks, [0; 4096]).unwrap(); + assert!(write_luks_epochs(¬_luks, 0, 2, 0).is_err()); +} + +#[tokio::test] +async fn test_luks_epoch_zero_disk_is_left_untouched() { + let Some(disk) = TestDisk::new("k0", None) else { + return; + }; + let unchanged = disk.state(); + disk.sync("k0", (0, 0), None).await.unwrap(); + assert_eq!(disk.state(), unchanged); + assert_eq!(unchanged.token_keyslot, None); +} + +#[test] +fn test_validate_luks2_header_root_epoch_token() { + let with_token = |token: &str| { + let mut header = include_bytes!("../../tests/fixtures/luks_header_good").to_vec(); + let replacement = format!(r#""tokens":{{"0":{token}}}"#); + let patched = patch_luks_json(&mut header, br#""tokens":{}"#, replacement.as_bytes()); + assert_eq!(patched, 2, "expected to patch both header copies"); + validate_luks2_headers(&mut &header[..]) + }; + let state = with_token(r#"{"type":"dstack-kms","keyslots":["0"],"root_epoch":2}"#).unwrap(); + assert_eq!(state.root_epoch, Some(2)); + for token in [ + r#"{"type":"luks2-keyring","keyslots":["0"],"root_epoch":2}"#, + r#"{"type":"dstack-kms","keyslots":["1"],"root_epoch":2}"#, + r#"{"type":"dstack-kms","keyslots":["0"]}"#, + ] { + assert!(with_token(token).is_err(), "{token}"); + } +} + +#[test] +fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() { + let mut header = include_bytes!("../../tests/fixtures/luks_header_good").to_vec(); + // 2**64 - 32768 + 1000: added to the accepted offset 32768 it wraps to + // 1000, which is below PAYLOAD_OFFSET. + let patched = patch_luks_json( + &mut header, + br#""size":"258048""#, + br#""size":"18446744073709519848""#, + ); + assert_eq!(patched, 2, "expected to patch both header copies"); + let error = validate_luks2_headers(&mut &header[..]).unwrap_err(); + assert!( + error.to_string().contains("Invalid LUKS keyslot area"), + "{error:#}" + ); +} diff --git a/dstack/dstack-util/src/system_setup/sealed_env.rs b/dstack/dstack-util/src/system_setup/sealed_env.rs new file mode 100644 index 000000000..ef7a717f6 --- /dev/null +++ b/dstack/dstack-util/src/system_setup/sealed_env.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Environment envelope encoding and encryption for guest boot migration. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub(super) struct SealedEnv { + pub(super) version: u32, + pub(super) root_epoch: u32, + pub(super) app_epoch: u32, + #[serde(with = "serde_human_bytes::base64")] + pub(super) enc: Vec, + #[serde(with = "serde_human_bytes::base64")] + pub(super) ciphertext: Vec, +} + +pub(super) fn sealed_env_info_at_epochs(root: u32, app: u32) -> Vec { + format!("dstack-sealed-env-v1:{root}:{app}").into_bytes() +} + +pub(super) fn seal_env_at_epochs( + secret: &[u8], + epoch: u32, + app_epoch: u32, + plaintext: &[u8], +) -> Result> { + let secret: [u8; 32] = secret.try_into().context("invalid sealed env key")?; + let public = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(secret)); + let payload = crate::crypto::sealed_env_encrypt( + &public.to_bytes(), + &sealed_env_info_at_epochs(epoch, app_epoch), + plaintext, + )?; + let (enc, ciphertext) = payload.split_at(32); + Ok(serde_json::to_vec(&SealedEnv { + version: 1, + root_epoch: epoch, + app_epoch, + enc: enc.to_vec(), + ciphertext: ciphertext.to_vec(), + })?) +} + +pub(super) fn env_crypt_key(key: &[u8]) -> Result<[u8; 32]> { + key.try_into() + .ok() + .context("encrypted env is present but the key provider gave no valid env crypt key") +} + +#[cfg(test)] +pub(super) fn seal_env(secret: &[u8], epoch: u32, plaintext: &[u8]) -> Result> { + seal_env_at_epochs(secret, epoch, 0, plaintext) +} diff --git a/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto b/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto index 3df408ad8..1a36a6692 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc_v1.proto @@ -189,6 +189,12 @@ message GetKeyRequest { // signing method: v0's `secp256k1_prehashed` was a mode wearing an // algorithm's name, and it does not appear here. string algorithm = 2; + // KMS root key epoch to derive from. Unset means the current epoch, which is + // what new keys should use; epoch 0 is the original root keys. Name an older + // epoch only to reach a key from before a KMS root key rotation, for example + // to migrate data or assets off it. + optional uint32 root_epoch = 3; + optional uint32 app_epoch = 4; } message GetKeyResponse { @@ -209,6 +215,10 @@ message GetKeyResponse { // unversioned surface returns. Link [0] is not: its claim encoding is new, // and it is deliberately not one the v0 claim format can produce. repeated bytes signature_chain = 3; + // KMS root key epoch the key was derived from. Link [1] is signed by that + // epoch's KMS root key. + uint32 root_epoch = 4; + uint32 app_epoch = 5; } // --------------------------------------------------------------------------- diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index a6dcd8e5f..5a3ac3e3a 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -92,11 +93,40 @@ pub struct AppState { inner: Arc, } +/// The app root key of one KMS root epoch, with its KMS signature. +#[derive(Clone)] +pub(crate) struct AppRoot { + pub epoch: u32, + pub app_epoch: u32, + pub k256_key: Vec, + pub signing_key: SigningKey, + pub kms_signature: Vec, +} + +impl AppRoot { + fn new(epoch: u32, app_epoch: u32, k256_key: Vec, kms_signature: Vec) -> Result { + let signing_key = SigningKey::from_slice(&k256_key) + .context("the app root k256 key is not a valid secp256k1 scalar")?; + Ok(Self { + epoch, + app_epoch, + k256_key, + signing_key, + kms_signature, + }) + } +} + struct AppStateInner { config: Config, keys: AppKeys, vm_config: String, cert_client: CertRequestClient, + /// Successfully fetched roots of earlier epochs. The lock is not held + /// while fetching, so concurrent first requests for one epoch may each ask + /// the KMS; the first result is kept. Failures are not cached, so a refused + /// epoch is asked for again on every call. + historical_roots: tokio::sync::Mutex>, demo_cert: tokio::sync::OnceCell, /// When the last demo-cert request failed; held while one is running. demo_cert_last_failure: tokio::sync::Mutex>, @@ -300,6 +330,7 @@ impl AppState { config, keys, cert_client, + historical_roots: Default::default(), demo_cert: Default::default(), demo_cert_last_failure: Default::default(), vm_config, @@ -377,6 +408,81 @@ impl AppState { Ok(key) } + /// KMS root epoch of the app keys this agent serves. + pub(crate) fn root_epoch(&self) -> u32 { + self.inner.keys.root_epoch() + } + + /// The app root key and its KMS signature of `root_epoch`; the current + /// ones when unset. An older epoch is requested from the KMS once and kept + /// in memory. + pub(crate) async fn app_root( + &self, + root_epoch: Option, + app_epoch: Option, + ) -> Result { + let current = self.root_epoch(); + let epoch = root_epoch.unwrap_or(current); + let current_app = self.inner.keys.app_epoch(); + let app_epoch = app_epoch.unwrap_or(current_app); + if app_epoch > current_app { + anyhow::bail!("app epoch {app_epoch} is newer than current {current_app}"); + } + if epoch == current && app_epoch == current_app { + return Ok(AppRoot { + epoch, + app_epoch, + k256_key: self.app_root_k256_key().to_vec(), + signing_key: self.app_root_signing_key()?.clone(), + kms_signature: self.kms_k256_signature().to_vec(), + }); + } + if epoch > current { + anyhow::bail!("root epoch {epoch} is newer than the current one, {current}"); + } + self.historical_root(epoch, app_epoch).await + } + + pub(crate) async fn historical_root(&self, epoch: u32, app_epoch: u32) -> Result { + if let Some(root) = self + .inner + .historical_roots + .lock() + .await + .get(&(epoch, app_epoch)) + .cloned() + { + return Ok(root); + } + let keys = self + .inner + .cert_client + .get_app_key(Some(epoch), Some(app_epoch)) + .await + .with_context(|| format!("failed to fetch the app keys of root epoch {epoch}"))?; + let root = AppRoot::new(epoch, app_epoch, keys.k256_key, keys.k256_signature)?; + let mut roots = self.inner.historical_roots.lock().await; + Ok(roots + .entry((epoch, app_epoch)) + .or_insert_with(|| root.clone()) + .clone()) + } + + #[cfg(test)] + pub(crate) async fn insert_historical_root( + &self, + epoch: u32, + k256_key: Vec, + kms_signature: Vec, + ) { + let root = AppRoot::new(epoch, 0, k256_key, kms_signature).unwrap(); + self.inner + .historical_roots + .lock() + .await + .insert((epoch, 0), root); + } + /// The KMS root key's signature over the app root public key: the second /// link of every signature chain, produced outside this agent and passed /// through byte-for-byte on both API surfaces. @@ -1152,10 +1258,45 @@ pub(crate) mod tests { .expect("certificate carries an attestation") } + pub(crate) const HISTORICAL_K256_KEY: [u8; 32] = [0x11; 32]; + pub(crate) async fn setup_test_state() -> (AppState, tempfile::NamedTempFile) { setup_test_state_with_platform(None).await } + #[tokio::test] + async fn app_root_selectors_and_cache_keep_epochs_independent() { + let (mut state, _guard) = setup_test_state().await; + Arc::get_mut(&mut state.inner).unwrap().keys.key_provider = KeyProvider::Kms { + url: "https://unused.invalid".into(), + pubkey: vec![], + root_epoch: 2, + app_epoch: 2, + }; + for (root, app, byte) in [(2, 0, 0x11), (0, 2, 0x22), (0, 0, 0x33)] { + state.inner.historical_roots.lock().await.insert( + (root, app), + AppRoot::new(root, app, vec![byte; 32], vec![]).unwrap(), + ); + } + let current = state.app_root(None, None).await.unwrap(); + assert_eq!((current.epoch, current.app_epoch), (2, 2)); + for (root, app, byte) in [ + (None, Some(0), 0x11), + (Some(0), None, 0x22), + (Some(0), Some(0), 0x33), + ] { + let key = state.app_root(root, app).await.unwrap(); + assert_eq!( + (key.epoch, key.app_epoch), + (root.unwrap_or(2), app.unwrap_or(2)) + ); + assert_eq!(key.k256_key, vec![byte; 32]); + } + assert!(state.app_root(Some(3), None).await.is_err()); + assert!(state.app_root(None, Some(3)).await.is_err()); + } + /// How many times the fixture platform was asked to attest for `Info`, and /// whether it should refuse. Counting the calls is the only way to see the /// identity throttle work: what it changes is how often the platform is @@ -1351,6 +1492,7 @@ pNs85uhOZE8z2jr8Pg== let dummy_keys = AppKeys { disk_crypt_key: Vec::new(), env_crypt_key: Vec::new(), + sealed_env_key: Vec::new(), k256_key: DUMMY_K256_KEY.to_vec(), k256_signature: Vec::new(), gateway_app_id: String::new(), @@ -1442,6 +1584,7 @@ pNs85uhOZE8z2jr8Pg== keys: dummy_keys, vm_config: String::new(), cert_client: dummy_cert_client, + historical_roots: Default::default(), demo_cert: Default::default(), demo_cert_last_failure: Default::default(), platform: Arc::new(TestSimulatorPlatform { diff --git a/dstack/guest-agent/src/rpc_service_v1.rs b/dstack/guest-agent/src/rpc_service_v1.rs index 354e94618..7eadc40ea 100644 --- a/dstack/guest-agent/src/rpc_service_v1.rs +++ b/dstack/guest-agent/src/rpc_service_v1.rs @@ -159,16 +159,20 @@ impl V1RpcHandler { Self { state } } - /// Derive the key named by `(domain, algorithm)` together with its - /// signature chain. - fn derive(&self, domain: &str, algorithm: &str) -> Result<(AppKey, Vec>)> { + /// Derive the key named by `(domain, algorithm)` under the app root of + /// `root_epoch`, together with its signature chain and that epoch. + async fn derive( + &self, + domain: &str, + algorithm: &str, + root_epoch: Option, + app_epoch: Option, + ) -> Result<(AppKey, Vec>, u32, u32)> { let algorithm = Algorithm::parse(algorithm)?; - let key = AppKey::derive(self.state.app_root_k256_key(), domain, algorithm)?; - let chain = vec![ - key.claim_signature(self.state.app_root_signing_key()?)?, - self.state.kms_k256_signature().to_vec(), - ]; - Ok((key, chain)) + let root = self.state.app_root(root_epoch, app_epoch).await?; + let key = AppKey::derive(&root.k256_key, domain, algorithm)?; + let chain = vec![key.claim_signature(&root.signing_key)?, root.kms_signature]; + Ok((key, chain, root.epoch, root.app_epoch)) } } @@ -196,11 +200,20 @@ impl DstackGuestRpc for V1RpcHandler { } async fn get_key(self, request: GetKeyRequest) -> Result { - let (key, signature_chain) = self.derive(&request.domain, &request.algorithm)?; + let (key, signature_chain, root_epoch, app_epoch) = self + .derive( + &request.domain, + &request.algorithm, + request.root_epoch, + request.app_epoch, + ) + .await?; Ok(GetKeyResponse { key: key.secret(), public_key: key.public_key(), signature_chain, + root_epoch, + app_epoch, }) } @@ -342,9 +355,54 @@ mod tests { GetKeyRequest { domain: domain.to_string(), algorithm: algorithm.to_string(), + root_epoch: None, + app_epoch: None, } } + #[tokio::test] + async fn get_key_selects_the_root_epoch() { + let (state, _guard) = state().await; + let handler = || V1RpcHandler::new(state.clone()); + let request = |root_epoch| GetKeyRequest { + root_epoch, + ..get_key_request("storage-encryption", "secp256k1") + }; + + let current = handler().get_key(request(None)).await.unwrap(); + assert_eq!(current.root_epoch, 0); + assert_eq!(current.app_epoch, 0); + assert!(handler() + .get_key(GetKeyRequest { + app_epoch: Some(1), + ..request(None) + }) + .await + .is_err()); + assert_eq!( + handler().get_key(request(Some(0))).await.unwrap().key, + current.key + ); + assert!(handler().get_key(request(Some(1))).await.is_err()); + } + + /// Older epochs come from the KMS once and are then served from memory; + /// the fixture has no KMS, so the cache is filled directly. + #[tokio::test] + async fn an_older_root_is_fetched_once_and_cached() { + let (state, _guard) = state().await; + let key = crate::rpc_service::tests::HISTORICAL_K256_KEY.to_vec(); + state + .insert_historical_root(1, key.clone(), vec![0xab; 65]) + .await; + let root = state.historical_root(1, 0).await.unwrap(); + assert_eq!(root.k256_key, key); + assert_eq!(root.kms_signature, [0xab; 65]); + // An uncached epoch needs the KMS, which a local key provider lacks. + assert!(state.historical_root(2, 0).await.is_err()); + assert!(state.historical_root(1, 1).await.is_err()); + } + /// The fixture's app root key is what `keys::tests` committed its vectors /// against. If the two drift, the vectors stop describing what this /// handler serves and every assertion built on them goes quiet. diff --git a/dstack/host-api/proto/host_api.proto b/dstack/host-api/proto/host_api.proto index f8864f516..85106ed6e 100644 --- a/dstack/host-api/proto/host_api.proto +++ b/dstack/host-api/proto/host_api.proto @@ -27,8 +27,19 @@ message GetSealingKeyResponse { bytes provider_quote = 2; } +// The env the guest re-encrypted to the current KMS root epoch at boot; empty +// when it needed no change. +message SealedEnv { + bytes sealed_env = 1; +} + service HostApi { rpc Info(google.protobuf.Empty) returns (HostInfo); rpc Notify(Notification) returns (google.protobuf.Empty); rpc GetSealingKey(GetSealingKeyRequest) returns (GetSealingKeyResponse); + // Close this boot's env update window, replacing the VM's stored encrypted + // env if it is still the one the VM booted with. Accepted once per boot; any + // answer means the window is closed, and the guest starts application code + // only after one. + rpc UpdateSealedEnv(SealedEnv) returns (google.protobuf.Empty); } diff --git a/dstack/host-api/src/client.rs b/dstack/host-api/src/client.rs index 5a0606009..aa6f214ff 100644 --- a/dstack/host-api/src/client.rs +++ b/dstack/host-api/src/client.rs @@ -4,6 +4,7 @@ use crate::host_api_client::HostApiClient; use http_client::prpc::PrpcClient; +pub use http_client::prpc::StatusError; pub type DefaultClient = HostApiClient; diff --git a/dstack/http-client/src/prpc.rs b/dstack/http-client/src/prpc.rs index 5971a1224..8ad81fbaa 100644 --- a/dstack/http-client/src/prpc.rs +++ b/dstack/http-client/src/prpc.rs @@ -87,6 +87,34 @@ impl PrpcClient { } } +/// A pRPC server answered with a status other than 200. +/// +/// Carried in the returned `anyhow::Error`, so callers can tell, say, a method +/// the server does not implement from other failures without parsing text. +#[derive(Debug)] +pub struct StatusError { + pub path: String, + pub status: u16, + /// The reason the server gave, if it gave one. + pub message: Option, +} + +impl std::fmt::Display for StatusError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + path, + status, + message, + } = self; + match message { + Some(message) => write!(f, "{path} failed with status {status}: {message}"), + None => write!(f, "Invalid status code: {status}, path={path}"), + } + } +} + +impl std::error::Error for StatusError {} + /// The reason a pRPC server gave for failing a JSON request, if it gave one. /// /// Servers built on `ra-rpc` answer a failed call with `{"error": "..."}`. @@ -142,10 +170,12 @@ impl RequestClient for PrpcClient { None => request.await?, }; if status != 200 { - match server_error_message(&body) { - Some(error) => anyhow::bail!("{path} failed with status {status}: {error}"), - None => anyhow::bail!("Invalid status code: {status}, path={path}"), + return Err(StatusError { + path, + status, + message: server_error_message(&body), } + .into()); } let response = serde_json::from_slice(normalize_json_response_body(&body)) .context("Failed to deserialize response")?; diff --git a/dstack/kms/Cargo.toml b/dstack/kms/Cargo.toml index 5c616d7a4..b60339120 100644 --- a/dstack/kms/Cargo.toml +++ b/dstack/kms/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { workspace = true, features = ["json"] } sha2.workspace = true sha3.workspace = true k256.workspace = true +p256.workspace = true rand.workspace = true dstack-guest-agent-rpc.workspace = true http-client = { workspace = true, features = ["prpc"] } @@ -52,6 +53,7 @@ moka = { workspace = true, features = ["future"] } [dev-dependencies] cc-eventlog.workspace = true +mock-attestation = { path = "../crates/mock-attestation" } [features] default = [] diff --git a/dstack/kms/README.md b/dstack/kms/README.md index 815424fd3..1c89cc615 100644 --- a/dstack/kms/README.md +++ b/dstack/kms/README.md @@ -55,7 +55,11 @@ certs = "/etc/kms/certs/rpc.crt" ``` The token can also be supplied via the `DSTACK_KMS_ADMIN_TOKEN` or -`ADMIN_API_TOKEN` environment variables instead of the config file. The admin +`ADMIN_API_TOKEN` environment variables instead of the config file, or as its +hex SHA-256 in `auth_token_hash` (`printf '%s' "$TOKEN" | sha256sum`) for a +config that is not secret, such as a KMS app compose. The hash is then public, +so the token must be high-entropy random, such as `openssl rand -hex 32`: a +weak token can be brute-forced offline from its hash. The admin listener fails closed: enabled with neither `auth_token` nor `htpasswd_file` (and `insecure_no_auth = false`) refuses to start. With `[core.admin.tls]` the listener serves HTTPS (use `http://` without it). Call it with, for example: @@ -73,8 +77,39 @@ below, which decides whether a CVM may boot and receive keys. KMS-to-KMS onboarding calls `Admin.GetKmsKey` with a self-issued RA-TLS client certificate plus the source's admin bearer token, so it requires `[core.admin.tls]` and an admin address the new KMS can reach; other admin RPCs -need no client certificate. `core.onboard.public_key_handover = true` (the default) keeps -`KMS.GetKmsKey` on the public listener for older clients. +need no client certificate. `core.onboard.public_key_handover` is off by default; set it to +`true` only to also serve `KMS.GetKmsKey` on the public listener for older +clients. + +A KMS refuses to start serving without a handover path, so that other nodes can +always onboard from it: unless `public_key_handover = true`, it requires +`[core.admin] enabled = true` and `[core.admin.tls]` with `key` and `certs`, and +the error names the missing setting. The check runs at startup, before +onboarding or bootstrap, so a misconfigured node fails before it gets or +generates root keys. There is no opt-out, `auth_api = "dev"` included. The default `kms.toml` +enables the admin listener with TLS on the RPC key and certificate, so it +starts only once an admin token is set (`auth_token`, +`DSTACK_KMS_ADMIN_TOKEN` or `auth_token_hash`). The `dstack-app` composes and +`dstackup` set `auth_token_hash` from a token they generate. + +Three settings relax authorization, each over a different scope: + +| Setting | Effect | +|---------|--------| +| `core.enforce_self_authorization = false` | Skips only this KMS's self check. Root epochs are still checked against the registry (`servable_epoch`), and every app and caller is still authorized. | +| `core.auth_api.type = "dev"` | Allows everything: no backend is consulted for self, app or KMS checks. Development only. | +| `core.onboard.public_key_handover = true` | Only bypasses the admin listener for key handover: `KMS.GetKmsKey` stays on the public listener, still subject to the self, caller and key-release checks. | + +The self check caches this KMS's own boot info for an hour, so a change to its +TCB status takes effect up to an hour later, and caches the backend's decision +for a second. + +`GetAppEnvEncryptPubKey` and `GetSealedEnvPubKey` need no client certificate and +read the app epoch from the auth backend (`/app/:appId`). The KMS caches each +app's epoch for a second (up to 10,000 apps; failures are not cached), so +repeated requests for one app reach the backend once. Requests for random app +IDs still pass through and, on an Ethereum backend, cost chain RPC calls; rate +limit them per source in the reverse proxy or gateway in front of the KMS. ## KMS Implementation @@ -83,16 +118,16 @@ need no client certificate. `core.onboard.public_key_handover = true` (the defau 1. **dstack-kms** - Main RPC service for app key requests - Quote verification and boot info validation - - Asks `dstack-kms-auth-eth` for permission + - Asks the auth webhook ([`auth-eth`](auth-eth/)) for permission - Builtin Replicator for root keys -2. **dstack-kms-auth-eth** +2. **[auth-eth](auth-eth/) webhook server** - Chain interface for permission checks - Two-step validation: 1. KMS control contract check 2. App control contract check -3. **Authorization Contracts** +3. **Authorization Contracts** ([`auth-eth`](auth-eth/)) - `DstackKms.sol` - Maintains a registry for all Applications - Maintains the allowed KMS Instance MRs @@ -244,3 +279,140 @@ The `SignCert` RPC is used by the dstack app to sign a TLS certificate. In this - Verify the CSR signature - Query the smart contract to check if the app is authorized - If authorized, sign the CSR with the CA root key and return the certificate chain to the app + + +## Authorization protocol + +Authorization uses two explicit endpoints: + +- `POST /authorizeKms` for KMS self-authorization and root-key handover. +- `POST /authorizeApp` for application keys and certificates. + +The Ethereum backends call the matching `DstackKms` method. Both accept +`AuthorizationRequest` and return `AuthorizationResult`; no caller-kind flag is +sent. The KMS method checks the KMS policy; the app method applies registered app +policy. +KMS callers do not fetch contract metadata to decide which policy to use. + +`requestedAppEpoch` selects the application's own key epoch, independently of KMS +root epochs. Omission uses `currentAppEpoch`; explicit zero selects app epoch zero. +Solidity uses `OptionalUint256 { isSet, value }` for both selectors. Future epochs +are denied; historical epochs remain subject to current authorization policy. +Rejection responses still carry the current epochs and root chain hash. See +[application key epochs](../../docs/app-key-epochs.md) for rotation and migration. + +`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 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 +`keccak256(bytes(operation))` as `bytes32`. Rust uses the shared serde-derived +`AuthorizationOperation` enum. Unknown operations and operations on the wrong +endpoint are rejected. The supported operations currently share the existing +material policy, leaving room for independent policies later: + +| Endpoint | Operation | Subject | +| --- | --- | --- | +| `authorizeKms` | `kms_bootstrap` | KMS generating its first roots | +| `authorizeKms` | `kms_serve` | KMS serving app keys, public keys or certificates | +| `authorizeKms` | `kms_export_keys` | Source KMS handing over its keyring | +| `authorizeKms` | `kms_import_keys` | Recipient KMS receiving a keyring | +| `authorizeApp` | `app_get_key` | App receiving derived keys | +| `authorizeApp` | `app_get_cert` | App requesting a certificate | + +The KMS chooses the operation from its code path, not an untrusted RPC field. +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. + +The response is an `AuthorizationResult`: + +```json +{ + "authorized": true, + "rejectionReason": "", + "gatewayAppId": "0x...", + "currentRootEpoch": 0, + "currentAppEpoch": 0, + "currentRootEpochHash": "0x...", + "latestRootEpoch": 0, + "latestRootEpochHash": "0x..." +} +``` + +Both successful and rejected decisions include the current epoch and chain hash +from the same contract call or dstack-auth configuration snapshot. `latestRootEpoch` +and `latestRootEpochHash` report the latest registered epoch, which may be pending +activation; onboarding refuses a source holding epochs beyond it and, when the source +holds it, requires its chain hash to match. Except for `kms_bootstrap`, with no +registered epoch the result is `authorized: false`, +`rejectionReason: "Root epoch not registered"`, epochs 0 and zero hashes. A zero hash +never authorizes key release or requests an epoch switch. + +The KMS holds no serving-epoch snapshot. It verifies the returned current epoch +and chain hash against its keyring, then derives keys from `requestedRootEpoch` when +specified or the returned current epoch otherwise. Missing or mismatched roots +fail closed. Self-authorization retains its one-second cache of complete results. + +The TLS listener has a separate lifetime: when an authorized current CA differs +from the running RPC certificate's issuer, the KMS requests a restart to refresh +that certificate and the caller must retry. The RPC CA does not select key +epochs. Application key responses and signed certificate chains report the +selected epoch's CA. CA certificates +are reloaded after renewal at startup. + +Upgrade the contract and auth backend and register epoch 0 before using this +protocol for key release. Runtime authorization never falls back to the legacy +API if the new endpoint or contract method is unavailable. The old contract +methods and `/bootAuth/app` and `/bootAuth/kms` endpoints remain available for +older clients. Bootstrap uses the `kms_bootstrap` operation, which checks KMS policy at epoch zero +even before roots are registered. Other operations require registered roots. +Onboarding checks its source through `authorizeKms`, without a legacy fallback. For +`dstack-auth`, set `kmsContractAddr` to the attested KMS app ID and configure +`rootEpochs`. For development without a backend, use `[core.auth_api] type = "dev"`, +which allows everything. + + +## Direct attested key retrieval + +`GetTempCaCert`, its original response fields, and the `temp_ca_key` handover +field remain available for existing OS images and legacy KMS onboarding. The +compatibility CA is stored in `tmp-ca.key`/`tmp-ca.crt`, independent of all root +epochs; existing files are reused and missing files are recreated. Its public +private key authenticates nobody. Client identity still requires attestation. +The returned `ca_cert` is the currently served root CA. + +Upgrade contracts and auth backends, register the original roots as epoch 0, +then upgrade KMS nodes before guests. Keep root and app epochs at zero during +the mixed-version phase; rotate only after guests support key migration. + +Guests and `dstack-util get-keys` generate their self-issued RA-TLS certificate +locally, then call `GetAppKey` directly. They verify the response connection's +attestation and `kms:rpc` usage, and verify that the returned root CA signed that +connection's certificate. Skipping CA-chain validation during this first request +still verifies the TLS handshake signature, so replaying an attested certificate +without its private key cannot impersonate the server. There is no `GetMeta` preflight or first unauthenticated +connection. The optional provider pin and the key-provider launch measurement +still bind the root identity; a valid quote alone is not a KMS policy decision. +`--root-ca` additionally pins the CLI connection before sending the request. + +Onboarding with an admin token uses a self-issued client certificate. Tokenless +onboarding, which needs a source with `core.onboard.public_key_handover = true`, +obtains the source temp CA first so pre-0.6 sources remain usable. +Both paths check the source attestation captured from the key-carrying connection +against local KMS policy before importing anything. Retries and reconnects repeat +verification on the response connection; no assumption relies on one TCP session +lasting forever. All subsequent key requests pin the KMS CA established at startup, regardless +of whether the epoch selector is omitted or explicit. A supplied `trusted_ca` +always wins over the response CA. The selected epoch's CA never replaces this +lifetime pin; a TLS CA change requires a fresh boot/trust establishment rather +than silently trusting another KMS. Without a supplied pin, the response CA +must sign the actual RPC certificate, even when an epoch is explicitly selected. diff --git a/dstack/kms/auth-eth-bun/README.md b/dstack/kms/auth-eth-bun/README.md deleted file mode 100644 index 9dcd4c955..000000000 --- a/dstack/kms/auth-eth-bun/README.md +++ /dev/null @@ -1,193 +0,0 @@ -# dstack KMS auth-eth-bun - -a single-file implementation of the dstack KMS ethereum backend using bun + hono + zod. - -## features - -- 🚀 fast and lightweight with bun runtime -- 🔧 modern web framework with hono.js -- ✅ type-safe validation with zod.js -- 📦 single file implementation -- 🔐 ethereum smart contract integration with viem - -## installation - -```bash -# install dependencies -bun install -``` - -## usage - -### development -```bash -# run with hot reload -bun run dev -``` - -### production -```bash -# run directly -bun run start - -# or build first -bun run build -``` - -### testing -```bash -# run tests (watch mode) -bun run test - -# run tests once -bun run test:run -``` - -### code quality -```bash -# run linter -bun run lint - -# run linter and fix issues -bun run lint:fix - -# format code -bun run format - -# run full check (lint + tests) -bun run check -``` - -## environment variables - -- `ETH_RPC_URL` - ethereum RPC endpoint (default: http://localhost:8545) -- `KMS_CONTRACT_ADDR` - KMS contract address (default: 0x0000000000000000000000000000000000000000) -- `PORT` - server port (default: 3000) -- `ETH_CHAIN_ID` - reject every decision if the RPC reports a different chain ID (default: unset, no check) -- `ETH_FINALITY_CONFIRMATIONS` - read policy this many blocks behind the RPC head (default: 0, the head) - -The Bun server sets `idleTimeout` to 255s (Bun's maximum). Bun's default of 10s closes the socket while a slow `eth_call` is still in flight, which surfaces on the KMS side as `connection closed before message completed`. - -## container image - -tagged releases are published by the `KMS auth-eth-bun` workflow -(`.github/workflows/kms-auth-eth-bun.yml`): pushing the tag -`kms-auth-eth-bun-v` builds this directory and publishes -`ghcr.io/dstack-tee/dstack-kms-auth-eth-bun:` with a build provenance -attestation. pull requests that touch this directory run the audit, lint and -tests and build the image without pushing it. - -the image listens on port 8000, which matches the KMS webhook default -`http://auth-api:8000`. deploy it by digest: - -```yaml -auth-api: - image: ghcr.io/dstack-tee/dstack-kms-auth-eth-bun:@sha256: - environment: - - ETH_RPC_URL=https://... - - KMS_CONTRACT_ADDR=0x... - - ETH_CHAIN_ID=8453 -``` - -to build it locally: - -```bash -docker build -t dstack-kms-auth-eth-bun . -``` - -### Reproducing a release image - -Use a clean checkout of the release tag and BuildKit 0.20.2, matching the -workflow. COPY permissions are fixed independently of the checkout umask. -Export with the commit timestamp and timestamp rewriting; a plain -`docker build` does not reproduce the release digest. - -```bash -docker buildx create --name auth-repro --driver docker-container \ - --driver-opt image=moby/buildkit:v0.20.2@sha256:c457984bd29f04d6acc90c8d9e717afe3922ae14665f3187e0096976fe37b1c8 - -git_revision=$(git rev-parse HEAD) -source_date_epoch=$(git show -s --format=%ct HEAD) -docker buildx build --builder auth-repro --platform linux/amd64 \ - --provenance=false \ - --build-arg "GIT_REV=$git_revision" \ - --build-arg "SOURCE_DATE_EPOCH=$source_date_epoch" \ - --label org.opencontainers.image.source=https://github.com/Dstack-TEE/dstack \ - --label "org.opencontainers.image.revision=$git_revision" \ - --label 'org.opencontainers.image.description=dstack KMS auth webhook backed by the DstackKms contract' \ - --tag ghcr.io/dstack-tee/dstack-kms-auth-eth-bun:VERSION \ - --output type=oci,rewrite-timestamp=true,dest=/tmp/auth-eth-bun.oci.tar . -``` - -Replace `VERSION` with the release version. These settings apply to releases -that include timestamp normalization; earlier tags used a different workflow. - -## API endpoints - -### GET / -health check and system information - -### POST /bootAuth/app -application boot authentication - -### POST /bootAuth/kms -KMS boot authentication - -## request format - -```json -{ - "tcbStatus": "string", - "advisoryIds": ["string"], - "mrAggregated": "string", - "mrSystem": "string", - "osImageHash": "string", - "appId": "string", - "composeHash": "string", - "instanceId": "string", - "deviceId": "string" -} -``` - -## response format - -```json -{ - "isAllowed": boolean, - "reason": "string", - "gatewayAppId": "string" -} -``` - -## API compatibility - -this implementation is fully compatible with the original fastify + ethers version: - -- **request/response schemas**: identical to original API -- **OpenAPI specification**: available in `openapi.json` -- **comprehensive testing**: vitest test suite validates compatibility -- **backward compatibility**: supports both minimal and full BootInfo formats - -### dstack-kms 0.6 - -dstack-kms 0.6 posts `dstack_verifier::PolicyBootInfo`: bytes as unprefixed -hex, plus `teeVariant` and `keyProviderInfo`, which the contract does not take -and this backend drops. the KMS checks `appId`, `composeHash` and `instanceId` -widths itself before calling the webhook. the `dstack-kms 0.6 bootAuth payload` -tests pin this contract. - -### OpenAPI specification - -the complete API specification is available in `openapi.json` and includes: -- detailed schema definitions -- request/response examples -- compatibility notes - -### compatibility testing - -the test suite (`index.test.ts`) validates: -- ✅ request/response format compatibility -- ✅ schema validation using OpenAPI spec -- ✅ error handling behavior -- ✅ hex encoding/decoding compatibility -- ✅ optional field handling diff --git a/dstack/kms/auth-eth-bun/index.ts b/dstack/kms/auth-eth-bun/index.ts deleted file mode 100644 index b192d9f0f..000000000 --- a/dstack/kms/auth-eth-bun/index.ts +++ /dev/null @@ -1,358 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { Hono } from 'hono'; -import { bodyLimit } from 'hono/body-limit'; -import { secureHeaders } from 'hono/secure-headers'; -import { zValidator } from '@hono/zod-validator'; -import { z } from 'zod'; -import { createPublicClient, http, type Address, type Hex } from 'viem'; - -// zod schemas for validation - compatible with original fastify implementation -const boundedHex = (bytes: number, description: string) => - z.string() - .regex(/^(?:0x)?[0-9a-fA-F]*$/, `${description} must be hexadecimal`) - .refine( - (value) => value.replace(/^0x/, '').length <= bytes * 2, - `${description} exceeds ${bytes} bytes`, - ); - -const BootInfoSchema = z.object({ - // Short hexadecimal values remain compatible with the original backend, - // which left-pads them before making the contract call. - mrAggregated: boundedHex(32, 'aggregated MR measurement'), - osImageHash: boundedHex(32, 'OS Image hash'), - appId: boundedHex(20, 'application ID'), - composeHash: boundedHex(32, 'compose hash'), - instanceId: boundedHex(20, 'instance ID'), - deviceId: boundedHex(32, 'device ID'), - tcbStatus: z.string().max(128).optional().default(''), - advisoryIds: z.array(z.string().max(256)).max(128).optional().default([]), - mrSystem: boundedHex(32, 'system MR measurement').optional().default('') -}); - -const BootResponseSchema = z.object({ - isAllowed: z.boolean(), - reason: z.string(), - gatewayAppId: z.string() -}); - -type BootInfo = z.infer; -type BootResponse = z.infer; - -// DstackKms contract ABI (minimal required functions) -const DSTACK_KMS_ABI = [ - { - name: 'isKmsAllowed', - type: 'function', - stateMutability: 'view', - inputs: [ - { - name: 'bootInfo', - type: 'tuple', - components: [ - { name: 'appId', type: 'address' }, - { name: 'composeHash', type: 'bytes32' }, - { name: 'instanceId', type: 'address' }, - { name: 'deviceId', type: 'bytes32' }, - { name: 'mrAggregated', type: 'bytes32' }, - { name: 'mrSystem', type: 'bytes32' }, - { name: 'osImageHash', type: 'bytes32' }, - { name: 'tcbStatus', type: 'string' }, - { name: 'advisoryIds', type: 'string[]' } - ] - } - ], - outputs: [ - { name: 'isAllowed', type: 'bool' }, - { name: 'reason', type: 'string' } - ] - }, - { - name: 'isAppAllowed', - type: 'function', - stateMutability: 'view', - inputs: [ - { - name: 'bootInfo', - type: 'tuple', - components: [ - { name: 'appId', type: 'address' }, - { name: 'composeHash', type: 'bytes32' }, - { name: 'instanceId', type: 'address' }, - { name: 'deviceId', type: 'bytes32' }, - { name: 'mrAggregated', type: 'bytes32' }, - { name: 'mrSystem', type: 'bytes32' }, - { name: 'osImageHash', type: 'bytes32' }, - { name: 'tcbStatus', type: 'string' }, - { name: 'advisoryIds', type: 'string[]' } - ] - } - ], - outputs: [ - { name: 'isAllowed', type: 'bool' }, - { name: 'reason', type: 'string' } - ] - }, - { - name: 'gatewayAppId', - type: 'function', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'string' }] - }, - { - name: 'appImplementation', - type: 'function', - stateMutability: 'view', - inputs: [], - outputs: [{ name: '', type: 'address' }] - } -] as const; - -// ethereum backend class -class EthereumBackend { - private client: ReturnType; - private kmsContractAddr: Address; - private expectedChainId?: number; - private finalityConfirmations: bigint; - - constructor( - client: ReturnType, - kmsContractAddr: string, - expectedChainId: number | undefined, - finalityConfirmations: bigint, - ) { - this.client = client; - this.kmsContractAddr = kmsContractAddr as Address; - this.expectedChainId = expectedChainId; - this.finalityConfirmations = finalityConfirmations; - } - - private async finalizedBlockNumber(): Promise { - const chainId = await this.client.getChainId(); - if (this.expectedChainId !== undefined && chainId !== this.expectedChainId) { - throw new Error('authorization backend chain ID mismatch'); - } - const head = await this.client.getBlockNumber(); - if (head < this.finalityConfirmations) { - throw new Error('authorization backend has not reached configured finality'); - } - return head - this.finalityConfirmations; - } - - private decodeHex(hex: string, sz: number = 32): Hex { - // remove '0x' prefix if present - hex = hex.startsWith('0x') ? hex.slice(2) : hex; - // pad hex string to specified size - hex = hex.padStart(sz * 2, '0'); - // add '0x' prefix back - return `0x${hex}` as Hex; - } - - async checkBoot(bootInfo: BootInfo, isKms: boolean): Promise { - // create boot info struct for contract call - const bootInfoStruct = { - appId: this.decodeHex(bootInfo.appId, 20) as Address, - composeHash: this.decodeHex(bootInfo.composeHash, 32), - instanceId: this.decodeHex(bootInfo.instanceId, 20) as Address, - deviceId: this.decodeHex(bootInfo.deviceId, 32), - mrAggregated: this.decodeHex(bootInfo.mrAggregated, 32), - mrSystem: this.decodeHex(bootInfo.mrSystem || '', 32), - osImageHash: this.decodeHex(bootInfo.osImageHash, 32), - tcbStatus: bootInfo.tcbStatus || '', - advisoryIds: bootInfo.advisoryIds || [] - }; - - const blockNumber = await this.finalizedBlockNumber(); - let response; - if (isKms) { - response = await this.client.readContract({ - address: this.kmsContractAddr, - abi: DSTACK_KMS_ABI, - functionName: 'isKmsAllowed', - args: [bootInfoStruct], - blockNumber - }); - } else { - response = await this.client.readContract({ - address: this.kmsContractAddr, - abi: DSTACK_KMS_ABI, - functionName: 'isAppAllowed', - args: [bootInfoStruct], - blockNumber - }); - } - - const [isAllowed, reason] = response; - const gatewayAppId = await this.client.readContract({ - address: this.kmsContractAddr, - abi: DSTACK_KMS_ABI, - functionName: 'gatewayAppId', - blockNumber - }); - - return { - isAllowed, - reason, - gatewayAppId: gatewayAppId as string, - }; - } - - async getGatewayAppId(): Promise { - const result = await this.client.readContract({ - address: this.kmsContractAddr, - abi: DSTACK_KMS_ABI, - functionName: 'gatewayAppId' - }); - return result as string; - } - - async getChainId(): Promise { - const chainId = await this.client.getChainId(); - return Number(chainId); - } - - async getAppImplementation(): Promise { - const result = await this.client.readContract({ - address: this.kmsContractAddr, - abi: DSTACK_KMS_ABI, - functionName: 'appImplementation' - }); - return result as string; - } -} - -// initialize app -const app = new Hono(); - -// initialize ethereum backend -const rpcUrl = process.env.ETH_RPC_URL || 'http://localhost:8545'; -const kmsContractAddr = process.env.KMS_CONTRACT_ADDR || '0x0000000000000000000000000000000000000000'; -const parseNonNegativeInteger = (name: string, value: string | undefined): number | undefined => { - if (value === undefined || value === '') return undefined; - if (!/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error(`${name} must be a non-negative integer`); - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) throw new Error(`${name} exceeds the safe integer range`); - return parsed; -}; -const expectedChainId = parseNonNegativeInteger('ETH_CHAIN_ID', process.env.ETH_CHAIN_ID); -const finalityConfirmations = BigInt( - parseNonNegativeInteger('ETH_FINALITY_CONFIRMATIONS', process.env.ETH_FINALITY_CONFIRMATIONS) ?? 0, -); -const client = createPublicClient({ - transport: http(rpcUrl) -}); -const ethereum = new EthereumBackend(client, kmsContractAddr, expectedChainId, finalityConfirmations); - -const publicRpcEndpoint = (value: string): string => { - try { - const endpoint = new URL(value); - return `${endpoint.protocol}//${endpoint.host}`; - } catch { - return 'configured'; - } -}; - -const backendUnavailable = 'authorization backend unavailable'; -const invalidRequest = { isAllowed: false, reason: 'invalid authorization request', gatewayAppId: '' }; - -// The schema caps a valid BootInfo well under this (advisoryIds alone at -// 128 x 256 chars is 32 KiB); the limit stops an oversized body from being -// buffered and parsed before validation rejects it. -const MAX_BOOT_INFO_BYTES = 128 * 1024; - -// Every response is JSON for the KMS, never a page; the headers keep a browser -// that reaches this port from sniffing or framing one. -app.use('*', secureHeaders()); -app.use('/bootAuth/*', bodyLimit({ - maxSize: MAX_BOOT_INFO_BYTES, - onError: (c) => c.json(invalidRequest, 413), -})); - -// health check and info endpoint -app.get('/', async (c) => { - try { - const batch = await Promise.all([ - ethereum.getGatewayAppId(), - ethereum.getChainId(), - ethereum.getAppImplementation(), - ]); - console.log('batch', batch); - - return c.json({ - status: 'ok', - kmsContractAddr: kmsContractAddr, - ethRpcUrl: publicRpcEndpoint(rpcUrl), - gatewayAppId: batch[0], - chainId: batch[1], - appAuthImplementation: batch[2], // NOTE: for backward compatibility - appImplementation: batch[2], - }); - } catch { - console.error('authorization backend health check failed'); - return c.json({ - status: 'error', - message: backendUnavailable - }, 500); - } -}); - -// app boot authentication -app.post('/bootAuth/app', - zValidator('json', BootInfoSchema, (result, c) => { - if (!result.success) return c.json(invalidRequest, 400); - }), - async (c) => { - try { - const bootInfo = c.req.valid('json'); - const result = await ethereum.checkBoot(bootInfo, false); - return c.json(result); - } catch { - console.error('application authorization backend failed'); - return c.json({ - isAllowed: false, - gatewayAppId: '', - reason: backendUnavailable - }); - } - } -); - -// KMS boot authentication -app.post('/bootAuth/kms', - zValidator('json', BootInfoSchema, (result, c) => { - if (!result.success) return c.json(invalidRequest, 400); - }), - async (c) => { - try { - const bootInfo = c.req.valid('json'); - const result = await ethereum.checkBoot(bootInfo, true); - return c.json(result); - } catch (error) { - // don't log test backend errors - if (!(error instanceof Error && "Test backend error" === error.message)) { - console.error('KMS authorization backend failed'); - } - return c.json({ - isAllowed: false, - gatewayAppId: '', - reason: backendUnavailable - }); - } - } -); - -// start server -const port = parseInt(process.env.PORT || '3000'); -console.log(`starting server on port ${port}`); - -// Bun defaults idleTimeout to 10s and closes the socket while the handler is -// still awaiting eth_call. KMS's webhook timeout is 60s; stay above that so the -// client times out first. Bun's documented maximum is 255. -export default { - port, - idleTimeout: 255, - fetch: app.fetch, -}; diff --git a/dstack/kms/auth-eth-bun/openapi.json b/dstack/kms/auth-eth-bun/openapi.json deleted file mode 100644 index e87c03611..000000000 --- a/dstack/kms/auth-eth-bun/openapi.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "dstack KMS Ethereum Backend API", - "description": "API for dstack KMS Ethereum backend authentication and system information", - "version": "1.0.0" - }, - "servers": [ - { - "url": "http://localhost:3000", - "description": "Development server" - } - ], - "paths": { - "/": { - "get": { - "summary": "Health check and system information", - "description": "Returns system status and configuration information", - "operationId": "getSystemInfo", - "responses": { - "200": { - "description": "System information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SystemInfo" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/bootAuth/app": { - "post": { - "summary": "Application boot authentication", - "description": "Validates application boot information against smart contract", - "operationId": "authenticateAppBoot", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootInfo" - } - } - } - }, - "responses": { - "200": { - "description": "Authentication result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootResponse" - } - } - } - } - } - } - }, - "/bootAuth/kms": { - "post": { - "summary": "KMS boot authentication", - "description": "Validates KMS boot information against smart contract", - "operationId": "authenticateKmsBoot", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootInfo" - } - } - } - }, - "responses": { - "200": { - "description": "Authentication result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootResponse" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "BootInfo": { - "type": "object", - "required": [ - "mrAggregated", - "osImageHash", - "appId", - "composeHash", - "instanceId", - "deviceId" - ], - "properties": { - "mrAggregated": { - "type": "string", - "description": "Aggregated MR measurement" - }, - "osImageHash": { - "type": "string", - "description": "OS Image hash" - }, - "appId": { - "type": "string", - "description": "Application ID" - }, - "composeHash": { - "type": "string", - "description": "Compose hash" - }, - "instanceId": { - "type": "string", - "description": "Instance ID" - }, - "deviceId": { - "type": "string", - "description": "Device ID" - }, - "tcbStatus": { - "type": "string", - "description": "TCB status (optional for compatibility)" - }, - "advisoryIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Advisory IDs (optional for compatibility)" - }, - "mrSystem": { - "type": "string", - "description": "MR System measurement (optional for compatibility)" - } - } - }, - "BootResponse": { - "type": "object", - "required": [ - "isAllowed", - "reason", - "gatewayAppId" - ], - "properties": { - "isAllowed": { - "type": "boolean", - "description": "Whether the boot is allowed" - }, - "reason": { - "type": "string", - "description": "Reason for the decision" - }, - "gatewayAppId": { - "type": "string", - "description": "Gateway application ID" - } - } - }, - "SystemInfo": { - "type": "object", - "required": [ - "status", - "kmsContractAddr", - "ethRpcUrl", - "gatewayAppId", - "chainId", - "appAuthImplementation", - "appImplementation" - ], - "properties": { - "status": { - "type": "string", - "enum": ["ok"], - "description": "System status" - }, - "kmsContractAddr": { - "type": "string", - "description": "KMS contract address" - }, - "ethRpcUrl": { - "type": "string", - "description": "Ethereum RPC URL" - }, - "gatewayAppId": { - "type": "string", - "description": "Gateway application ID" - }, - "chainId": { - "type": "number", - "description": "Blockchain chain ID" - }, - "appAuthImplementation": { - "type": "string", - "description": "App auth implementation address (deprecated, use appImplementation)" - }, - "appImplementation": { - "type": "string", - "description": "App implementation address" - } - } - }, - "Error": { - "type": "object", - "required": [ - "status", - "message" - ], - "properties": { - "status": { - "type": "string", - "enum": ["error"] - }, - "message": { - "type": "string", - "description": "Error message" - } - } - } - } - } -} diff --git a/dstack/kms/auth-eth-bun/package.json b/dstack/kms/auth-eth-bun/package.json deleted file mode 100644 index 69b34d6fe..000000000 --- a/dstack/kms/auth-eth-bun/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "auth-eth-bun", - "version": "1.0.0", - "description": "dstack KMS ethereum backend with bun + hono + zod", - "main": "index.ts", - "scripts": { - "dev": "bun run --watch index.ts", - "start": "bun run index.ts", - "build": "bun build index.ts --outdir ./dist --target bun", - "test": "vitest", - "test:run": "vitest run", - "lint": "oxlint .", - "lint:fix": "oxlint --fix .", - "fmt": "oxlint --fix .", - "check": "bun run lint && bun run test:run" - }, - "dependencies": { - "@hono/zod-validator": "0.9.1", - "hono": "4.13.13", - "viem": "2.57.5", - "zod": "4.6.7" - }, - "devDependencies": { - "@types/bun": "1.4.2", - "@types/node": "24.19.2", - "oxlint": "1.88.0", - "typescript": "5.9.3", - "vite": "8.3.3", - "vitest": "5.0.4" - }, - "type": "module", - "overrides": { - "source-map-js": "1.2.2" - } -} diff --git a/dstack/kms/auth-eth-bun/vitest.config.ts b/dstack/kms/auth-eth-bun/vitest.config.ts deleted file mode 100644 index 17597daca..000000000 --- a/dstack/kms/auth-eth-bun/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - }, -}); diff --git a/dstack/kms/auth-eth-bun/.dockerignore b/dstack/kms/auth-eth/.dockerignore similarity index 50% rename from dstack/kms/auth-eth-bun/.dockerignore rename to dstack/kms/auth-eth/.dockerignore index ee1f9e2c2..a2451cfe0 100644 --- a/dstack/kms/auth-eth-bun/.dockerignore +++ b/dstack/kms/auth-eth/.dockerignore @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: Apache-2.0 +# This directory also holds the Foundry project (contracts/, lib/, test/, +# script/, ...). The image only needs the server source and its lockfile. * !package.json !bun.lock diff --git a/dstack/kms/auth-eth/.env.example b/dstack/kms/auth-eth/.env.example deleted file mode 100644 index a1e967f46..000000000 --- a/dstack/kms/auth-eth/.env.example +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -# Example environment configuration for local testing - -# Server configuration -PORT=8000 -HOST=127.0.0.1 - -# Ethereum configuration -ETH_RPC_URL=http://127.0.0.1:8545 -KMS_CONTRACT_ADDR=0x0000000000000000000000000000000000000000 - -# For testing with local Anvil node (Foundry): -# ETH_RPC_URL=http://127.0.0.1:8545 -# KMS_CONTRACT_ADDR= - -# For testing with testnet: -# ETH_RPC_URL=https://rpc.sepolia.org -# KMS_CONTRACT_ADDR= diff --git a/dstack/kms/auth-eth/.gitignore b/dstack/kms/auth-eth/.gitignore index 0993643fe..10450c1b1 100644 --- a/dstack/kms/auth-eth/.gitignore +++ b/dstack/kms/auth-eth/.gitignore @@ -1,13 +1,10 @@ -/artifacts +# Foundry /broadcast /cache -/coverage -/dist /out -# Test logs -anvil-test.log -anvil.log -deploy.log -server-test.log -.env.test +# Bun server +/node_modules +/dist +/coverage +.env diff --git a/dstack/kms/auth-eth-bun/.oxlintrc.json b/dstack/kms/auth-eth/.oxlintrc.json similarity index 82% rename from dstack/kms/auth-eth-bun/.oxlintrc.json rename to dstack/kms/auth-eth/.oxlintrc.json index 640a8f158..2dcc5d26d 100644 --- a/dstack/kms/auth-eth-bun/.oxlintrc.json +++ b/dstack/kms/auth-eth/.oxlintrc.json @@ -7,6 +7,10 @@ }, "ignorePatterns": [ "node_modules/**", + "lib/**", + "out/**", + "cache/**", + "broadcast/**", "dist/**", "build/**", "coverage/**", diff --git a/dstack/kms/auth-eth-bun/Dockerfile b/dstack/kms/auth-eth/Dockerfile similarity index 100% rename from dstack/kms/auth-eth-bun/Dockerfile rename to dstack/kms/auth-eth/Dockerfile diff --git a/dstack/kms/auth-eth/README.md b/dstack/kms/auth-eth/README.md index 3d3b88f36..5a905ceb2 100644 --- a/dstack/kms/auth-eth/README.md +++ b/dstack/kms/auth-eth/README.md @@ -1,66 +1,294 @@ -# dstack KMS Auth-ETH +# dstack KMS auth-eth -A Foundry-based smart contract project for dstack's Key Management System (KMS) authentication on Ethereum. +On-chain authorization for the dstack KMS. This directory holds both halves: -## Overview +- **Contracts** (`contracts/`, `test/`, `script/`): the upgradeable `DstackKms` + and `DstackApp` contracts, built and tested with Foundry. + - **DstackKms**: Key Management System contract with app registration and validation + - **DstackApp**: Application-specific authentication contract with device and compose hash management +- **Webhook server** (`index.ts`): a single-file Bun + Hono + zod + viem server + that reads those contracts and serves the KMS auth webhook (`/bootAuth/*`, + `/authorizeKms`, `/authorizeApp`). -This project contains upgradeable smart contracts for: -- **DstackKms**: Key Management System contract with app registration and validation -- **DstackApp**: Application-specific authentication contract with device and compose hash management +## Upgrade order -## Prerequisites +Upgrade the `DstackKms` contract before deploying the new auth backend or KMS. +An empty root epoch registry is supported on the upgraded contract; there is no +fallback for legacy contracts. Old KMS instances that omit the root epoch can +receive app authorization only while no epoch is registered or epoch 0 is current, +subject to the existing authorization policy. -- [Foundry](https://book.getfoundry.sh/getting-started/installation) - For smart contract development and testing -- Node.js - For the bootAuth server and TypeScript development +## Webhook server + +### Prerequisites -## Setup +- [Bun](https://bun.sh) + +### Development -1. Install dependencies: ```bash -# Install Foundry dependencies -forge install +bun install --frozen-lockfile + +# run with hot reload +bun run dev + +# run once +bun run start + +# tests (watch mode / once) +bun run test +bun run test:run + +# lint, lint with fixes, lint + tests +bun run lint +bun run lint:fix +bun run check +``` -# Install Node.js dependencies for server -npm install +The Bun server sets `idleTimeout` to 255s (Bun's maximum). Bun's default of 10s closes the socket while a slow `eth_call` is still in flight, which surfaces on the KMS side as `connection closed before message completed`. + +### Environment variables + +- `ETH_RPC_URL` - ethereum RPC endpoint (default: http://localhost:8545) +- `KMS_CONTRACT_ADDR` - KMS contract address (default: 0x0000000000000000000000000000000000000000) +- `PORT` - server port (default: 3000) +- `ETH_CHAIN_ID` - reject every decision if the RPC reports a different chain ID (default: unset, no check) +- `ETH_BLOCK_TAG` - block policy is read at: `latest`, `safe` or `finalized` (default: `finalized`). each request resolves the tag to one block and reads all state there. the server resolves the tag once at startup and refuses to start if the RPC cannot serve it. a later tag delays every policy change, including emergency revocations, until that block is reached (about 13 minutes for `finalized` on Ethereum mainnet). use `finalized` on L1. set `latest` explicitly for an OP-stack chain read through the Helios light client, which tracks only the sequencer-signed unsafe head and serves neither `safe` nor `finalized`, and for local development chains such as Anvil, which finalizes 64 blocks behind its head. see [reorgs and the block tag](#reorgs-and-the-block-tag) before using `latest` or `safe` +- `ETH_FINALITY_CONFIRMATIONS` - read this many blocks further behind the block `ETH_BLOCK_TAG` resolves to (default: 0) + +### Reorgs and the block tag + +Only `finalized` protects policy from reorgs. With `latest` or `safe`, a reorg +can revert any policy transaction the server has already acted on, in both +directions: + +- **Revocations roll back.** A removed OS image, compose hash or device, or a + revoked KMS measurement, is authorized again until the revocation is + re-included and the read block reaches it. +- **Activations roll back.** A root epoch activation or an app epoch increase + can disappear. Neither this server nor the KMS keeps a high-water mark: the + KMS only checks the reported epoch against its keyring's chain hash, so after + a root epoch rollback it restarts once and serves the previous epoch as + current, and apps again receive keys of their previous app epoch. + +Keys released in that window stay released. `ETH_FINALITY_CONFIRMATIONS=N` +reads N blocks behind the tagged block, so a reorg shallower than N blocks is +never observed, at the cost of delaying every policy change, revocations +included, by N more blocks. A reorg deeper than N still rolls back, so this +narrows the risk with `latest` or `safe` but does not remove it. Through Helios +on an OP-stack chain, `latest` is the sequencer's unsafe head: choose N from +how deep a reorg of that head you are willing to accept, and send emergency +revocations with that delay in mind. + +### Container image + +Tagged releases are published by the `KMS auth-eth` workflow +(`.github/workflows/kms-auth-eth.yml`): pushing the tag +`kms-auth-eth-v` builds this directory and publishes +`ghcr.io/dstack-tee/dstack-kms-auth-eth:` with a build provenance +attestation. Pull requests that touch this directory run the audit, lint and +tests and build the image without pushing it. + +The image listens on port 8000, which matches the KMS webhook default +`http://auth-api:8000`. Deploy it by digest: + +```yaml +auth-api: + image: ghcr.io/dstack-tee/dstack-kms-auth-eth:@sha256: + environment: + - ETH_RPC_URL=https://... + - KMS_CONTRACT_ADDR=0x... + - ETH_CHAIN_ID=8453 ``` -2. Build contracts and server: +To build it locally (the `.dockerignore` keeps the Foundry +project out of the build context): + ```bash -# Build smart contracts -forge build +docker build -t dstack-kms-auth-eth . +``` + +#### Reproducing a release image -# Build TypeScript server -npm run build +Use a clean checkout of the release tag and BuildKit 0.20.2, matching the +workflow. COPY permissions are fixed independently of the checkout umask. +Export with the commit timestamp and timestamp rewriting; a plain +`docker build` does not reproduce the release digest. + +```bash +docker buildx create --name auth-repro --driver docker-container \ + --driver-opt image=moby/buildkit:v0.20.2@sha256:c457984bd29f04d6acc90c8d9e717afe3922ae14665f3187e0096976fe37b1c8 + +git_revision=$(git rev-parse HEAD) +source_date_epoch=$(git show -s --format=%ct HEAD) +docker buildx build --builder auth-repro --platform linux/amd64 \ + --provenance=false \ + --build-arg "GIT_REV=$git_revision" \ + --build-arg "SOURCE_DATE_EPOCH=$source_date_epoch" \ + --label org.opencontainers.image.source=https://github.com/Dstack-TEE/dstack \ + --label "org.opencontainers.image.revision=$git_revision" \ + --label 'org.opencontainers.image.description=dstack KMS auth webhook backed by the DstackKms contract' \ + --tag ghcr.io/dstack-tee/dstack-kms-auth-eth:VERSION \ + --output type=oci,rewrite-timestamp=true,dest=/tmp/auth-eth.oci.tar . ``` -## Testing +Replace `VERSION` with the release version. These settings apply to releases +that include timestamp normalization; earlier tags used a different workflow. + +Releases before the rename were published as +`ghcr.io/dstack-tee/dstack-kms-auth-eth-bun` from `kms-auth-eth-bun-v*` tags; +those images remain available. + +### API endpoints + +#### GET / +health check and system information + +#### POST /bootAuth/app +application boot authentication + +#### POST /bootAuth/kms +KMS boot authentication + +### Request format + +```json +{ + "tcbStatus": "string", + "advisoryIds": ["string"], + "mrAggregated": "string", + "mrSystem": "string", + "osImageHash": "string", + "appId": "string", + "composeHash": "string", + "instanceId": "string", + "deviceId": "string" +} +``` + +### Response format + +```json +{ + "isAllowed": boolean, + "reason": "string", + "gatewayAppId": "string" +} +``` + +### API compatibility + +this implementation keeps the API of the removed fastify + ethers server that used to live in this directory: + +- **request/response schemas**: identical to original API +- **OpenAPI specification**: available in `openapi.json` +- **comprehensive testing**: vitest test suite validates compatibility +- **backward compatibility**: supports both minimal and full BootInfo formats + +#### dstack-kms 0.6 + +dstack-kms 0.6 posts `dstack_verifier::PolicyBootInfo`: bytes as unprefixed +hex, plus `teeVariant` and `keyProviderInfo`, which the contract does not take +and this backend drops. the KMS checks `appId`, `composeHash` and `instanceId` +widths itself before calling the webhook. the `dstack-kms 0.6 bootAuth payload` +tests pin this contract. + +#### OpenAPI specification + +the complete API specification is available in `openapi.json` and includes: +- detailed schema definitions +- request/response examples +- compatibility notes + +#### compatibility testing + +The test suite (`index.test.ts`) validates: +- ✅ request/response format compatibility +- ✅ schema validation using OpenAPI spec +- ✅ error handling behavior +- ✅ hex encoding/decoding compatibility +- ✅ optional field handling + +#### Authorization endpoints + +`POST /authorizeKms` and `POST /authorizeApp` return `AuthorizationResult`, including `authorized`, +`rejectionReason`, `gatewayAppId`, `currentRootEpoch`, `currentRootEpochHash`, `currentAppEpoch`, +`latestRootEpoch`, and `latestRootEpochHash` (the latest registered epoch, which may be pending activation). +The selected endpoint determines the policy; the backend checks that the verified +`appId` matches that scope. Optional `requestedAppEpoch` selects the app epoch; +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 +[KMS authorization protocol](../README.md#authorization-protocol) for registration, +bootstrap, and compatibility requirements. The legacy `/bootAuth/*` endpoints +remain available. + +#### Authorization operations + +`/authorizeKms` and `/authorizeApp` require an `operation` string. See the +[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. + +## Contracts + +### Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) - For smart contract development and testing +- Node.js/npx - Only for `@openzeppelin/upgrades-core`, which the upgrade-safety tests run through `npx` with `--ffi` + +### Setup -### Smart contract tests (Foundry) ```bash -forge test --ffi +# Fetch the Foundry dependencies (git submodules under lib/) +git submodule update --init --recursive lib/forge-std \ + lib/openzeppelin-contracts-upgradeable lib/openzeppelin-foundry-upgrades + +# Fetch the OpenZeppelin upgrade-safety validator that `forge test --ffi` runs +# through npx. Do not `npm install` it here: package.json belongs to the Bun +# webhook server. +npx --yes @openzeppelin/upgrades-core@^1.37.0 --help > /dev/null + +# Build the contracts +forge build ``` -### BootAuth server tests (Jest) +### Testing + ```bash -npm test +# Run `forge clean` first: stale build-info files make the upgrade validator +# report the same contract twice. +forge clean +forge test --ffi ``` -### Local integration testing +### Local chain + +To try the contracts together with the webhook server, deploy them to a local +Anvil node and point the webhook server at the proxy: + ```bash -# Quick test workflow — spins up Anvil, deploys, runs Jest + Foundry against the live chain -npm run test:all +# Terminal 1: local node +anvil -# Step-by-step workflow -npm run test:setup # Start Anvil and deploy contracts -npm run test:run # Run tests against deployed contracts -npm run test:cleanup # Stop all test processes +# Terminal 2: deploy with Anvil's first prefunded account; the output prints +# "DstackKms proxy deployed to: 0x..." +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url http://127.0.0.1:8545 + +# Terminal 3: webhook server. Anvil finalizes 64 blocks behind its head, so read +# at the latest block. +bun install +ETH_RPC_URL=http://127.0.0.1:8545 ETH_BLOCK_TAG=latest KMS_CONTRACT_ADDR=0x... PORT=8000 \ + bun index.ts ``` -## Contract Management +### Contract Management Use Foundry scripts for all contract operations instead of Cast commands: -### Deployment +#### Deployment ```bash # Deploy both contracts forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url http://localhost:8545 @@ -69,7 +297,7 @@ forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url http://local forge script script/Deploy.s.sol:DeployScript --broadcast --rpc-url --private-key ``` -### Management Operations +#### Management Operations ```bash # Add KMS aggregated MR KMS_CONTRACT_ADDR=0x... MR_AGGREGATED=0x1234... \ @@ -80,7 +308,7 @@ KMS_CONTRACT_ADDR=0x... APP_OWNER=0x... \ forge script script/Manage.s.sol:DeployApp --broadcast --rpc-url $RPC_URL ``` -### Query Operations +#### Query Operations ```bash # Get KMS settings KMS_CONTRACT_ADDR=0x... \ @@ -91,7 +319,7 @@ APP_CONTRACT_ADDR=0x... DEVICE_ID=0x1234... \ forge script script/Query.s.sol:CheckAppDevice --rpc-url $RPC_URL ``` -### Safe Upgrades +#### Safe Upgrades ```bash # Upgrade KMS to V2 KMS_CONTRACT_ADDR=0x... \ @@ -100,55 +328,45 @@ forge script script/Upgrade.s.sol:UpgradeKmsToV2 --broadcast --rpc-url $RPC_URL See `script/README.md` for complete documentation of all available scripts. -## BootAuth Server +#### Epoch-scoped material approvals -The project includes a Fastify-based HTTP server for TEE boot validation: +Material approvals use exact root/app epoch rows; neither older nor newer rows +are implicitly authorized. Material setters name their rows: `*AtEpoch` one +row, `*AtEpochs` a range; `DstackApp` reports them as `IAppEpochManagement`. +The management scripts take `ROOT_EPOCH`/`APP_EPOCH`, or +`FROM_ROOT_EPOCH`/`TO_ROOT_EPOCH` and `FROM_APP_EPOCH`/`TO_APP_EPOCH`, and use +the epoch-less setters of app policies predating app epochs. 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. -### Endpoints -- **`GET /`** - Health check and contract information -- **`POST /bootAuth/app`** - Validate application boot information -- **`POST /bootAuth/kms`** - Validate KMS boot information +### Static Analysis & Symbolic Verification -### Configuration -Set these environment variables: -- **`ETH_RPC_URL`** - Ethereum RPC endpoint (default: `http://localhost:8545`) -- **`KMS_CONTRACT_ADDR`** - Deployed DstackKms contract address -- **`PORT`** - Server port (default: `8000`) -- **`HOST`** - Server host (default: `127.0.0.1`) - -### Running the Server -```bash -# Development mode -npm run dev - -# Production mode -npm run build && npm start - -# Test the server -npm test -``` - -## Static Analysis & Symbolic Verification (local only) - -We run [Slither](https://github.com/crytic/slither) and -[Halmos](https://github.com/a16z/halmos) locally during development. They -are intentionally not part of CI — symbolic proofs are slow, sensitive to -solver versions, and most valuable as an interactive design check rather -than a gate. +We run [Slither](https://github.com/crytic/slither) locally during +development. [Halmos](https://github.com/a16z/halmos) also runs in CI +(`.github/workflows/halmos.yml`, pinned Halmos and Forge versions) whenever +this directory changes. ```bash pipx install slither-analyzer halmos slither . halmos --contract DstackAppSymbolicTest +halmos --contract DstackAppEpochSymbolicTest halmos --contract DstackKmsSymbolicTest +halmos --contract DstackKmsEpochSymbolicTest ``` +Forge 1.8 enables `dynamic_test_linking` by default, which compiles `new` in +tests to `vm.deployCode`, a cheat code Halmos 0.3.3 does not support. Disable +it for Halmos runs with `FOUNDRY_DYNAMIC_TEST_LINKING=false`. + Configuration lives in `slither.config.json` and `foundry.toml`. See `docs/formal-verification.md` for the verification roadmap, what each symbolic property proves, and the deeper-verification plan for a future audit-firm engagement. -## Additional Commands +### Additional Commands ```bash # Format code diff --git a/dstack/kms/auth-eth/TESTING.md b/dstack/kms/auth-eth/TESTING.md deleted file mode 100644 index 40398cef5..000000000 --- a/dstack/kms/auth-eth/TESTING.md +++ /dev/null @@ -1,47 +0,0 @@ -# Testing Guide - -## Smart Contract Testing - -```bash -forge test --ffi -``` - -## API Server Testing - -### Unit Tests -```bash -npm test # Jest, mocked Ethereum backend -``` - -### Integration Tests -```bash -npm run test:all # Complete: Anvil + Deploy + API tests + Cleanup -``` - -This automatically: -1. Starts Anvil node -2. Deploys contracts -3. Starts API server -4. Tests all endpoints -5. Cleans up - -## Manual Testing - -### Start Services -```bash -npm run test:setup # Start Anvil and deploy contracts -npm run dev # Start API server in development mode -``` - -### Test Endpoints -```bash -curl http://127.0.0.1:8000/ # Health check -curl -X POST http://127.0.0.1:8000/bootAuth/app \ - -H "Content-Type: application/json" \ - -d '{"tcbStatus":"UpToDate","advisoryIds":[],"mrAggregated":"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","osImageHash":"0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"}' -``` - -### Cleanup -```bash -npm run test:cleanup # Stop all test processes -``` diff --git a/dstack/kms/auth-eth-bun/bun.lock b/dstack/kms/auth-eth/bun.lock similarity index 99% rename from dstack/kms/auth-eth-bun/bun.lock rename to dstack/kms/auth-eth/bun.lock index 005a33ea2..7dc0e14a4 100644 --- a/dstack/kms/auth-eth-bun/bun.lock +++ b/dstack/kms/auth-eth/bun.lock @@ -3,7 +3,7 @@ "configVersion": 0, "workspaces": { "": { - "name": "auth-eth-bun", + "name": "auth-eth", "dependencies": { "@hono/zod-validator": "0.9.1", "hono": "4.13.13", diff --git a/dstack/kms/auth-eth/contracts/AuthorizationOperation.sol b/dstack/kms/auth-eth/contracts/AuthorizationOperation.sol new file mode 100644 index 000000000..d5f926616 --- /dev/null +++ b/dstack/kms/auth-eth/contracts/AuthorizationOperation.sol @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.24; + +/// The operation is performed by the attested subject, not the policy caller. +library AuthorizationOperation { + bytes32 internal constant KMS_BOOTSTRAP = keccak256("kms_bootstrap"); + bytes32 internal constant KMS_SERVE = keccak256("kms_serve"); + bytes32 internal constant KMS_EXPORT_KEYS = keccak256("kms_export_keys"); + bytes32 internal constant KMS_IMPORT_KEYS = keccak256("kms_import_keys"); + bytes32 internal constant APP_GET_KEY = keccak256("app_get_key"); + bytes32 internal constant APP_GET_CERT = keccak256("app_get_cert"); + + function isKms(bytes32 operation) internal pure returns (bool) { + return operation == KMS_BOOTSTRAP || operation == KMS_SERVE || operation == KMS_EXPORT_KEYS + || operation == KMS_IMPORT_KEYS; + } + + function isApp(bytes32 operation) internal pure returns (bool) { + return operation == APP_GET_KEY || operation == APP_GET_CERT; + } +} diff --git a/dstack/kms/auth-eth/contracts/DstackApp.sol b/dstack/kms/auth-eth/contracts/DstackApp.sol index 7d90f29ef..722614c67 100644 --- a/dstack/kms/auth-eth/contracts/DstackApp.sol +++ b/dstack/kms/auth-eth/contracts/DstackApp.sol @@ -7,7 +7,9 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; -import "./IAppAuthBasicManagement.sol"; +import "./AuthorizationOperation.sol"; +import "./IAppEpochAuth.sol"; +import "./IAppEpochManagement.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; @@ -19,25 +21,44 @@ contract DstackApp is UUPSUpgradeable, ERC165Upgradeable, IAppAuth, - IAppAuthBasicManagement + IAppEpochAuth, + IAppEpochManagement { - // Mapping of allowed compose hashes for this app + /// @notice Allowed compose hashes for this app. Epoch 0 only; for other epochs use the *AtEpoch getters and + /// EpochPolicyChanged. mapping(bytes32 => bool) public allowedComposeHashes; // State variable to track if upgrades are disabled bool private _upgradesDisabled; - // Whether allow any device to boot this app or only allow devices + /// @notice Whether any device may boot this app. Epoch 0 only; for other epochs use the *AtEpoch getters and + /// EpochPolicyChanged. bool public allowAnyDevice; - // Mapping of allowed device IDs for this app + /// @notice Allowed device IDs for this app. Epoch 0 only; for other epochs use the *AtEpoch getters and + /// EpochPolicyChanged. mapping(bytes32 => bool) public allowedDeviceIds; // Whether to require TCB status to be UpToDate bool public requireTcbUpToDate; - // Additional events specific to DstackApp + 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); + + /// @notice Legacy material events. Epoch 0 only; for other epochs use the *AtEpoch getters and EpochPolicyChanged. + event ComposeHashAdded(bytes32 composeHash); + event ComposeHashRemoved(bytes32 composeHash); + event DeviceAdded(bytes32 deviceId); + event DeviceRemoved(bytes32 deviceId); event UpgradesDisabled(); + /// @notice Epoch 0 only; for other epochs use the *AtEpoch getters and EpochPolicyChanged. event AllowAnyDeviceSet(bool allowAny); event RequireTcbUpToDateSet(bool requireUpToDate); /// @notice Additive audit event for reconstructing authorization policy. @@ -128,8 +149,8 @@ contract DstackApp is override(ERC165Upgradeable, IERC165) returns (bool) { - return interfaceId == 0x1e079198 // IAppAuth - || interfaceId == 0x8fd37527 // IAppAuthBasicManagement + return interfaceId == type(IAppEpochAuth).interfaceId || interfaceId == type(IAppEpochManagement).interfaceId + || interfaceId == 0x1e079198 // IAppAuth || super.supportsInterface(interfaceId); } @@ -143,26 +164,89 @@ 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]; + } + + // A request may select any app epoch up to the current one and is decided + // on that epoch's row, so revoking a value takes every row that allows it: + // 0 through currentAppEpoch. + function _setAtEpochs( + function(uint256, bytes32, bool) internal setter, + uint256 fromEpoch, + uint256 toEpoch, + bytes32 value, + bool allowed + ) + private + { + require(fromEpoch <= toEpoch, "Invalid app epoch range"); + for (uint256 epoch = fromEpoch; epoch <= toEpoch; ++epoch) { + setter(epoch, value, allowed); + } + } + + 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 addComposeHashAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 composeHash) external onlyOwner { + require(composeHash != bytes32(0), "invalid compose hash"); + _setAtEpochs(_setComposeHash, fromEpoch, toEpoch, composeHash, true); } - // 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 removeComposeHashAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 composeHash) external onlyOwner { + _setAtEpochs(_setComposeHash, fromEpoch, toEpoch, composeHash, false); + } + + function _requireWritableEpoch(uint256 epoch) private view { + require(epoch <= currentAppEpoch + 1, "Unknown app epoch"); + } + + function _setComposeHash(uint256 epoch, bytes32 composeHash, bool allowed) private { + _requireWritableEpoch(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 setAllowAnyDeviceAtEpoch(uint256 epoch, bool allowed) public onlyOwner { + _setAllowAnyDevice(epoch, bytes32(0), allowed); + } + + function setAllowAnyDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bool allowed) external onlyOwner { + _setAtEpochs(_setAllowAnyDevice, fromEpoch, toEpoch, bytes32(0), allowed); + } + + function _setAllowAnyDevice(uint256 epoch, bytes32, bool allowed) private { + _requireWritableEpoch(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 @@ -172,28 +256,74 @@ 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 addDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + require(deviceId != bytes32(0), "invalid device ID"); + _setDevice(epoch, deviceId, true); + } + + function addDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 deviceId) external onlyOwner { require(deviceId != bytes32(0), "invalid device ID"); - allowedDeviceIds[deviceId] = true; - emit DeviceAdded(deviceId); - _emitPolicy("device", deviceId, true); + _setAtEpochs(_setDevice, fromEpoch, toEpoch, deviceId, true); } - // Remove a device ID from allowed list - function removeDevice(bytes32 deviceId) external onlyOwner { - allowedDeviceIds[deviceId] = false; - emit DeviceRemoved(deviceId); - _emitPolicy("device", deviceId, false); + function removeDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + _setDevice(epoch, deviceId, false); } - // Check if an app is allowed to boot - function isAppAllowed(IAppAuth.AppBootInfo calldata bootInfo) + function removeDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 deviceId) external onlyOwner { + _setAtEpochs(_setDevice, fromEpoch, toEpoch, deviceId, false); + } + + function _setDevice(uint256 epoch, bytes32 deviceId, bool allowed) private { + _requireWritableEpoch(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); + } + + // Legacy callers derive epoch-zero keys from this bool, so once the app epoch + // advances they are refused rather than handed keys the owner is retiring. + function isAppAllowed(IAppAuth.AppInfo calldata bootInfo) external view override returns (bool isAllowed, string memory reason) { + if (currentAppEpoch != 0) return (false, "App epoch requires epoch-aware authorization"); + return _authorizeApp(bootInfo, 0); + } + + /// Decide the request at the selected app epoch; the KMS fills gateway and root fields. + function authorizeApp(AuthorizationRequest calldata request) + external + view + override + returns (AuthorizationResult memory result) + { + result.currentAppEpoch = currentAppEpoch; + if (!AuthorizationOperation.isApp(request.operation)) { + result.rejectionReason = "Invalid authorization operation"; + return result; + } + uint256 appEpoch = request.requestedAppEpoch.isSet ? request.requestedAppEpoch.value : currentAppEpoch; + (result.authorized, result.rejectionReason) = _authorizeApp(request.appInfo, 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 @@ -203,18 +333,27 @@ 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"); } return (true, ""); } + /// Advance only from the epoch the owner intended, rejecting stale transactions. + function advanceAppEpoch(uint256 expectedCurrentEpoch) external onlyOwner { + require(currentAppEpoch == expectedCurrentEpoch, "App epoch changed"); + require(currentAppEpoch < type(uint32).max, "App epoch exhausted"); + currentAppEpoch++; + emit AppEpochAdvanced(currentAppEpoch); + _emitPolicy("app-epoch", bytes32(currentAppEpoch), true); + } + // Function to permanently disable upgrades function disableUpgrades() external onlyOwner { _upgradesDisabled = true; @@ -223,5 +362,5 @@ contract DstackApp is } // Add storage gap for upgradeable contracts - uint256[49] 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 f79325b89..91651a39a 100644 --- a/dstack/kms/auth-eth/contracts/DstackKms.sol +++ b/dstack/kms/auth-eth/contracts/DstackKms.sol @@ -7,6 +7,9 @@ pragma solidity ^0.8.24; import "./IAppAuth.sol"; +import "./AuthorizationOperation.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"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; @@ -22,7 +25,8 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E bytes eventlog; } - // KMS information + // Retained for proxy storage compatibility and legacy readers. Only root + // epoch registration and activation may update this identity. KmsInfo public kmsInfo; // The dstack-gateway App ID @@ -32,23 +36,67 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E // Mapping of registered apps mapping(address => bool) public registeredApps; - // Mapping of allowed aggregated MR measurements for running KMS + /// @notice Allowed aggregated MR measurements for running KMS. Epoch 0 only; for other epochs use the *AtEpoch + /// getters and EpochPolicyChanged. mapping(bytes32 => bool) public kmsAllowedAggregatedMrs; - // Mapping of allowed KMS device IDs + /// @notice Allowed KMS device IDs. Epoch 0 only; for other epochs use the *AtEpoch getters and EpochPolicyChanged. mapping(bytes32 => bool) public kmsAllowedDeviceIds; - // Mapping of allowed image measurements + /// @notice Allowed OS image measurements. Epoch 0 only; for other epochs use the *AtEpoch getters and + /// EpochPolicyChanged. mapping(bytes32 => bool) public allowedOsImages; // DstackApp implementation contract address for factory deployment address public appImplementation; + // DER SubjectPublicKeyInfo prefix of a P-256 key with an uncompressed point; + // the 64 bytes of the point follow. + bytes27 private constant P256_SPKI_PREFIX = 0x3059301306072a8648ce3d020106082a8648ce3d03010703420004; + + struct RootEpoch { + bytes caPubkey; + bytes k256Pubkey; + // Where to fetch the generating KMS's attestation. Its report data binds + // both public keys, so the evidence proves itself: anyone can check the + // keys came from an approved KMS in a TEE. + string evidenceUrl; + // Commits to the public keys of this epoch and every earlier one: + // keccak256(previous chainHash || keccak256(caPubkey) || + // keccak256(k256Pubkey)), with a zero previous hash for epoch 0. + bytes32 chainHash; + // Keys this epoch's policy rows; unique to each registration. + uint256 registration; + } + + // Root keys of each KMS root epoch, indexed by epoch; epoch 0 is the + // original roots. A registered epoch's keys and chain hash never change. + // A pending epoch can be popped, and the owner can update evidenceUrl: + // the evidence proves itself, so the URL only says where it is stored. + RootEpoch[] private _rootEpochs; + + // The epoch KMS instances serve. Registering epoch 0 makes it current; + // later epochs take effect only when activated, one at a time. + uint256 private _currentRootEpoch; + + // Number of root epoch registrations, popped ones included. + uint256 private _rootEpochRegistrations; + + // Policy rows of epochs from 1, keyed by the epoch's registration so that a + // popped and re-registered epoch starts empty, even with the same keys. + // Epoch zero continues to use the original fields above after an upgrade. + mapping(uint256 => mapping(bytes32 => bool)) private _kmsAggregatedMrsByRoot; + mapping(uint256 => mapping(bytes32 => bool)) private _kmsDeviceIdsByRoot; + mapping(uint256 => mapping(bytes32 => bool)) private _osImagesByRoot; + + 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 event AppRegistered(address appId); event KmsInfoSet(bytes k256Pubkey); + /// @notice Legacy material events. Epoch 0 only; for other epochs use the *AtEpoch getters and EpochPolicyChanged. event KmsAggregatedMrAdded(bytes32 mrAggregated); event KmsAggregatedMrRemoved(bytes32 mrAggregated); event KmsDeviceAdded(bytes32 deviceId); @@ -58,6 +106,12 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E event GatewayAppIdSet(string gatewayAppId); // slither-disable-next-line unindexed-event-address event AppImplementationSet(address implementation); + event RootEpochRegistered( + uint256 indexed epoch, bytes32 chainHash, bytes caPubkey, bytes k256Pubkey, string evidenceUrl + ); + event RootEpochActivated(uint256 indexed epoch); + event RootEpochPopped(uint256 indexed epoch, bytes32 chainHash); + event RootEpochEvidenceUrlSet(uint256 indexed epoch, string evidenceUrl); event AppDeployedViaFactory(address indexed appId, address indexed deployer); /// @notice Additive audit event for reconstructing authorization policy. /// @dev `value` is the affected bytes32 value, address, or hash of dynamic public data. @@ -108,21 +162,109 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E emit PolicyChanged(msg.sender, keccak256(bytes(policy)), value, enabled); } - // Function to set KMS information - function setKmsInfo(KmsInfo memory info) external onlyOwner { - kmsInfo = info; - emit KmsInfoSet(info.k256Pubkey); - _emitPolicy("kms-info", keccak256(info.k256Pubkey), true); + // Register the root public keys of the next KMS root epoch, with where to + // fetch the attestation proving where they were generated. It is not served until + // activated, except epoch 0, which is current from the start and must be + // the kmsInfo keys when those are set. + function registerRootEpoch( + // P-256 DER SubjectPublicKeyInfo, uncompressed (91 bytes) + bytes calldata caPubkey, + // secp256k1 SEC1 compressed (33 bytes) + bytes calldata k256Pubkey, + string calldata evidenceUrl + ) + external + onlyOwner + returns (uint256 epoch) + { + require(caPubkey.length == 91 && bytes27(caPubkey[:27]) == P256_SPKI_PREFIX, "Invalid epoch CA public key"); + require( + k256Pubkey.length == 33 && (k256Pubkey[0] == 0x02 || k256Pubkey[0] == 0x03), "Invalid epoch k256 public key" + ); + require(bytes(evidenceUrl).length > 0, "Empty epoch evidence URL"); + epoch = _rootEpochs.length; + if (epoch == 0 && kmsInfo.k256Pubkey.length > 0) { + require( + keccak256(caPubkey) == keccak256(kmsInfo.caPubkey) + && keccak256(k256Pubkey) == keccak256(kmsInfo.k256Pubkey), + "Epoch 0 keys differ from kmsInfo" + ); + } + bytes32 previous = epoch == 0 ? bytes32(0) : _rootEpochs[epoch - 1].chainHash; + bytes32 chainHash = keccak256(abi.encodePacked(previous, keccak256(caPubkey), keccak256(k256Pubkey))); + _rootEpochs.push(RootEpoch(caPubkey, k256Pubkey, evidenceUrl, chainHash, _rootEpochRegistrations++)); + emit RootEpochRegistered(epoch, chainHash, caPubkey, k256Pubkey, evidenceUrl); + _emitPolicy("root-epoch", chainHash, true); + if (epoch == 0) { + _useRootEpoch(0); + } + } + + // Make the next registered root epoch the one KMS instances serve. Bind + // activation to its chain hash in case a pending epoch has been replaced. + function activateRootEpoch(uint256 epoch, bytes32 chainHash) external onlyOwner { + require(epoch == _currentRootEpoch + 1 && epoch < _rootEpochs.length, "Invalid root epoch activation"); + require(_rootEpochs[epoch].chainHash == chainHash, "root epoch chain hash mismatch"); + _currentRootEpoch = epoch; + _useRootEpoch(epoch); + _emitPolicy("root-epoch-activated", bytes32(epoch), true); } - // Function to set KMS quote - function setKmsQuote(bytes memory quote) external onlyOwner { - kmsInfo.quote = quote; + // Point kmsInfo at the keys of the now current epoch, for readers of the + // legacy KMS identity. Its quote and event log described the previous keys; + // the epoch's evidence is at its evidenceUrl. + function _useRootEpoch(uint256 epoch) private { + RootEpoch storage keys = _rootEpochs[epoch]; + kmsInfo.caPubkey = keys.caPubkey; + kmsInfo.k256Pubkey = keys.k256Pubkey; + delete kmsInfo.quote; + delete kmsInfo.eventlog; + emit KmsInfoSet(keys.k256Pubkey); + emit RootEpochActivated(epoch); } - // Function to set KMS eventlog - function setKmsEventlog(bytes memory eventlog) external onlyOwner { - kmsInfo.eventlog = eventlog; + // Point an epoch at a new copy of its evidence, for when the old location + // goes away. The evidence proves itself, so only its location changes. + function setRootEpochEvidenceUrl(uint256 epoch, string calldata evidenceUrl) external onlyOwner { + require(epoch < _rootEpochs.length, "Unknown root epoch"); + require(bytes(evidenceUrl).length > 0, "Empty epoch evidence URL"); + _rootEpochs[epoch].evidenceUrl = evidenceUrl; + emit RootEpochEvidenceUrlSet(epoch, evidenceUrl); + } + + // Drop the last registered epoch while it is still pending, for when the + // nodes holding it are lost before enough others have onboarded it. The + // epoch is named by number and chain hash, so a stale call cannot drop a + // later registration of the same number. Its policy rows go with it: the + // next registration of the number starts with empty rows, whatever its keys, + // which must be prepared again before activation. + function popRootEpoch(uint256 epoch, bytes32 chainHash) external onlyOwner { + require(epoch + 1 == _rootEpochs.length && epoch > _currentRootEpoch, "Not the pending root epoch"); + require(_rootEpochs[epoch].chainHash == chainHash, "Root epoch chain hash mismatch"); + _rootEpochs.pop(); + emit RootEpochPopped(epoch, chainHash); + _emitPolicy("root-epoch", chainHash, false); + } + + function rootEpochCount() external view returns (uint256) { + return _rootEpochs.length; + } + + // The root epoch KMS instances serve and its chain hash + function currentRootEpoch() external view returns (uint256 epoch, bytes32 chainHash) { + require(_rootEpochs.length > 0, "No root epoch registered"); + epoch = _currentRootEpoch; + chainHash = _rootEpochs[epoch].chainHash; + } + + function rootEpoch(uint256 epoch) + external + view + returns (bytes memory caPubkey, bytes memory k256Pubkey, string memory evidenceUrl, bytes32 chainHash) + { + require(epoch < _rootEpochs.length, "Unknown root epoch"); + RootEpoch storage keys = _rootEpochs[epoch]; + return (keys.caPubkey, keys.k256Pubkey, keys.evidenceUrl, keys.chainHash); } // Function to set trusted Gateway App ID @@ -210,94 +352,306 @@ 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) { + if (epoch == 0) return kmsAllowedAggregatedMrs[mrAggregated]; + return epoch < _rootEpochs.length && _kmsAggregatedMrsByRoot[_rootEpochs[epoch].registration][mrAggregated]; + } + + function addKmsAggregatedMrAtEpoch(uint256 epoch, bytes32 mrAggregated) public onlyOwner { + _setKmsAggregatedMr(epoch, mrAggregated, true); + } + + function addKmsAggregatedMrAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 mrAggregated) external onlyOwner { + _setAtEpochs(_setKmsAggregatedMr, fromEpoch, toEpoch, mrAggregated, true); + } + + function removeKmsAggregatedMrAtEpoch(uint256 epoch, bytes32 mrAggregated) public onlyOwner { + _setKmsAggregatedMr(epoch, mrAggregated, false); + } + + function removeKmsAggregatedMrAtEpochs( + uint256 fromEpoch, + uint256 toEpoch, + bytes32 mrAggregated + ) + external + onlyOwner + { + _setAtEpochs(_setKmsAggregatedMr, fromEpoch, toEpoch, 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 { + _kmsAggregatedMrsByRoot[_rootEpochs[epoch].registration][mrAggregated] = allowed; + } + emit EpochPolicyChanged(keccak256("kms-aggregated-mr"), mrAggregated, epoch, allowed); } - // 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 kmsAllowedDeviceIdsAtEpoch(uint256 epoch, bytes32 deviceId) public view returns (bool) { + if (epoch == 0) return kmsAllowedDeviceIds[deviceId]; + return epoch < _rootEpochs.length && _kmsDeviceIdsByRoot[_rootEpochs[epoch].registration][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 addKmsDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + _setKmsDevice(epoch, deviceId, true); } - // 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 addKmsDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 deviceId) external onlyOwner { + _setAtEpochs(_setKmsDevice, fromEpoch, toEpoch, deviceId, true); } - // Function to register an image measurement - function addOsImageHash(bytes32 osImageHash) external onlyOwner { - allowedOsImages[osImageHash] = true; - emit OsImageHashAdded(osImageHash); - _emitPolicy("os-image", osImageHash, true); + function removeKmsDeviceAtEpoch(uint256 epoch, bytes32 deviceId) public onlyOwner { + _setKmsDevice(epoch, deviceId, false); } - // Function to deregister an image measurement - function removeOsImageHash(bytes32 osImageHash) external onlyOwner { - allowedOsImages[osImageHash] = false; - emit OsImageHashRemoved(osImageHash); - _emitPolicy("os-image", osImageHash, false); + function removeKmsDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 deviceId) external onlyOwner { + _setAtEpochs(_setKmsDevice, fromEpoch, toEpoch, deviceId, false); } - // Function to check if KMS is allowed to boot - function isKmsAllowed(AppBootInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason) { + 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 { + _kmsDeviceIdsByRoot[_rootEpochs[epoch].registration][deviceId] = allowed; + } + emit EpochPolicyChanged(keccak256("kms-device"), deviceId, epoch, allowed); + } + + function allowedOsImagesAtEpoch(uint256 epoch, bytes32 osImageHash) public view returns (bool) { + if (epoch == 0) return allowedOsImages[osImageHash]; + return epoch < _rootEpochs.length && _osImagesByRoot[_rootEpochs[epoch].registration][osImageHash]; + } + + function addOsImageHashAtEpoch(uint256 epoch, bytes32 osImageHash) public onlyOwner { + _setOsImageHash(epoch, osImageHash, true); + } + + function addOsImageHashAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 osImageHash) external onlyOwner { + _setAtEpochs(_setOsImageHash, fromEpoch, toEpoch, osImageHash, true); + } + + function removeOsImageHashAtEpoch(uint256 epoch, bytes32 osImageHash) public onlyOwner { + _setOsImageHash(epoch, osImageHash, false); + } + + function removeOsImageHashAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 osImageHash) external onlyOwner { + _setAtEpochs(_setOsImageHash, fromEpoch, toEpoch, 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 { + _osImagesByRoot[_rootEpochs[epoch].registration][osImageHash] = allowed; + } + emit EpochPolicyChanged(keccak256("os-image"), osImageHash, epoch, allowed); + } + + // A request may select any epoch up to the current one and is decided on + // that epoch's row, so revoking a value takes every row that allows it: + // 0 through rootEpochCount() - 1. + function _setAtEpochs( + function(uint256, bytes32, bool) internal setter, + uint256 fromEpoch, + uint256 toEpoch, + bytes32 value, + bool allowed + ) + private + { + require(fromEpoch <= toEpoch, "Invalid root epoch range"); + for (uint256 epoch = fromEpoch; epoch <= toEpoch; ++epoch) { + setter(epoch, value, allowed); + } + } + + function _latestRootEpoch() private view returns (uint256) { + return _rootEpochs.length == 0 ? 0 : _rootEpochs.length - 1; + } + + /// Reverts for an unregistered or undeployed app rather than reporting epoch + /// zero for it. Legacy policies have epoch zero; an advertised getter must succeed. + function currentAppEpoch(address appId) external view returns (uint256) { + require(registeredApps[appId], "App not registered"); + require(appId.code.length != 0, "App not deployed or invalid address"); + return _appEpoch(appId); + } + + /// Zero for an app authorization rejects anyway, so the decision reports the rejection. + function _appEpoch(address appId) private view returns (uint256) { + if (!registeredApps[appId] || appId.code.length == 0) return 0; + 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. + /// The caller selects KMS or app policy. + function authorizeKms(AuthorizationRequest calldata request) external view returns (AuthorizationResult memory) { + return _checkKmsAuthorization(request); + } + + /// Apply the KMS-owned checks, forward the request unchanged to an epoch-aware + /// policy, then fill the gateway and root epoch fields. + function authorizeApp(AuthorizationRequest calldata request) + external + view + returns (AuthorizationResult memory result) + { + address appId = request.appInfo.appId; + string memory rejection = _checkAppRequest(request); + if (bytes(rejection).length == 0) { + if (ERC165Checker.supportsInterface(appId, type(IAppEpochAuth).interfaceId)) { + // The policy owns the operation scope and the app epoch, so its + // decision and epoch come from one call. + result = IAppEpochAuth(appId).authorizeApp(request); + } else if (request.requestedAppEpoch.isSet && request.requestedAppEpoch.value != 0) { + result.rejectionReason = "Requested app epoch is not active"; + } else { + // Legacy policies only know app epoch zero. + (result.authorized, result.rejectionReason) = IAppAuth(appId).isAppAllowed(request.appInfo); + } + } else { + result.rejectionReason = rejection; + } + _fillRootEpochs(result); + } + + /// The KMS-owned fields of every authorization result, read from the same state. + function _fillRootEpochs(AuthorizationResult memory result) private view { + result.gatewayAppId = gatewayAppId; + if (_rootEpochs.length == 0) return; + result.currentRootEpoch = _currentRootEpoch; + result.currentRootEpochHash = _rootEpochs[_currentRootEpoch].chainHash; + uint256 latest = _latestRootEpoch(); + result.latestRootEpoch = latest; + result.latestRootEpochHash = _rootEpochs[latest].chainHash; + } + + /// KMS-owned app checks; an empty string forwards the request to the app policy. + function _checkAppRequest(AuthorizationRequest calldata request) private view returns (string memory) { + if (!AuthorizationOperation.isApp(request.operation)) return "Invalid authorization operation"; + if (_rootEpochs.length == 0) return "Root epoch not registered"; + if (request.requestedRootEpoch.isSet && request.requestedRootEpoch.value > _currentRootEpoch) { + return "Requested root epoch is not active"; + } + uint256 selectedRootEpoch = + request.requestedRootEpoch.isSet ? request.requestedRootEpoch.value : _currentRootEpoch; + return _checkAppDeployment(request.appInfo, selectedRootEpoch); + } + + function _checkAppDeployment( + AppInfo calldata bootInfo, + uint256 selectedRootEpoch + ) + private + view + returns (string memory) + { + if (!registeredApps[bootInfo.appId]) return "App not registered"; + if (!allowedOsImagesAtEpoch(selectedRootEpoch, bootInfo.osImageHash)) return "OS image is not allowed"; + if (!isContract(bootInfo.appId)) return "App not deployed or invalid address"; + return ""; + } + + function _checkKmsAuthorization(AuthorizationRequest calldata request) + private + view + returns (AuthorizationResult memory result) + { + _fillRootEpochs(result); + if (!AuthorizationOperation.isKms(request.operation)) { + result.rejectionReason = "Invalid authorization operation"; + return result; + } + if (_rootEpochs.length == 0 && request.operation != AuthorizationOperation.KMS_BOOTSTRAP) { + result.rejectionReason = "Root epoch not registered"; + return result; + } + if (request.requestedRootEpoch.isSet && request.requestedRootEpoch.value > _latestRootEpoch()) { + result.rejectionReason = "Requested root epoch is not registered"; + return result; + } + // Serving issues keys of the epoch, so only the current one; the key + // handover may move a pending epoch between instances. + if ( + request.operation == AuthorizationOperation.KMS_SERVE && request.requestedRootEpoch.isSet + && request.requestedRootEpoch.value > _currentRootEpoch + ) { + result.rejectionReason = "Requested root epoch is not active"; + return result; + } + if (request.requestedAppEpoch.isSet && request.requestedAppEpoch.value != 0) { + result.rejectionReason = "Requested app epoch is not active"; + return result; + } + uint256 selectedRootEpoch = + request.requestedRootEpoch.isSet ? request.requestedRootEpoch.value : _currentRootEpoch; + (result.authorized, result.rejectionReason) = _kmsPolicy(request.appInfo, selectedRootEpoch); + } + + // 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) { + if (_currentRootEpoch != 0) { + return (false, "Root epoch requires epoch-aware authorization"); + } + return _kmsPolicy(bootInfo, 0); + } + + function _kmsPolicy(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"); } return (true, ""); } - // Function to check if an app is allowed to boot - function isAppAllowed(AppBootInfo calldata bootInfo) + // Legacy entry point for pre-epoch KMS binaries: reads epoch 0, and refuses + // once a later root epoch is current. + function isAppAllowed(AppInfo calldata bootInfo) external view override returns (bool isAllowed, string memory reason) { - // 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]) { - return (false, "OS image is not allowed"); + if (_currentRootEpoch != 0) { + return (false, "Root epoch requires epoch-aware authorization"); } - - // Check if the contract exists at the appId address - if (!isContract(bootInfo.appId)) { - return (false, "App not deployed or invalid address"); + if (_appEpoch(bootInfo.appId) != 0) { + return (false, "App epoch requires epoch-aware authorization"); } - + string memory rejection = _checkAppDeployment(bootInfo, 0); + if (bytes(rejection).length != 0) return (false, rejection); // 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. @@ -306,7 +660,7 @@ contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, E } // Add storage gap for upgradeable contracts - uint256[50] private __gap; + uint256[44] private __gap; } function isContract(address addr) view returns (bool) { diff --git a/dstack/kms/auth-eth/contracts/IAppAuth.sol b/dstack/kms/auth-eth/contracts/IAppAuth.sol index 56b768971..896c60954 100644 --- a/dstack/kms/auth-eth/contracts/IAppAuth.sol +++ b/dstack/kms/auth-eth/contracts/IAppAuth.sol @@ -33,7 +33,7 @@ interface IAppAuth is IERC165 { * @param tcbStatus Trusted Computing Base status * @param advisoryIds Array of security advisory identifiers */ - struct AppBootInfo { + struct AppInfo { address appId; bytes32 composeHash; address instanceId; @@ -52,5 +52,5 @@ interface IAppAuth is IERC165 { * @return isAllowed True if the app is authorized to boot, false otherwise * @return reason Human-readable reason for the decision (empty if allowed) */ - function isAppAllowed(AppBootInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason); + function isAppAllowed(AppInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason); } diff --git a/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol new file mode 100644 index 000000000..be720f49c --- /dev/null +++ b/dstack/kms/auth-eth/contracts/IAppEpochAuth.sol @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "./IAppAuth.sol"; + +/// An explicitly selected value; `isSet == false` selects the current one. +struct OptionalUint256 { + bool isSet; + uint256 value; +} + +struct AuthorizationRequest { + IAppAuth.AppInfo appInfo; + bytes32 operation; + OptionalUint256 requestedAppEpoch; + OptionalUint256 requestedRootEpoch; +} + +struct AuthorizationResult { + bool authorized; + string rejectionReason; + string gatewayAppId; + uint256 currentRootEpoch; + bytes32 currentRootEpochHash; + uint256 currentAppEpoch; + // The latest registered root epoch, which may be pending activation. + uint256 latestRootEpoch; + bytes32 latestRootEpochHash; +} + +/// Optional epoch- and operation-aware policy capability. Legacy policies only authorize app epoch zero. +interface IAppEpochAuth { + function currentAppEpoch() external view returns (uint256); + + /// Receives the KMS request unchanged. The policy validates the operation and app + /// epoch and fills `authorized`, `rejectionReason` and `currentAppEpoch`; the KMS + /// owns `gatewayAppId` and the current and latest root epoch fields. + function authorizeApp(AuthorizationRequest calldata request) external view returns (AuthorizationResult memory); +} diff --git a/dstack/kms/auth-eth/contracts/IAppEpochManagement.sol b/dstack/kms/auth-eth/contracts/IAppEpochManagement.sol new file mode 100644 index 000000000..a8585ecad --- /dev/null +++ b/dstack/kms/auth-eth/contracts/IAppEpochManagement.sol @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +/// Epoch-scoped allowlist management. Each setter names the app epoch rows it +/// writes: one epoch, or `fromEpoch` through `toEpoch`, at most the next epoch. +/// Replaces the epoch-less IAppAuthBasicManagement (0x8fd37527), which policies +/// predating app epochs still report; tools tell the two apart by interface ID. +interface IAppEpochManagement { + function addComposeHashAtEpoch(uint256 epoch, bytes32 composeHash) external; + function addComposeHashAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 composeHash) external; + function removeComposeHashAtEpoch(uint256 epoch, bytes32 composeHash) external; + function removeComposeHashAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 composeHash) external; + function addDeviceAtEpoch(uint256 epoch, bytes32 deviceId) external; + function addDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 deviceId) external; + function removeDeviceAtEpoch(uint256 epoch, bytes32 deviceId) external; + function removeDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bytes32 deviceId) external; + function setAllowAnyDeviceAtEpoch(uint256 epoch, bool allowed) external; + function setAllowAnyDeviceAtEpochs(uint256 fromEpoch, uint256 toEpoch, bool allowed) external; +} diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/DstackAppV059.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/DstackAppV059.sol new file mode 100644 index 000000000..7eafcd703 --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/DstackAppV059.sol @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.22; + +import "./IAppAuth.sol"; +import "./IAppAuthBasicManagement.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; + +contract DstackApp is + Initializable, + OwnableUpgradeable, + UUPSUpgradeable, + ERC165Upgradeable, + IAppAuth, + IAppAuthBasicManagement +{ + // Mapping of allowed compose hashes for this app + mapping(bytes32 => bool) public allowedComposeHashes; + + // State variable to track if upgrades are disabled + bool private _upgradesDisabled; + + // Whether allow any device to boot this app or only allow devices + bool public allowAnyDevice; + + // Mapping of allowed device IDs for this app + mapping(bytes32 => bool) public allowedDeviceIds; + + // Whether to require TCB status to be UpToDate + bool public requireTcbUpToDate; + + // Additional events specific to DstackApp + event UpgradesDisabled(); + event AllowAnyDeviceSet(bool allowAny); + event RequireTcbUpToDateSet(bool requireUpToDate); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + // Old initialize — preserved for upgrade compatibility + function initialize( + address initialOwner, + bool _disableUpgrades, + bool _allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) public initializer { + _initializeCommon(initialOwner, _disableUpgrades, _allowAnyDevice, initialDeviceId, initialComposeHash); + } + + // New initialize — includes requireTcbUpToDate + function initialize( + address initialOwner, + bool _disableUpgrades, + bool _requireTcbUpToDate, + bool _allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) public initializer { + requireTcbUpToDate = _requireTcbUpToDate; + _initializeCommon(initialOwner, _disableUpgrades, _allowAnyDevice, initialDeviceId, initialComposeHash); + } + + function _initializeCommon( + address initialOwner, + bool _disableUpgrades, + bool _allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) internal { + require(initialOwner != address(0), "invalid owner address"); + + _upgradesDisabled = _disableUpgrades; + allowAnyDevice = _allowAnyDevice; + + // Add initial device if provided + if (initialDeviceId != bytes32(0)) { + allowedDeviceIds[initialDeviceId] = true; + emit DeviceAdded(initialDeviceId); + } + + // Add initial compose hash if provided + if (initialComposeHash != bytes32(0)) { + allowedComposeHashes[initialComposeHash] = true; + emit ComposeHashAdded(initialComposeHash); + } + + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + __ERC165_init(); + } + + function version() public pure returns (uint256) { + return 2; + } + + /** + * @dev See {IERC165-supportsInterface}. + * @notice Returns true if this contract implements the interface defined by interfaceId + * @param interfaceId The interface identifier, as specified in ERC-165 + * @return True if the contract implements `interfaceId` + */ + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return + interfaceId == 0x1e079198 || // IAppAuth + interfaceId == 0x8fd37527 || // IAppAuthBasicManagement + super.supportsInterface(interfaceId); + } + + // Function to authorize upgrades (required by UUPSUpgradeable) + function _authorizeUpgrade(address) internal view override onlyOwner { + require(!_upgradesDisabled, "Upgrades are permanently disabled"); + } + + // Add a compose hash to allowed list + function addComposeHash(bytes32 composeHash) external onlyOwner { + allowedComposeHashes[composeHash] = true; + emit ComposeHashAdded(composeHash); + } + + // Remove a compose hash from allowed list + function removeComposeHash(bytes32 composeHash) external onlyOwner { + allowedComposeHashes[composeHash] = false; + emit ComposeHashRemoved(composeHash); + } + + // Set whether any device is allowed to boot this app + function setAllowAnyDevice(bool _allowAnyDevice) external onlyOwner { + allowAnyDevice = _allowAnyDevice; + emit AllowAnyDeviceSet(_allowAnyDevice); + } + + // Set whether TCB status must be UpToDate to boot this app + function setRequireTcbUpToDate(bool _requireUpToDate) external onlyOwner { + requireTcbUpToDate = _requireUpToDate; + emit RequireTcbUpToDateSet(_requireUpToDate); + } + + // Add a device ID to allowed list + function addDevice(bytes32 deviceId) external onlyOwner { + allowedDeviceIds[deviceId] = true; + emit DeviceAdded(deviceId); + } + + // Remove a device ID from allowed list + function removeDevice(bytes32 deviceId) external onlyOwner { + allowedDeviceIds[deviceId] = false; + emit DeviceRemoved(deviceId); + } + + // Check if an app is allowed to boot + function isAppAllowed( + IAppAuth.AppBootInfo calldata bootInfo + ) external view override returns (bool isAllowed, string memory reason) { + // Optionally require TCB status to be up to date + if ( + requireTcbUpToDate && + keccak256(abi.encodePacked(bootInfo.tcbStatus)) != + keccak256(abi.encodePacked("UpToDate")) + ) { + return (false, "TCB status is not up to date"); + } + + // Check if compose hash is allowed + if (!allowedComposeHashes[bootInfo.composeHash]) { + return (false, "Compose hash not allowed"); + } + + // Check if device is allowed (when device restriction is enabled) + if (!allowAnyDevice && !allowedDeviceIds[bootInfo.deviceId]) { + return (false, "Device not allowed"); + } + + return (true, ""); + } + + // Function to permanently disable upgrades + function disableUpgrades() external onlyOwner { + _upgradesDisabled = true; + emit UpgradesDisabled(); + } + + // Add storage gap for upgradeable contracts + uint256[49] private __gap; +} diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/DstackKmsV059.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/DstackKmsV059.sol new file mode 100644 index 000000000..64031076c --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/DstackKmsV059.sol @@ -0,0 +1,286 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.22; + +import "./IAppAuth.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract DstackKms is + Initializable, + OwnableUpgradeable, + UUPSUpgradeable, + ERC165Upgradeable, + IAppAuth +{ + // Struct for KMS information + struct KmsInfo { + bytes k256Pubkey; + bytes caPubkey; + bytes quote; + bytes eventlog; + } + + // KMS information + KmsInfo public kmsInfo; + + // The dstack-gateway App ID + /// @custom:oz-renamed-from tproxyAppId + string public gatewayAppId; + + // Mapping of registered apps + mapping(address => bool) public registeredApps; + + // Mapping of allowed aggregated MR measurements for running KMS + mapping(bytes32 => bool) public kmsAllowedAggregatedMrs; + + // Mapping of allowed KMS device IDs + mapping(bytes32 => bool) public kmsAllowedDeviceIds; + + // Mapping of allowed image measurements + mapping(bytes32 => bool) public allowedOsImages; + + // DstackApp implementation contract address for factory deployment + address public appImplementation; + + // Events + event AppRegistered(address appId); + event KmsInfoSet(bytes k256Pubkey); + event KmsAggregatedMrAdded(bytes32 mrAggregated); + event KmsAggregatedMrRemoved(bytes32 mrAggregated); + event KmsDeviceAdded(bytes32 deviceId); + event KmsDeviceRemoved(bytes32 deviceId); + event OsImageHashAdded(bytes32 osImageHash); + event OsImageHashRemoved(bytes32 osImageHash); + event GatewayAppIdSet(string gatewayAppId); + event AppImplementationSet(address implementation); + event AppDeployedViaFactory(address indexed appId, address indexed deployer); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + // Initialize the contract with the owner wallet address and optionally set DstackApp implementation + function initialize(address initialOwner, address _appImplementation) public initializer { + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + __ERC165_init(); + + // Set DstackApp implementation if provided + if (_appImplementation != address(0)) { + appImplementation = _appImplementation; + emit AppImplementationSet(_appImplementation); + } + } + + /** + * @dev See {IERC165-supportsInterface}. + * @notice Returns true if this contract implements the interface defined by interfaceId + * @param interfaceId The interface identifier, as specified in ERC-165 + * @return True if the contract implements `interfaceId` + */ + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return + interfaceId == 0x1e079198 || // IAppAuth + super.supportsInterface(interfaceId); + } + + // Function to authorize upgrades (required by UUPSUpgradeable) + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} + + // Function to set KMS information + function setKmsInfo(KmsInfo memory info) external onlyOwner { + kmsInfo = info; + emit KmsInfoSet(info.k256Pubkey); + } + + // Function to set KMS quote + function setKmsQuote(bytes memory quote) external onlyOwner { + kmsInfo.quote = quote; + } + + // Function to set KMS eventlog + function setKmsEventlog(bytes memory eventlog) external onlyOwner { + kmsInfo.eventlog = eventlog; + } + + // Function to set trusted Gateway App ID + function setGatewayAppId(string memory appId) external onlyOwner { + gatewayAppId = appId; + emit GatewayAppIdSet(appId); + } + + // Function to register an app + function registerApp(address appId) public { + require(appId != address(0), "Invalid app ID"); + registeredApps[appId] = true; + emit AppRegistered(appId); + } + + // Function to set DstackApp implementation contract address + function setAppImplementation(address _implementation) external onlyOwner { + require(_implementation != address(0), "Invalid implementation address"); + appImplementation = _implementation; + emit AppImplementationSet(_implementation); + } + + // Factory method: Deploy and register DstackApp in single transaction + function deployAndRegisterApp( + address initialOwner, + bool disableUpgrades, + bool requireTcbUpToDate, + bool allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) public returns (address appId) { + require(appImplementation != address(0), "DstackApp implementation not set"); + require(initialOwner != address(0), "Invalid owner address"); + + bytes memory initData = abi.encodeWithSelector( + bytes4(keccak256("initialize(address,bool,bool,bool,bytes32,bytes32)")), + initialOwner, + disableUpgrades, + requireTcbUpToDate, + allowAnyDevice, + initialDeviceId, + initialComposeHash + ); + + appId = address(new ERC1967Proxy(appImplementation, initData)); + registerApp(appId); + emit AppDeployedViaFactory(appId, msg.sender); + } + + // Backward compatible factory method for old SDK callers (pre TCB flag) + function deployAndRegisterApp( + address initialOwner, + bool disableUpgrades, + bool allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) external returns (address appId) { + return deployAndRegisterApp( + initialOwner, + disableUpgrades, + false, + allowAnyDevice, + initialDeviceId, + initialComposeHash + ); + } + + // Function to register an aggregated MR measurement + function addKmsAggregatedMr(bytes32 mrAggregated) external onlyOwner { + kmsAllowedAggregatedMrs[mrAggregated] = true; + emit KmsAggregatedMrAdded(mrAggregated); + } + + // Function to deregister an aggregated MR measurement + function removeKmsAggregatedMr(bytes32 mrAggregated) external onlyOwner { + kmsAllowedAggregatedMrs[mrAggregated] = false; + emit KmsAggregatedMrRemoved(mrAggregated); + } + + // Function to register a KMS device ID + function addKmsDevice(bytes32 deviceId) external onlyOwner { + kmsAllowedDeviceIds[deviceId] = true; + emit KmsDeviceAdded(deviceId); + } + + // Function to deregister a KMS device ID + function removeKmsDevice(bytes32 deviceId) external onlyOwner { + kmsAllowedDeviceIds[deviceId] = false; + emit KmsDeviceRemoved(deviceId); + } + + // Function to register an image measurement + function addOsImageHash(bytes32 osImageHash) external onlyOwner { + allowedOsImages[osImageHash] = true; + emit OsImageHashAdded(osImageHash); + } + + // Function to deregister an image measurement + function removeOsImageHash(bytes32 osImageHash) external onlyOwner { + allowedOsImages[osImageHash] = false; + emit OsImageHashRemoved(osImageHash); + } + + // Function to check if KMS is allowed to boot + function isKmsAllowed( + AppBootInfo calldata bootInfo + ) external view returns (bool isAllowed, string memory reason) { + // 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]) { + return (false, "OS image is not allowed"); + } + + // Check if the aggregated MR is allowed + if (!kmsAllowedAggregatedMrs[bootInfo.mrAggregated]) { + return (false, "Aggregated MR not allowed"); + } + + // Check if the KMS device ID is allowed + if (!kmsAllowedDeviceIds[bootInfo.deviceId]) { + return (false, "KMS is not allowed to boot on this device"); + } + + return (true, ""); + } + + // Function to check if an app is allowed to boot + function isAppAllowed( + AppBootInfo calldata bootInfo + ) external view override returns (bool isAllowed, string memory reason) { + // 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]) { + return (false, "OS image is not allowed"); + } + + // Check if the contract exists at the appId address + if (!isContract(bootInfo.appId)) { + return (false, "App not deployed or invalid address"); + } + + // Call the app's isAppAllowed function + return IAppAuth(bootInfo.appId).isAppAllowed(bootInfo); + } + + // Add storage gap for upgradeable contracts + uint256[50] private __gap; +} + +function isContract(address addr) view returns (bool){ + uint32 size; + assembly { + size := extcodesize(addr) + } + return (size > 0); +} diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/IAppAuth.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/IAppAuth.sol new file mode 100644 index 000000000..29eb6737a --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/IAppAuth.sol @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @title IAppAuth + * @notice Core interface for App Authentication contracts + * @dev This interface defines the core function for validating app boot information. + * Any contract implementing this interface should also implement ERC-165 to + * allow interface detection. + * + * Interface ID: 0x1e079198 + * + * This interface can be checked using: + * contract.supportsInterface(0x1e079198) + */ +interface IAppAuth is IERC165 { + /** + * @notice Information required to validate app boot authorization + * @param appId The unique identifier for the application + * @param composeHash Hash of the application composition/configuration + * @param instanceId Unique identifier for this specific app instance + * @param deviceId Unique identifier for the device/hardware + * @param mrAggregated Aggregated measurement register value + * @param mrSystem System measurement register value + * @param osImageHash Hash of the operating system image + * @param tcbStatus Trusted Computing Base status + * @param advisoryIds Array of security advisory identifiers + */ + struct AppBootInfo { + address appId; + bytes32 composeHash; + address instanceId; + bytes32 deviceId; + bytes32 mrAggregated; + bytes32 mrSystem; + bytes32 osImageHash; + string tcbStatus; + string[] advisoryIds; + } + + /** + * @notice Check if an application is allowed to boot with the given parameters + * @dev This is the core authorization function that validates all boot parameters + * @param bootInfo Struct containing all necessary boot validation information + * @return isAllowed True if the app is authorized to boot, false otherwise + * @return reason Human-readable reason for the decision (empty if allowed) + */ + function isAppAllowed( + AppBootInfo calldata bootInfo + ) external view returns (bool isAllowed, string memory reason); +} diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/IAppAuthBasicManagement.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/IAppAuthBasicManagement.sol new file mode 100644 index 000000000..0396b1d47 --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.5.9/IAppAuthBasicManagement.sol @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @title IAppAuthBasicManagement + * @notice Basic management interface for App Authentication contracts + * @dev This interface defines the standard functions that UI tools and other contracts + * can use to interact with App Auth contracts. Any contract implementing this + * interface should also implement ERC-165 to allow interface detection. + * + * Interface ID: 0x8fd37527 + * + * UI tools can check if a contract supports this interface by calling: + * contract.supportsInterface(type(IAppAuthBasicManagement).interfaceId) + */ +interface IAppAuthBasicManagement is IERC165 { + /// @notice Emitted when a new compose hash is added to the allowed list + /// @param composeHash The compose hash that was added + event ComposeHashAdded(bytes32 composeHash); + + /// @notice Emitted when a compose hash is removed from the allowed list + /// @param composeHash The compose hash that was removed + event ComposeHashRemoved(bytes32 composeHash); + + /// @notice Emitted when a new device ID is added to the allowed list + /// @param deviceId The device ID that was added + event DeviceAdded(bytes32 deviceId); + + /// @notice Emitted when a device ID is removed from the allowed list + /// @param deviceId The device ID that was removed + event DeviceRemoved(bytes32 deviceId); + + /** + * @notice Add a compose hash to the allowed list + * @dev MUST emit ComposeHashAdded event on success + * MUST revert if caller is not authorized + * @param composeHash The compose hash to add + */ + function addComposeHash(bytes32 composeHash) external; + + /** + * @notice Remove a compose hash from the allowed list + * @dev MUST emit ComposeHashRemoved event on success + * MUST revert if caller is not authorized + * @param composeHash The compose hash to remove + */ + function removeComposeHash(bytes32 composeHash) external; + + /** + * @notice Add a device ID to the allowed list + * @dev MUST emit DeviceAdded event on success + * MUST revert if caller is not authorized + * @param deviceId The device ID to add + */ + function addDevice(bytes32 deviceId) external; + + /** + * @notice Remove a device ID from the allowed list + * @dev MUST emit DeviceRemoved event on success + * MUST revert if caller is not authorized + * @param deviceId The device ID to remove + */ + function removeDevice(bytes32 deviceId) external; +} \ No newline at end of file diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/DstackAppV060.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/DstackAppV060.sol new file mode 100644 index 000000000..7d90f29ef --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/DstackAppV060.sol @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "./IAppAuth.sol"; +import "./IAppAuthBasicManagement.sol"; +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"; + +contract DstackApp is + Initializable, + Ownable2StepUpgradeable, + UUPSUpgradeable, + ERC165Upgradeable, + IAppAuth, + IAppAuthBasicManagement +{ + // Mapping of allowed compose hashes for this app + mapping(bytes32 => bool) public allowedComposeHashes; + + // State variable to track if upgrades are disabled + bool private _upgradesDisabled; + + // Whether allow any device to boot this app or only allow devices + bool public allowAnyDevice; + + // Mapping of allowed device IDs for this app + mapping(bytes32 => bool) public allowedDeviceIds; + + // Whether to require TCB status to be UpToDate + bool public requireTcbUpToDate; + + // Additional events specific to DstackApp + event UpgradesDisabled(); + event AllowAnyDeviceSet(bool allowAny); + event RequireTcbUpToDateSet(bool requireUpToDate); + /// @notice Additive audit event for reconstructing authorization policy. + event PolicyChanged(address indexed actor, bytes32 indexed policy, bytes32 indexed value, bool enabled); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + // Old initialize — preserved for upgrade compatibility + function initialize( + address initialOwner, + bool _disableUpgrades, + bool _allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) + public + initializer + { + _initializeCommon(initialOwner, _disableUpgrades, _allowAnyDevice, initialDeviceId, initialComposeHash); + } + + // New initialize — includes requireTcbUpToDate + function initialize( + address initialOwner, + bool _disableUpgrades, + bool _requireTcbUpToDate, + bool _allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) + public + initializer + { + requireTcbUpToDate = _requireTcbUpToDate; + _initializeCommon(initialOwner, _disableUpgrades, _allowAnyDevice, initialDeviceId, initialComposeHash); + } + + function _initializeCommon( + address initialOwner, + bool _disableUpgrades, + bool _allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) + internal + { + require(initialOwner != address(0), "invalid owner address"); + + _upgradesDisabled = _disableUpgrades; + allowAnyDevice = _allowAnyDevice; + + // Add initial device if provided + if (initialDeviceId != bytes32(0)) { + allowedDeviceIds[initialDeviceId] = true; + emit DeviceAdded(initialDeviceId); + _emitPolicy("device", initialDeviceId, true); + } + + // Add initial compose hash if provided + if (initialComposeHash != bytes32(0)) { + allowedComposeHashes[initialComposeHash] = true; + emit ComposeHashAdded(initialComposeHash); + _emitPolicy("compose-hash", initialComposeHash, true); + } + + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + __ERC165_init(); + } + + function version() public pure returns (uint256) { + return 2; + } + + /** + * @dev See {IERC165-supportsInterface}. + * @notice Returns true if this contract implements the interface defined by interfaceId + * @param interfaceId The interface identifier, as specified in ERC-165 + * @return True if the contract implements `interfaceId` + */ + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return interfaceId == 0x1e079198 // IAppAuth + || interfaceId == 0x8fd37527 // IAppAuthBasicManagement + || super.supportsInterface(interfaceId); + } + + // Function to authorize upgrades (required by UUPSUpgradeable) + function _authorizeUpgrade(address newImplementation) internal override onlyOwner { + require(!_upgradesDisabled, "Upgrades are permanently disabled"); + _emitPolicy("implementation-upgrade", bytes32(uint256(uint160(newImplementation))), true); + } + + function _emitPolicy(string memory policy, bytes32 value, bool enabled) internal { + emit PolicyChanged(msg.sender, keccak256(bytes(policy)), value, enabled); + } + + // Add a compose hash to allowed list + function addComposeHash(bytes32 composeHash) external onlyOwner { + require(composeHash != bytes32(0), "invalid compose hash"); + allowedComposeHashes[composeHash] = true; + emit ComposeHashAdded(composeHash); + _emitPolicy("compose-hash", 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); + } + + // 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); + } + + // Set whether TCB status must be UpToDate to boot this app + function setRequireTcbUpToDate(bool _requireUpToDate) external onlyOwner { + requireTcbUpToDate = _requireUpToDate; + emit RequireTcbUpToDateSet(_requireUpToDate); + _emitPolicy("require-tcb-up-to-date", bytes32(0), _requireUpToDate); + } + + // Add a device ID to allowed list + function addDevice(bytes32 deviceId) external onlyOwner { + require(deviceId != bytes32(0), "invalid device ID"); + allowedDeviceIds[deviceId] = true; + emit DeviceAdded(deviceId); + _emitPolicy("device", deviceId, true); + } + + // Remove a device ID from allowed list + function removeDevice(bytes32 deviceId) external onlyOwner { + allowedDeviceIds[deviceId] = false; + emit DeviceRemoved(deviceId); + _emitPolicy("device", deviceId, false); + } + + // Check if an app is allowed to boot + function isAppAllowed(IAppAuth.AppBootInfo calldata bootInfo) + external + view + override + returns (bool isAllowed, string memory reason) + { + // Optionally require TCB status to be up to date + if ( + requireTcbUpToDate + && keccak256(abi.encodePacked(bootInfo.tcbStatus)) != keccak256(abi.encodePacked("UpToDate")) + ) { + return (false, "TCB status is not up to date"); + } + + // Check if compose hash is allowed + if (!allowedComposeHashes[bootInfo.composeHash]) { + return (false, "Compose hash not allowed"); + } + + // Check if device is allowed (when device restriction is enabled) + if (!allowAnyDevice && !allowedDeviceIds[bootInfo.deviceId]) { + return (false, "Device not allowed"); + } + + return (true, ""); + } + + // Function to permanently disable upgrades + function disableUpgrades() external onlyOwner { + _upgradesDisabled = true; + emit UpgradesDisabled(); + _emitPolicy("upgrades-disabled", bytes32(0), true); + } + + // Add storage gap for upgradeable contracts + uint256[49] private __gap; +} diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/DstackKmsV060.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/DstackKmsV060.sol new file mode 100644 index 000000000..f79325b89 --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/DstackKmsV060.sol @@ -0,0 +1,318 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "./IAppAuth.sol"; +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/proxy/ERC1967/ERC1967Proxy.sol"; + +contract DstackKms is Initializable, Ownable2StepUpgradeable, UUPSUpgradeable, ERC165Upgradeable, IAppAuth { + // Struct for KMS information + struct KmsInfo { + bytes k256Pubkey; + bytes caPubkey; + bytes quote; + bytes eventlog; + } + + // KMS information + KmsInfo public kmsInfo; + + // The dstack-gateway App ID + /// @custom:oz-renamed-from tproxyAppId + string public gatewayAppId; + + // Mapping of registered apps + mapping(address => bool) public registeredApps; + + // Mapping of allowed aggregated MR measurements for running KMS + mapping(bytes32 => bool) public kmsAllowedAggregatedMrs; + + // Mapping of allowed KMS device IDs + mapping(bytes32 => bool) public kmsAllowedDeviceIds; + + // Mapping of allowed image measurements + mapping(bytes32 => bool) public allowedOsImages; + + // DstackApp implementation contract address for factory deployment + address public appImplementation; + + // Events + // appId is unindexed for backward compatibility with deployed log indexers. + // slither-disable-next-line unindexed-event-address + event AppRegistered(address appId); + event KmsInfoSet(bytes k256Pubkey); + event KmsAggregatedMrAdded(bytes32 mrAggregated); + event KmsAggregatedMrRemoved(bytes32 mrAggregated); + event KmsDeviceAdded(bytes32 deviceId); + event KmsDeviceRemoved(bytes32 deviceId); + event OsImageHashAdded(bytes32 osImageHash); + event OsImageHashRemoved(bytes32 osImageHash); + event GatewayAppIdSet(string gatewayAppId); + // slither-disable-next-line unindexed-event-address + event AppImplementationSet(address implementation); + event AppDeployedViaFactory(address indexed appId, address indexed deployer); + /// @notice Additive audit event for reconstructing authorization policy. + /// @dev `value` is the affected bytes32 value, address, or hash of dynamic public data. + event PolicyChanged(address indexed actor, bytes32 indexed policy, bytes32 indexed value, bool enabled); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + // Initialize the contract with the owner wallet address and optionally set DstackApp implementation + function initialize(address initialOwner, address _appImplementation) public initializer { + __Ownable_init(initialOwner); + __UUPSUpgradeable_init(); + __ERC165_init(); + + // Set DstackApp implementation if provided + if (_appImplementation != address(0)) { + appImplementation = _appImplementation; + emit AppImplementationSet(_appImplementation); + _emitPolicy("app-implementation", bytes32(uint256(uint160(_appImplementation))), true); + } + } + + /** + * @dev See {IERC165-supportsInterface}. + * @notice Returns true if this contract implements the interface defined by interfaceId + * @param interfaceId The interface identifier, as specified in ERC-165 + * @return True if the contract implements `interfaceId` + */ + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return interfaceId == 0x1e079198 // IAppAuth + || super.supportsInterface(interfaceId); + } + + // Function to authorize upgrades (required by UUPSUpgradeable) + function _authorizeUpgrade(address newImplementation) internal override onlyOwner { + _emitPolicy("implementation-upgrade", bytes32(uint256(uint160(newImplementation))), true); + } + + function _emitPolicy(string memory policy, bytes32 value, bool enabled) internal { + emit PolicyChanged(msg.sender, keccak256(bytes(policy)), value, enabled); + } + + // Function to set KMS information + function setKmsInfo(KmsInfo memory info) external onlyOwner { + kmsInfo = info; + emit KmsInfoSet(info.k256Pubkey); + _emitPolicy("kms-info", keccak256(info.k256Pubkey), true); + } + + // Function to set KMS quote + function setKmsQuote(bytes memory quote) external onlyOwner { + kmsInfo.quote = quote; + } + + // Function to set KMS eventlog + function setKmsEventlog(bytes memory eventlog) external onlyOwner { + kmsInfo.eventlog = eventlog; + } + + // Function to set trusted Gateway App ID + function setGatewayAppId(string memory appId) external onlyOwner { + gatewayAppId = appId; + emit GatewayAppIdSet(appId); + _emitPolicy("gateway-app-id", keccak256(bytes(appId)), true); + } + + /// @notice Register an app address as known to this KMS. + /// @dev Intentionally permissionless: any caller can mark any non-zero + /// address as registered. This is the factory hook used by + /// {deployAndRegisterApp}, and direct external registration is + /// also a supported use case (e.g. third parties bootstrapping + /// their own DstackApp under this KMS). Authorization is gated + /// downstream by the owner-controlled {allowedOsImages} whitelist + /// and by the registered app's own {IAppAuth-isAppAllowed}, so + /// registration alone confers no privilege. + function registerApp(address appId) public { + require(appId != address(0), "Invalid app ID"); + registeredApps[appId] = true; + emit AppRegistered(appId); + _emitPolicy("registered-app", bytes32(uint256(uint160(appId))), true); + } + + // Function to set DstackApp implementation contract address + function setAppImplementation(address _implementation) external onlyOwner { + require(_implementation != address(0), "Invalid implementation address"); + appImplementation = _implementation; + emit AppImplementationSet(_implementation); + _emitPolicy("app-implementation", bytes32(uint256(uint160(_implementation))), true); + } + + // Factory method: Deploy and register DstackApp in single transaction + function deployAndRegisterApp( + address initialOwner, + bool disableUpgrades, + bool requireTcbUpToDate, + bool allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) + public + returns (address appId) + { + require(appImplementation != address(0), "DstackApp implementation not set"); + require(initialOwner != address(0), "Invalid owner address"); + + bytes memory initData = abi.encodeWithSelector( + bytes4(keccak256("initialize(address,bool,bool,bool,bytes32,bytes32)")), + initialOwner, + disableUpgrades, + requireTcbUpToDate, + allowAnyDevice, + initialDeviceId, + initialComposeHash + ); + + // The ERC1967Proxy constructor delegatecalls into `appImplementation`'s + // initializer. The owner sets `appImplementation` via + // setAppImplementation; safety here rests on owner trust, not on a + // structural reentrancy impossibility — a malicious owner could in + // principle install an impl whose `initialize` reenters this contract. + // See docs/specification.md §1 for the owner trust scope and §6.1 for + // the malicious-registered-app threat model. + // slither-disable-next-line reentrancy-benign,reentrancy-events + appId = address(new ERC1967Proxy(appImplementation, initData)); + registerApp(appId); + emit AppDeployedViaFactory(appId, msg.sender); + } + + // Backward compatible factory method for old SDK callers (pre TCB flag) + function deployAndRegisterApp( + address initialOwner, + bool disableUpgrades, + bool allowAnyDevice, + bytes32 initialDeviceId, + bytes32 initialComposeHash + ) + external + returns (address appId) + { + return deployAndRegisterApp( + initialOwner, disableUpgrades, false, allowAnyDevice, initialDeviceId, initialComposeHash + ); + } + + // 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 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 to register a KMS device ID + function addKmsDevice(bytes32 deviceId) external onlyOwner { + kmsAllowedDeviceIds[deviceId] = true; + emit KmsDeviceAdded(deviceId); + _emitPolicy("kms-device", deviceId, true); + } + + // 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 to register an image measurement + function addOsImageHash(bytes32 osImageHash) external onlyOwner { + allowedOsImages[osImageHash] = true; + emit OsImageHashAdded(osImageHash); + _emitPolicy("os-image", osImageHash, true); + } + + // Function to deregister an image measurement + function removeOsImageHash(bytes32 osImageHash) external onlyOwner { + allowedOsImages[osImageHash] = false; + emit OsImageHashRemoved(osImageHash); + _emitPolicy("os-image", osImageHash, false); + } + + // Function to check if KMS is allowed to boot + function isKmsAllowed(AppBootInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason) { + // 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]) { + return (false, "OS image is not allowed"); + } + + // Check if the aggregated MR is allowed + if (!kmsAllowedAggregatedMrs[bootInfo.mrAggregated]) { + return (false, "Aggregated MR not allowed"); + } + + // Check if the KMS device ID is allowed + if (!kmsAllowedDeviceIds[bootInfo.deviceId]) { + return (false, "KMS is not allowed to boot on this device"); + } + + return (true, ""); + } + + // Function to check if an app is allowed to boot + function isAppAllowed(AppBootInfo calldata bootInfo) + external + view + override + returns (bool isAllowed, string memory reason) + { + // 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]) { + return (false, "OS image is not allowed"); + } + + // Check if the contract exists at the appId address + if (!isContract(bootInfo.appId)) { + return (false, "App not deployed or invalid address"); + } + + // 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. + // slither-disable-next-line unused-return + return IAppAuth(bootInfo.appId).isAppAllowed(bootInfo); + } + + // Add storage gap for upgradeable contracts + uint256[50] private __gap; +} + +function isContract(address addr) view returns (bool) { + uint32 size; + assembly { + size := extcodesize(addr) + } + return (size > 0); +} diff --git a/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/IAppAuth.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/IAppAuth.sol new file mode 100644 index 000000000..56b768971 --- /dev/null +++ b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/IAppAuth.sol @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: © 2025 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @title IAppAuth + * @notice Core interface for App Authentication contracts + * @dev This interface defines the core function for validating app boot information. + * Any contract implementing this interface should also implement ERC-165 to + * allow interface detection. + * + * Interface ID: 0x1e079198 + * + * This interface can be checked using: + * contract.supportsInterface(0x1e079198) + */ +interface IAppAuth is IERC165 { + /** + * @notice Information required to validate app boot authorization + * @param appId The unique identifier for the application + * @param composeHash Hash of the application composition/configuration + * @param instanceId Unique identifier for this specific app instance + * @param deviceId Unique identifier for the device/hardware + * @param mrAggregated Aggregated measurement register value + * @param mrSystem System measurement register value + * @param osImageHash Hash of the operating system image + * @param tcbStatus Trusted Computing Base status + * @param advisoryIds Array of security advisory identifiers + */ + struct AppBootInfo { + address appId; + bytes32 composeHash; + address instanceId; + bytes32 deviceId; + bytes32 mrAggregated; + bytes32 mrSystem; + bytes32 osImageHash; + string tcbStatus; + string[] advisoryIds; + } + + /** + * @notice Check if an application is allowed to boot with the given parameters + * @dev This is the core authorization function that validates all boot parameters + * @param bootInfo Struct containing all necessary boot validation information + * @return isAllowed True if the app is authorized to boot, false otherwise + * @return reason Human-readable reason for the decision (empty if allowed) + */ + function isAppAllowed(AppBootInfo calldata bootInfo) external view returns (bool isAllowed, string memory reason); +} diff --git a/dstack/kms/auth-eth/contracts/IAppAuthBasicManagement.sol b/dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/IAppAuthBasicManagement.sol similarity index 100% rename from dstack/kms/auth-eth/contracts/IAppAuthBasicManagement.sol rename to dstack/kms/auth-eth/contracts/test-utils/reference/v0.6.0/IAppAuthBasicManagement.sol diff --git a/dstack/kms/auth-eth/docs/formal-verification.md b/dstack/kms/auth-eth/docs/formal-verification.md index 67d776c4a..636387b27 100644 --- a/dstack/kms/auth-eth/docs/formal-verification.md +++ b/dstack/kms/auth-eth/docs/formal-verification.md @@ -21,7 +21,7 @@ correctness matters more than gas. - `contracts/DstackApp.sol` (209 LOC) — per-app boot authorization - `contracts/DstackKms.sol` (276 LOC) — KMS whitelist + factory -- `contracts/IAppAuth.sol`, `contracts/IAppAuthBasicManagement.sol` — interfaces +- `contracts/IAppAuth.sol`, `contracts/IAppEpochAuth.sol`, `contracts/IAppEpochManagement.sol` — interfaces ## Layer 0 — Slither (static analysis) @@ -84,8 +84,8 @@ reuse the existing `.t.sol` scaffolding and OZ-foundry-upgrades plugin. ### Setup - [x] `pipx install halmos` documented in `README.md` -- [ ] No CI integration (intentional — see "What's intentionally not in - scope" below) +- [x] Runs in CI (`.github/workflows/halmos.yml`) when `dstack/kms/auth-eth` + changes, with pinned Halmos and Forge versions ### What we verify @@ -120,11 +120,113 @@ says `pre: msg.sender == owner()`, we trust the OZ modifier. init, both `initialize` overloads revert for any inputs. Verifies INV-3. +`test/DstackAppEpoch.symbolic.t.sol` (app epochs; setUp advances to epoch 2 +and admits one app per epoch, then one symbolic owner write where noted): +- [x] `check_AppEpoch_StepMonotonic` — no call to any enumerated mutating + function, by any caller, decreases `currentAppEpoch`; it moves only by + one, through the owner's `advanceAppEpoch(current)`. +- [x] `check_PolicyWrite_RejectsEpochPastNext` — every `*AtEpoch` setter + reverts for an epoch above the next one, and every `*AtEpochs` setter + for a range ending above it, for any caller; the owner may prepare the + next epoch's row before advancing. +- [x] `check_AuthorizeApp_RejectsInactiveEpochAndForeignOperation` — an + epoch above the current one or a non-app operation is never authorized. +- [x] `check_IsAppAllowed_RefusesAtNonzeroEpoch` — once the app epoch has + advanced, the legacy `isAppAllowed` refuses, since its callers derive + epoch-0 keys. +- [x] `check_IsAppAllowed_EqualsEpochZeroDecisionBeforeAdvance` — at app + epoch 0, with the next row prepared and any one owner write, the legacy + `isAppAllowed` returns exactly the `authorizeApp` result at epoch 0. +- [x] `check_PolicyWrite_PreservesOtherEpochDecisions` — a write never + changes an authorization decision at an epoch outside the rows it names + (one epoch, or a symbolic range), including epoch 0's legacy fields. +- [x] `check_AuthorizeApp_MatchesPolicy` — for both app operations, + `authorizeApp` allows exactly when the epoch is active, the TCB + requirement holds, and that epoch admits the compose hash and device. +- [x] `check_PolicyWrite_TakesEffect` — each `*AtEpoch` setter on an epoch + up to the next one, and each `*AtEpochs` setter on every row of a range + ending at most there, either sets the value it was given or reverts on + a zero ID. +- [x] `check_FullRangeRemoval_RevokesAtEveryAppEpoch` — after removing a + compose hash, or a device and the any-device flag, from rows 0 through + the next epoch, no app epoch authorizes it, for either app operation. + +Mutation-tested: requiring both the any-device flag and a listed device, +ignoring the value of a per-epoch `allowAnyDevice` write, dropping a setter's +epoch bound or widening it past the next epoch, aliasing epoch 1 onto the +legacy fields, letting `isAppAllowed` decide at a nonzero epoch or read the +prepared next row, a permissionless +`advanceAppEpoch` and dropping its stale-transaction guard each produce a +counterexample. Dropping the inactive-epoch check in `_authorizeApp` does +not: policy for an inactive epoch cannot be written, so that check is +defense in depth. + +`test/DstackKmsEpoch.symbolic.t.sol` (root epochs; setUp registers epochs +0-2, activates 1 so that 2 is pending, admits one KMS identity and OS image +per epoch, and deploys an app at app epoch 1): +- [x] `check_RootRegistry_StepInvariant` — after any single call to any + enumerated mutating function, by any caller with symbolic args: the + current epoch stays registered and moves only by one, through the + owner's activation naming its chain hash; the registry changes by at + most one entry at its end, and only the owner's pop of a pending + epoch naming its chain hash removes one; existing entries keep their + keys and hashes; every chain hash commits to its keys and the previous + hash; `kmsInfo` holds the current keys without legacy evidence. +- [x] `check_RegisterRootEpoch_AcceptsOnlyCanonicalEncodings` — registration + succeeds exactly for a 91-byte uncompressed P-256 SPKI and a 33-byte + compressed secp256k1 key. +- [x] `check_Authorize_EnforcesEpochBounds` — a wrong-kind operation, an app + or `kms_serve` root epoch above the current one, another KMS root epoch + above the latest registered one, or an app epoch above the app's current one is never + authorized; every result reports the current root epoch and hash. +- [x] `check_Authorize_OnlyBootstrapBeforeRegistration` — with no root epoch + registered, only `kms_bootstrap` can be authorized. +- [x] `check_AuthorizeKms_MatchesEpochPolicy` — after any one owner write, + KMS authorization allows exactly when the selected root epoch is + registered (current at most for `kms_serve`), the TCB is up to date, and that epoch's row admits the OS + image, aggregated MR and device. +- [x] `check_AuthorizeApp_MatchesKmsAndAppPolicy` — app authorization allows + exactly when both epochs are active, the selected root epoch admits + the OS image, and the app's own `authorizeApp` admits it. +- [x] `check_KmsPolicyWrite_PreservesOtherEpochDecisions` — a KMS policy + write never changes a KMS decision at a root epoch outside the rows it + names (one epoch, or a symbolic range). +- [x] `check_FullRangeRemoval_RevokesAtEveryEpoch` — after removing an OS + image, aggregated MR or KMS device from rows 0 through the latest, no + request carrying it is authorized at any root or app epoch, for any KMS + operation, nor for app operations in the OS image case. +- [x] `check_PopThenRegister_StartsWithEmptyRows` — popping the pending + epoch and registering the number again, with the same keys or others, + leaves all three of its rows empty. +- [x] `check_EpochZeroRows_AreTheLegacyMappings` — after any one owner + write, the v0.5.x getters and the epoch 0 getters agree. +- [x] `check_AuthorizeApp_LegacyPolicyOnlyAtAppEpochZero` — an app policy + without `IAppEpochAuth`, like v0.5.x and v0.6.0 `DstackApp`, is + authorized exactly when the KMS checks pass at the selected root epoch, + no nonzero app epoch is requested, and its `isAppAllowed` admits it; + its app epoch is reported as 0. +- [x] `check_LegacyEntryPoints_RefuseOnceRotated` — once a later root epoch + is current, `isKmsAllowed` and `isAppAllowed` refuse. +- [x] `check_LegacyKmsCheck_ReadsEpochZeroBeforeRotation` — before any + rotation, with the next epoch registered and prepared, `isKmsAllowed` + decides as `kms_serve` at root epoch 0. + +Mutation-tested: a chain hash that ignores the previous epoch, activation +that leaves `kmsInfo` stale, a pop that ignores the chain hash, app requests +selecting a pending root epoch, KMS requests limited to the current epoch, +`isKmsAllowed` reading the pending epoch or still deciding after a rotation, accepting an `0x04` k256 tag, an +OS image row aliasing epoch 1 onto the legacy row, an app check skipping the +OS image, and non-bootstrap operations before registration each produce a +counterexample. + +The bounds test found that wrong-kind rejections reported root epoch +`(0, 0x0)`, contrary to the KMS README; fixed in #1538. + Single-call (`test/DstackKms.symbolic.t.sol`): - [x] `check_RegisterApp_AnyCallerCanRegisterNonZeroAddress` + `_RejectsZeroAddress` — codifies the permissionless-by-design behavior; see "Findings" - [x] `check_IsAppAllowed_RejectsUnregisteredApp` / `_RejectsUnknownOsImage` - — fully symbolic `AppBootInfo`; the failing gate produces the + — fully symbolic `AppInfo`; the failing gate produces the right rejection reason without delegating - [x] `check_IsAppAllowed_DelegatesFaithfully` — when both KMS gates pass, the outer return equals the registered `IAppAuth`'s return. @@ -235,10 +337,8 @@ If we revisit, target the same invariants as Layer 2 but add: ## What's intentionally not in scope -- **CI gating of Slither / Halmos.** Symbolic execution is slow, solver- - version-sensitive, and produces non-actionable noise as a PR gate. - Run locally during development; treat these as design checks rather - than blocking lints. +- **CI gating of Slither.** It produces non-actionable noise as a PR gate; + run it locally during development. - **Echidna** — overlaps with Foundry's built-in fuzzer (already runs 10k iterations under `FOUNDRY_PROFILE=ci` in `.github/workflows/foundry-test.yml`). - **Manticore / Mythril** — bytecode-level tools, slow, awkward with our diff --git a/dstack/kms/auth-eth/docs/specification.md b/dstack/kms/auth-eth/docs/specification.md index 95a72e506..b99fe2e37 100644 --- a/dstack/kms/auth-eth/docs/specification.md +++ b/dstack/kms/auth-eth/docs/specification.md @@ -52,12 +52,12 @@ return-value spoofing are still possible — see §6. | Slot | Name | Type | Writable by | Notes | |---|---|---|---|---| -| 0–3 | `kmsInfo` | `KmsInfo` (struct, 4 fields) | `setKmsInfo`, `setKmsQuote`, `setKmsEventlog` (owner) | | +| 0–3 | `kmsInfo` | `KmsInfo` (struct, 4 fields) | `registerRootEpoch` (epoch 0), `activateRootEpoch` (owner) | Preserved for proxy upgrades and legacy readers; quote and event log are cleared on activation | | 4 | `gatewayAppId` | `string` | `setGatewayAppId` (owner) | Renamed from `tproxyAppId`; carries `@custom:oz-renamed-from` | | 5 | `registeredApps` | `mapping(address => bool)` | `registerApp` (anyone), `deployAndRegisterApp` (anyone) | Permissionless — see §6.1 | -| 6 | `kmsAllowedAggregatedMrs` | `mapping(bytes32 => bool)` | `addKmsAggregatedMr`, `removeKmsAggregatedMr` (owner) | | -| 7 | `kmsAllowedDeviceIds` | `mapping(bytes32 => bool)` | `addKmsDevice`, `removeKmsDevice` (owner) | | -| 8 | `allowedOsImages` | `mapping(bytes32 => bool)` | `addOsImageHash`, `removeOsImageHash` (owner) | | +| 6 | `kmsAllowedAggregatedMrs` | `mapping(bytes32 => bool)` | `addKmsAggregatedMrAtEpoch(s)`, `removeKmsAggregatedMrAtEpoch(s)` at epoch 0 (owner) | | +| 7 | `kmsAllowedDeviceIds` | `mapping(bytes32 => bool)` | `addKmsDeviceAtEpoch(s)`, `removeKmsDeviceAtEpoch(s)` at epoch 0 (owner) | | +| 8 | `allowedOsImages` | `mapping(bytes32 => bool)` | `addOsImageHashAtEpoch(s)`, `removeOsImageHashAtEpoch(s)` at epoch 0 (owner) | | | 9 | `appImplementation` | `address` | `setAppImplementation` (owner) | Used by `deployAndRegisterApp` | | 10 | `__gap[50]` | `uint256[50]` | (none — must always read zero) | Reserved | @@ -69,9 +69,9 @@ Plus inherited storage in ERC-7201 namespaces: `OwnableUpgradeable`, | Slot | Name | Type | Writable by | |---|---|---|---| -| 0 | `allowedComposeHashes` | `mapping(bytes32 => bool)` | `addComposeHash`, `removeComposeHash` (owner); 5/6-arg initializer | -| 1 | `_upgradesDisabled` + `allowAnyDevice` (packed) | `bool` + `bool` | `disableUpgrades` (owner, monotonic); `setAllowAnyDevice` (owner); initializer | -| 2 | `allowedDeviceIds` | `mapping(bytes32 => bool)` | `addDevice`, `removeDevice` (owner); initializer | +| 0 | `allowedComposeHashes` | `mapping(bytes32 => bool)` | `addComposeHashAtEpoch(s)`, `removeComposeHashAtEpoch(s)` at epoch 0 (owner); 5/6-arg initializer | +| 1 | `_upgradesDisabled` + `allowAnyDevice` (packed) | `bool` + `bool` | `disableUpgrades` (owner, monotonic); `setAllowAnyDeviceAtEpoch(s)` at epoch 0 (owner); initializer | +| 2 | `allowedDeviceIds` | `mapping(bytes32 => bool)` | `addDeviceAtEpoch(s)`, `removeDeviceAtEpoch(s)` at epoch 0 (owner); initializer | | 3 | `requireTcbUpToDate` | `bool` | `setRequireTcbUpToDate` (owner); 6-arg initializer only | | 4 | `__gap[49]` | `uint256[49]` | (none) | @@ -125,7 +125,7 @@ Signature: `deployAndRegisterApp(address initialOwner, bool disableUpgrades, boo - Semantics: equivalent to the 6-arg overload with `requireTcbUpToDate = false`. - (verified: `DstackKmsSymbolicTest.check_DeployAndRegisterApp5Arg_DefaultsTcbToFalse`) -### 3.5 `DstackKms.isAppAllowed(AppBootInfo b) external view returns (bool, string)` +### 3.5 `DstackKms.isAppAllowed(AppInfo b) external view returns (bool, string)` Decision (in order): @@ -147,7 +147,7 @@ Decision (in order): behavior of an adversarial registered app; those propagate to the outer call by EVM semantics (§5.1). -### 3.6 `DstackKms.isKmsAllowed(AppBootInfo b) external view returns (bool, string)` +### 3.6 `DstackKms.isKmsAllowed(AppInfo b) external view returns (bool, string)` Decision (in order): @@ -167,9 +167,15 @@ Decision (in order): ### 3.7 Owner-only KMS mutations (uniform pattern) -For each of `setKmsInfo`, `setKmsQuote`, `setKmsEventlog`, `setGatewayAppId`, -`setAppImplementation`, `addKmsAggregatedMr`, `removeKmsAggregatedMr`, -`addKmsDevice`, `removeKmsDevice`, `addOsImageHash`, `removeOsImageHash`: +The legacy `setKmsInfo`, `setKmsQuote`, and `setKmsEventlog` entry points are +removed: calls always revert, including before root epoch registration. Initial +setup uses `registerRootEpoch`. On an upgraded proxy with existing legacy roots, +epoch 0 must match the stored `kmsInfo` keys; the upgrade preserves that storage. + +For each of `setGatewayAppId`, +`setAppImplementation`, and the `*AtEpoch` and `*AtEpochs` forms of +`addKmsAggregatedMr`, `removeKmsAggregatedMr`, `addKmsDevice`, `removeKmsDevice`, +`addOsImageHash`, `removeOsImageHash`: - **pre**: `msg.sender == owner()`. - **frame**: every storage cell except the one mapped to the operation. @@ -195,7 +201,7 @@ For each of `setKmsInfo`, `setKmsQuote`, `setKmsEventlog`, `setGatewayAppId`, - (verified: `DstackAppSymbolicTest.check_Initialize5Arg_DefaultsTcbToFalse`, `check_Initialize6Arg_HonorsTcbFlag`) -### 3.9 `DstackApp.isAppAllowed(AppBootInfo b) external view returns (bool, string)` +### 3.9 `DstackApp.isAppAllowed(AppInfo b) external view returns (bool, string)` Decision (in order, all evaluated under `b.appId == address(this)` by convention): @@ -231,8 +237,9 @@ byte sequence — no case variations, no trailing nulls, no whitespace. ### 3.11 Owner-only App mutations -For each of `addComposeHash`, `removeComposeHash`, `addDevice`, `removeDevice`, -`setAllowAnyDevice`, `setRequireTcbUpToDate`, `disableUpgrades`: +For each of the `*AtEpoch` and `*AtEpochs` forms of `addComposeHash`, +`removeComposeHash`, `addDevice`, `removeDevice` and `setAllowAnyDevice`, and +`setRequireTcbUpToDate`, `disableUpgrades`: - **pre**: `msg.sender == owner()`. - **frame**: every storage cell except the one mapped to the operation. @@ -403,7 +410,7 @@ formal audit: apps have been deployed via the factory, the older proxies stay on the older impl. Is that intended (versioning) or should the KMS track which impl was current at deploy time? -3. **Removing an OS image.** `removeOsImageHash` does not retroactively +3. **Removing an OS image.** `removeOsImageHashAtEpochs` does not retroactively un-authorize already-running apps that booted under that image. Is that intended (revocation requires explicit downstream action) or should the spec require it? diff --git a/dstack/kms/auth-eth/foundry.toml b/dstack/kms/auth-eth/foundry.toml index 05f06a0b2..a300d54c1 100644 --- a/dstack/kms/auth-eth/foundry.toml +++ b/dstack/kms/auth-eth/foundry.toml @@ -30,6 +30,8 @@ extra_output = ["storageLayout"] fuzz = { runs = 10_000 } [fmt] +# Released contract sources kept verbatim as upgrade references. +ignore = ["contracts/test-utils/reference/**"] bracket_spacing = true int_types = "long" line_length = 120 diff --git a/dstack/kms/auth-eth-bun/index.test.ts b/dstack/kms/auth-eth/index.test.ts similarity index 62% rename from dstack/kms/auth-eth-bun/index.test.ts rename to dstack/kms/auth-eth/index.test.ts index 4d8a85f45..6eff517e3 100644 --- a/dstack/kms/auth-eth-bun/index.test.ts +++ b/dstack/kms/auth-eth/index.test.ts @@ -4,17 +4,19 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; import openApiSpec from './openapi.json'; +import { keccak256, toBytes } from 'viem'; // Mock viem const mockReadContract = vi.fn(); const mockGetChainId = vi.fn(); -const mockGetBlockNumber = vi.fn(); +const mockGetBlock = vi.fn(); -vi.mock('viem', () => ({ +vi.mock('viem', async (importOriginal) => ({ + ...await importOriginal(), createPublicClient: vi.fn(() => ({ readContract: mockReadContract, getChainId: mockGetChainId, - getBlockNumber: mockGetBlockNumber, + getBlock: mockGetBlock, })), http: vi.fn(), getContract: vi.fn(), @@ -40,7 +42,7 @@ beforeEach(() => { // Reset mocks before each test vi.clearAllMocks(); mockGetChainId.mockResolvedValue(1337); - mockGetBlockNumber.mockResolvedValue(100n); + mockGetBlock.mockResolvedValue({ number: 100n }); }); describe('server runtime config', () => { @@ -58,6 +60,7 @@ describe('API Compatibility Tests', () => { it('should return system info matching OpenAPI spec', async () => { // Mock contract calls mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'gatewayAppId') { return '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; } @@ -119,6 +122,7 @@ describe('API Compatibility Tests', () => { it('should validate app boot with required fields only', async () => { // Mock successful contract response mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') { return [true, 'success']; } @@ -160,6 +164,7 @@ describe('API Compatibility Tests', () => { }; mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') { return [true, 'success with full info']; } @@ -230,6 +235,126 @@ describe('API Compatibility Tests', () => { }); }); + describe('root epochs', () => { + const validBootInfo = { + mrAggregated: '0x' + '12'.repeat(32), + osImageHash: '0x' + 'ab'.repeat(32), + appId: '0x' + '11'.repeat(20), + composeHash: '0x' + '22'.repeat(32), + instanceId: '0x' + '33'.repeat(20), + deviceId: '0x' + '44'.repeat(32), + }; + + const hash = '0x' + '11'.repeat(32); + const mockChain = (currentRootEpoch: () => unknown, rootEpochCount = () => 2n) => + mockReadContract.mockImplementation((params) => { + switch (params.functionName) { + case 'isAppAllowed': return [true, '']; + case 'isKmsAllowed': return [true, '']; + case 'gatewayAppId': return '0xgateway'; + case 'appImplementation': return '0x9876543210987654321098765432109876543210'; + case 'rootEpochCount': return rootEpochCount(); + case 'currentRootEpoch': return currentRootEpoch(); + } + }); + const boot = async (path: string, fields: object) => { + const response = await appFetch(new Request(`http://localhost:3001/bootAuth/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...validBootInfo, ...fields }), + })); + return response.json(); + }; + + it('allows only a KMS serving the current root epoch', async () => { + mockChain(() => [1n, hash]); + + expect((await boot('app', { rootEpoch: 1, rootEpochHash: hash })).isAllowed).toBe(true); + const stale = await boot('app', { rootEpoch: 0, rootEpochHash: hash }); + expect(stale.isAllowed).toBe(false); + expect(stale.reason).toMatch(/1 is current/); + const forked = await boot('app', { rootEpoch: 1, rootEpochHash: '0x' + '22'.repeat(32) }); + expect(forked.reason).toMatch(/not the registered/); + // A KMS predating root epochs, and its apps, are cut off. + expect((await boot('app', {})).reason).toMatch(/reports none/); + 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 }); + }); + + it('supports an upgraded empty registry without querying the current epoch', async () => { + const current = vi.fn(() => { throw new Error('no root epoch registered'); }); + mockChain(current, () => 0n); + + expect((await boot('app', {})).isAllowed).toBe(true); + expect((await boot('kms', {})).isAllowed).toBe(true); + expect((await boot('app', { rootEpoch: 0, rootEpochHash: hash })).isAllowed).toBe(true); + 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'); + expect(current).not.toHaveBeenCalled(); + }); + + it('serves an old KMS, and its apps, while epoch 0 is current', async () => { + mockChain(() => [0n, hash], () => 1n); + expect((await boot('app', {})).isAllowed).toBe(true); + expect((await boot('kms', {})).isAllowed).toBe(true); + }); + + it.each(['app', 'info'])('reads the registry at the same finalized block for %s', async (endpoint) => { + mockChain(() => [1n, hash]); + if (endpoint === 'app') { + expect((await boot('app', { rootEpoch: 1, rootEpochHash: hash })).isAllowed).toBe(true); + } else { + const response = await appFetch(new Request('http://localhost:3001/')); + 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']); + expect(calls.every(({ blockNumber }) => blockNumber === 98n)).toBe(true); + expect(mockGetBlock).toHaveBeenCalledTimes(1); + }); + + describe.each(['rootEpochCount', 'currentRootEpoch'])('%s failures', (getter) => { + it.each([ + ['missing getter', { name: 'ContractFunctionZeroDataError' }], + ['revert', { name: 'ContractFunctionRevertedError' }], + ['RPC failure', new Error('RPC unavailable')], + ])('fails closed on %s', async (_name, cause) => { + const failure = () => { throw Object.assign(new Error('query failed'), { cause }); }; + mockChain(getter === 'currentRootEpoch' ? failure : () => [1n, hash], + getter === 'rootEpochCount' ? failure : () => 2n); + + for (const path of ['app', 'kms']) { + expect(await boot(path, {})).toEqual({ + isAllowed: false, reason: 'authorization backend unavailable', gatewayAppId: '', + }); + expect((await boot(path, { rootEpoch: 0, rootEpochHash: hash })).isAllowed).toBe(false); + } + const info = await appFetch(new Request('http://localhost:3001/')); + expect(info.status).toBe(500); + }); + + it.each(['0x', '0x1234'] as const)('fails closed on malformed ABI data %s', async (data) => { + const { decodeFunctionResult } = await vi.importActual('viem'); + mockReadContract.mockImplementation((params) => { + if (params.functionName === getter) return decodeFunctionResult({ ...params, data }); + if (params.functionName === 'rootEpochCount') return 2n; + if (params.functionName === 'isAppAllowed') return [true, '']; + if (params.functionName === 'gatewayAppId') return 'gateway-app'; + if (params.functionName === 'appImplementation') return '0x' + '11'.repeat(20); + throw new Error(`unexpected function ${params.functionName}`); + }); + expect((await boot('app', {})).isAllowed).toBe(false); + const info = await appFetch(new Request('http://localhost:3001/')); + expect(info.status).toBe(500); + }); + }); + }); + describe('POST /bootAuth/kms', () => { const validBootInfo = { mrAggregated: '0x1234567890123456789012345678901234567890123456789012345678901234', @@ -243,6 +368,7 @@ describe('API Compatibility Tests', () => { it('should validate KMS boot successfully', async () => { // Mock successful contract response mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isKmsAllowed') { return [true, 'KMS allowed']; } @@ -269,6 +395,7 @@ describe('API Compatibility Tests', () => { it('should handle KMS rejection', async () => { mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isKmsAllowed') { return [false, 'KMS not authorized']; } @@ -399,6 +526,7 @@ describe('Hex Decoding Compatibility', () => { }; mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') { // Verify that hex values are properly formatted const [bootInfoStruct] = params.args; @@ -444,6 +572,7 @@ describe('Authorization freshness and domain binding', () => { let decisions = 0; mockReadContract.mockImplementation((params) => { expect(params.address).toBe('0x1234567890123456789012345678901234567890'); + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') { decisions += 1; return decisions === 1 ? [true, 'initial allow'] : [false, 'policy changed']; @@ -463,6 +592,7 @@ describe('Authorization freshness and domain binding', () => { it('binds changed measurements and identities into distinct contract arguments', async () => { const calls: unknown[] = []; mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') { calls.push(params.args[0]); return [true, 'allowed']; @@ -490,6 +620,7 @@ describe('Authorization freshness and domain binding', () => { }); mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') return [true, 'recovered']; if (params.functionName === 'gatewayAppId') return 'gateway-app'; throw new Error(`unexpected function ${params.functionName}`); @@ -517,9 +648,10 @@ describe('Ethereum finalized snapshot authorization', () => { })); it('reads the decision and gateway identity from one confirmation-depth snapshot', async () => { - mockGetBlockNumber.mockResolvedValue(100n); + mockGetBlock.mockResolvedValue({ number: 100n }); mockReadContract.mockImplementation((params) => { expect(params.blockNumber).toBe(98n); + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') return [true, 'finalized allow']; if (params.functionName === 'gatewayAppId') return 'gateway-app'; throw new Error(`unexpected function ${params.functionName}`); @@ -527,14 +659,42 @@ describe('Ethereum finalized snapshot authorization', () => { const response = await authorize(); expect(await response.json()).toMatchObject({ isAllowed: true, reason: 'finalized allow' }); - expect(mockGetBlockNumber).toHaveBeenCalledTimes(1); + expect(mockGetBlock).toHaveBeenCalledTimes(1); + expect(mockGetBlock).toHaveBeenCalledWith({ blockTag: 'finalized' }); + }); + + it('probes the block tag at startup and names the fix when the RPC cannot serve it', async () => { + const { EthereumBackend } = await import('./index.ts'); + const getBlock = vi.fn().mockResolvedValue({ number: 5n }); + const client = { getChainId: async () => 1337, getBlock }; + const backend = new EthereumBackend(client as never, '0x' + '11'.repeat(20), 1337, 'safe', 0n); + await expect(backend.probeBlockTag()).resolves.toBeUndefined(); + expect(getBlock).toHaveBeenCalledWith({ blockTag: 'safe' }); + getBlock.mockRejectedValue(new Error('invalid block tag')); + await expect(backend.probeBlockTag()).rejects.toThrow( + /^ETH_BLOCK_TAG=safe is not supported by the RPC \(.+\); OP-stack via Helios requires latest$/, + ); + // Behind the confirmation depth counts as unavailable too. + getBlock.mockResolvedValue({ number: 1n }); + const deep = new EthereumBackend(client as never, '0x' + '11'.repeat(20), 1337, 'finalized', 2n); + await expect(deep.probeBlockTag()).rejects.toThrow(/ETH_BLOCK_TAG=finalized/); + }); + + it('accepts only the supported block tags, defaulting to finalized', async () => { + const { parseBlockTag } = await import('./index.ts'); + expect(parseBlockTag(undefined)).toBe('finalized'); + expect(parseBlockTag('')).toBe('finalized'); + for (const tag of ['latest', 'safe', 'finalized']) expect(parseBlockTag(tag)).toBe(tag); + expect(() => parseBlockTag('pending')).toThrow(/ETH_BLOCK_TAG/); + expect(() => parseBlockTag('0x10')).toThrow(/ETH_BLOCK_TAG/); }); it('re-evaluates the canonical finalized snapshot after a short reorg', async () => { - mockGetBlockNumber.mockResolvedValueOnce(100n).mockResolvedValueOnce(101n); + mockGetBlock.mockResolvedValueOnce({ number: 100n }).mockResolvedValueOnce({ number: 101n }); let decisions = 0; const observedBlocks: bigint[] = []; mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') { observedBlocks.push(params.blockNumber); decisions += 1; @@ -553,8 +713,9 @@ describe('Ethereum finalized snapshot authorization', () => { it.each([ ['wrong chain', () => mockGetChainId.mockResolvedValue(1)], - ['stale head', () => mockGetBlockNumber.mockResolvedValue(1n)], - ['head timeout', () => mockGetBlockNumber.mockRejectedValue(new Error('timeout'))], + ['stale head', () => mockGetBlock.mockResolvedValue({ number: 1n })], + ['head timeout', () => mockGetBlock.mockRejectedValue(new Error('timeout'))], + ['unsupported block tag', () => mockGetBlock.mockRejectedValue(new Error('invalid block tag'))], ])('fails closed for %s and recovers without retained decisions', async (_name, inject) => { inject(); const failed = await authorize(); @@ -565,8 +726,9 @@ describe('Ethereum finalized snapshot authorization', () => { }); mockGetChainId.mockResolvedValue(1337); - mockGetBlockNumber.mockResolvedValue(102n); + mockGetBlock.mockResolvedValue({ number: 102n }); mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isAppAllowed') return [true, 'recovered']; if (params.functionName === 'gatewayAppId') return 'gateway-app'; throw new Error(`unexpected function ${params.functionName}`); @@ -607,6 +769,7 @@ describe('dstack-kms 0.6 bootAuth payload', () => { beforeEach(() => { mockReadContract.mockImplementation((params) => { + if (params.functionName === 'rootEpochCount') return 0n; if (params.functionName === 'isKmsAllowed' || params.functionName === 'isAppAllowed') { return [true, '']; } @@ -700,3 +863,115 @@ describe('request hardening', () => { expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN'); }); }); + + +describe('GET /app/:appId', () => { + it('reports an app epoch only when the contract can vouch for it', async () => { + const url = `http://localhost/app/0x${'11'.repeat(20)}`; + mockReadContract.mockRejectedValueOnce(new Error('execution reverted: App not registered')); + expect((await appFetch(new Request(url))).status).toBe(503); + mockReadContract.mockResolvedValueOnce(0n); + const legacy = await appFetch(new Request(url)); + expect(legacy.status).toBe(200); + expect(await legacy.json()).toEqual({ currentAppEpoch: 0 }); + }); +}); + +describe('POST /authorizeApp', () => { + const appInfo = { + appId: '0x' + '11'.repeat(20), composeHash: '0x' + '22'.repeat(32), + instanceId: '', deviceId: '0x' + '33'.repeat(32), + mrAggregated: '0x' + '44'.repeat(32), mrSystem: '0x' + '55'.repeat(32), + osImageHash: '0x' + '66'.repeat(32), tcbStatus: 'UpToDate', advisoryIds: [], + }; + const authorize = (requestedRootEpoch?: number) => appFetch(new Request('http://localhost/authorizeApp', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', requestedRootEpoch }), + })); + + it('reads the complete result with one contract call at configured finality', async () => { + const result = { + authorized: false, rejectionReason: 'App not registered', gatewayAppId: 'gateway', + currentAppEpoch: 0n, currentRootEpoch: 2n, currentRootEpochHash: '0x' + '77'.repeat(32), + latestRootEpoch: 3n, latestRootEpochHash: '0x' + '88'.repeat(32), + }; + mockReadContract.mockResolvedValueOnce(result); + const response = await authorize(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ...result, currentRootEpoch: 2, currentAppEpoch: 0, latestRootEpoch: 3 }); + expect(mockReadContract).toHaveBeenCalledTimes(1); + expect(mockReadContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: 'authorizeApp', blockNumber: 98n, + args: [expect.objectContaining({ appInfo: expect.objectContaining({ appId: appInfo.appId }), requestedAppEpoch: { isSet: false, value: 0n }, requestedRootEpoch: { isSet: false, value: 0n } })], + })); + }); + + it('encodes an explicit epoch zero as present', async () => { + mockReadContract.mockResolvedValueOnce({ + authorized: true, rejectionReason: '', gatewayAppId: 'gateway', + currentAppEpoch: 0n, currentRootEpoch: 1n, currentRootEpochHash: '0x' + '77'.repeat(32), + latestRootEpoch: 1n, latestRootEpochHash: '0x' + '77'.repeat(32), + }); + expect((await authorize(0)).status).toBe(200); + expect(mockReadContract).toHaveBeenCalledWith(expect.objectContaining({ + args: [expect.objectContaining({ requestedRootEpoch: { isSet: true, value: 0n } })], + })); + }); + + it.each([ + ['authorizeKms', 'kms_import_keys', { requestedRootEpoch: 2 }], + ['authorizeApp', 'app_get_key', { requestedRootEpoch: 1, requestedAppEpoch: 3 }], + ['authorizeApp', 'app_get_cert', {}], + ] as const)('forwards the operation and both epochs to %s (%s)', async (method, operation, epochs) => { + mockReadContract.mockResolvedValueOnce({ + authorized: true, rejectionReason: '', gatewayAppId: 'gateway', + currentAppEpoch: 3n, currentRootEpoch: 2n, currentRootEpochHash: '0x' + '77'.repeat(32), + latestRootEpoch: 2n, latestRootEpochHash: '0x' + '77'.repeat(32), + }); + const response = await appFetch(new Request(`http://localhost/${method}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...appInfo, operation, ...epochs }), + })); + expect(response.status).toBe(200); + const selector = (value?: number) => ({ isSet: value !== undefined, value: BigInt(value ?? 0) }); + const { requestedRootEpoch, requestedAppEpoch } = epochs as { requestedRootEpoch?: number, requestedAppEpoch?: number }; + expect(mockReadContract).toHaveBeenCalledTimes(1); + expect(mockReadContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: method, + args: [expect.objectContaining({ + operation: keccak256(toBytes(operation)), + requestedRootEpoch: selector(requestedRootEpoch), + requestedAppEpoch: selector(requestedAppEpoch), + })], + })); + }); + + it('rejects invalid, malformed and oversized requests in the AuthorizationResult shape', async () => { + const rejection = { + authorized: false, rejectionReason: 'invalid authorization request', gatewayAppId: '', + currentRootEpoch: 0, currentRootEpochHash: '0x' + '00'.repeat(32), currentAppEpoch: 0, + latestRootEpoch: 0, latestRootEpochHash: '0x' + '00'.repeat(32), + }; + const requests = [ + { status: 400, body: JSON.stringify({ ...appInfo, operation: 'unknown' }) }, + { status: 400, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', mrAggregated: 'not-hex' }) }, + { status: 400, body: '{"appId":' }, + { status: 413, body: JSON.stringify({ ...appInfo, operation: 'app_get_key', padding: 'a'.repeat(256 * 1024) }) }, + ]; + for (const path of ['/authorizeApp', '/authorizeKms']) { + for (const { status, body } of requests) { + const response = await appFetch(new Request(`http://localhost${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body, + })); + expect(response.status).toBe(status); + expect(await response.json()).toEqual(rejection); + } + } + expect(mockReadContract).not.toHaveBeenCalled(); + }); + + it('does not fall back to legacy authorization on a failed contract call', async () => { + mockReadContract.mockRejectedValueOnce(new Error('contract unavailable')); + expect((await authorize()).status).toBe(503); + expect(mockReadContract).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dstack/kms/auth-eth/index.ts b/dstack/kms/auth-eth/index.ts new file mode 100644 index 000000000..380f88805 --- /dev/null +++ b/dstack/kms/auth-eth/index.ts @@ -0,0 +1,584 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { secureHeaders } from 'hono/secure-headers'; +import { HTTPException } from 'hono/http-exception'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { createPublicClient, http, keccak256, toBytes, type Address, type Hex } from 'viem'; + +// zod schemas for validation - compatible with original fastify implementation +const boundedHex = (bytes: number, description: string) => + z.string() + .regex(/^(?:0x)?[0-9a-fA-F]*$/, `${description} must be hexadecimal`) + .refine( + (value) => value.replace(/^0x/, '').length <= bytes * 2, + `${description} exceeds ${bytes} bytes`, + ); + +const BootInfoSchema = z.object({ + // Short hexadecimal values remain compatible with the original backend, + // which left-pads them before making the contract call. + mrAggregated: boundedHex(32, 'aggregated MR measurement'), + osImageHash: boundedHex(32, 'OS Image hash'), + appId: boundedHex(20, 'application ID'), + composeHash: boundedHex(32, 'compose hash'), + instanceId: boundedHex(20, 'instance ID'), + deviceId: boundedHex(32, 'device ID'), + tcbStatus: z.string().max(128).optional().default(''), + advisoryIds: z.array(z.string().max(256)).max(128).optional().default([]), + mrSystem: boundedHex(32, 'system MR measurement').optional().default(''), + // The root epoch the asking KMS serves and its chain hash; absent from a KMS + // that predates root epochs, and from KMS checks made while onboarding. + rootEpoch: z.number().int().min(0).optional(), + rootEpochHash: boundedHex(32, 'root epoch chain hash').optional() +}); + +const AuthorizationRequestSchema = BootInfoSchema.omit({ rootEpoch: true, rootEpochHash: true }).extend({ + 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(), +}); +type AuthorizationRequest = z.infer; + +const BootResponseSchema = z.object({ + isAllowed: z.boolean(), + reason: z.string(), + gatewayAppId: z.string() +}); + +// The current root epoch and its chain hash, which commits to the root public +// keys of epochs 0..=rootEpoch; both absent when none is registered. +type RootEpochInfo = { rootEpoch?: number; rootEpochHash?: Hex }; + +type BootInfo = z.infer; +type BootResponse = z.infer; + +const normHash = (hash?: string) => hash?.toLowerCase().replace(/^0x/, ''); + +// Only a KMS serving the current root epoch, with the registered keys up to it, +// may authorize. A KMS that predates root epochs reports none and holds only +// epoch 0, so it is served only until another epoch is activated. +function rootEpochDenial(bootInfo: BootInfo, current: RootEpochInfo): string | undefined { + const currentEpoch = current.rootEpoch ?? 0; + if (bootInfo.rootEpoch === undefined) { + return currentEpoch === 0 ? undefined : `root epoch ${currentEpoch} is current, but the KMS reports none`; + } + if (current.rootEpoch === undefined) { + return bootInfo.rootEpoch === 0 ? undefined : `root epoch ${bootInfo.rootEpoch} is not registered`; + } + if (bootInfo.rootEpoch !== currentEpoch) { + return `the KMS serves root epoch ${bootInfo.rootEpoch}, but ${currentEpoch} is current`; + } + if (normHash(bootInfo.rootEpochHash) !== normHash(current.rootEpochHash)) { + return `the KMS root keys up to epoch ${currentEpoch} are not the registered ones`; + } + return undefined; +} + +const APP_INFO_COMPONENTS = [ + { name: 'appId', type: 'address' }, + { name: 'composeHash', type: 'bytes32' }, + { name: 'instanceId', type: 'address' }, + { name: 'deviceId', type: 'bytes32' }, + { name: 'mrAggregated', type: 'bytes32' }, + { name: 'mrSystem', type: 'bytes32' }, + { name: 'osImageHash', type: 'bytes32' }, + { name: 'tcbStatus', type: 'string' }, + { name: 'advisoryIds', type: 'string[]' } +] as const; + +const AUTHORIZATION_ABI = { + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'request', type: 'tuple', components: [ + { name: 'appInfo', type: 'tuple', components: APP_INFO_COMPONENTS }, + { name: 'operation', type: 'bytes32' }, + { name: 'requestedAppEpoch', type: 'tuple', components: [ + { name: 'isSet', type: 'bool' }, { name: 'value', type: 'uint256' }, + ] }, + { name: 'requestedRootEpoch', type: 'tuple', components: [ + { name: 'isSet', type: 'bool' }, { name: 'value', type: 'uint256' }, + ] }, + ] }], + outputs: [{ + name: 'result', + type: 'tuple', + components: [ + { name: 'authorized', type: 'bool' }, + { name: 'rejectionReason', type: 'string' }, + { name: 'gatewayAppId', type: 'string' }, + { name: 'currentRootEpoch', type: 'uint256' }, + { name: 'currentRootEpochHash', type: 'bytes32' }, + { name: 'currentAppEpoch', type: 'uint256' }, + { name: 'latestRootEpoch', type: 'uint256' }, + { name: 'latestRootEpochHash', type: 'bytes32' }, + ], + }], +} as const; + +// DstackKms contract ABI (minimal required functions) +const DSTACK_KMS_ABI = [ + { name: 'currentAppEpoch', type: 'function', stateMutability: 'view', inputs: [{ name: 'appId', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] }, + { ...AUTHORIZATION_ABI, name: 'authorizeKms' }, + { ...AUTHORIZATION_ABI, name: 'authorizeApp' }, + { + name: 'isKmsAllowed', + type: 'function', + stateMutability: 'view', + inputs: [ + { + name: 'bootInfo', + type: 'tuple', + components: APP_INFO_COMPONENTS + } + ], + outputs: [ + { name: 'isAllowed', type: 'bool' }, + { name: 'reason', type: 'string' } + ] + }, + { + name: 'isAppAllowed', + type: 'function', + stateMutability: 'view', + inputs: [ + { + name: 'bootInfo', + type: 'tuple', + components: APP_INFO_COMPONENTS + } + ], + outputs: [ + { name: 'isAllowed', type: 'bool' }, + { name: 'reason', type: 'string' } + ] + }, + { + name: 'gatewayAppId', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'string' }] + }, + { + name: 'appImplementation', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }] + }, + { + name: 'rootEpochCount', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint256' }] + }, + { + name: 'currentRootEpoch', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [ + { name: 'epoch', type: 'uint256' }, + { name: 'chainHash', type: 'bytes32' } + ] + } +] as const; + +// ethereum backend class +const BLOCK_TAGS = ['latest', 'safe', 'finalized'] as const; +type BlockTag = typeof BLOCK_TAGS[number]; + +export function parseBlockTag(value: string | undefined): BlockTag { + const tag = value || 'finalized'; + if (!(BLOCK_TAGS as readonly string[]).includes(tag)) { + throw new Error(`ETH_BLOCK_TAG must be one of ${BLOCK_TAGS.join(', ')}`); + } + return tag as BlockTag; +} + +export class EthereumBackend { + private client: ReturnType; + private kmsContractAddr: Address; + private expectedChainId?: number; + private blockTag: BlockTag; + private finalityConfirmations: bigint; + + constructor( + client: ReturnType, + kmsContractAddr: string, + expectedChainId: number | undefined, + blockTag: BlockTag, + finalityConfirmations: bigint, + ) { + this.client = client; + this.kmsContractAddr = kmsContractAddr as Address; + this.expectedChainId = expectedChainId; + this.blockTag = blockTag; + this.finalityConfirmations = finalityConfirmations; + } + + /// Resolve the configured tag, less the confirmation depth, to one block so + /// that every read of a request sees the same state. An RPC that does not + /// support the tag fails closed. + async resolveBlock(): Promise { + const chainId = await this.client.getChainId(); + if (this.expectedChainId !== undefined && chainId !== this.expectedChainId) { + throw new Error('authorization backend chain ID mismatch'); + } + const { number } = await this.client.getBlock({ blockTag: this.blockTag }); + if (number === null || number < this.finalityConfirmations) { + throw new Error('authorization backend has not reached configured finality'); + } + return number - this.finalityConfirmations; + } + + /// Resolve the tag once at startup, so an RPC that cannot serve it fails the + /// deployment instead of every later request. + async probeBlockTag(): Promise { + try { + await this.resolveBlock(); + } catch (err) { + // ethers and viem both put a one-line summary in shortMessage. + const detail = (err as { shortMessage?: string }).shortMessage ?? (err instanceof Error ? err.message : String(err)); + throw new Error( + `ETH_BLOCK_TAG=${this.blockTag} is not supported by the RPC (${detail}); OP-stack via Helios requires latest`, + ); + } + } + + private decodeHex(hex: string, sz: number = 32): Hex { + // remove '0x' prefix if present + hex = hex.startsWith('0x') ? hex.slice(2) : hex; + // pad hex string to specified size + hex = hex.padStart(sz * 2, '0'); + // add '0x' prefix back + return `0x${hex}` as Hex; + } + + private contractAppInfo(bootInfo: BootInfo) { + // create boot info struct for contract call + return { + appId: this.decodeHex(bootInfo.appId, 20) as Address, + composeHash: this.decodeHex(bootInfo.composeHash, 32), + instanceId: this.decodeHex(bootInfo.instanceId, 20) as Address, + deviceId: this.decodeHex(bootInfo.deviceId, 32), + mrAggregated: this.decodeHex(bootInfo.mrAggregated, 32), + mrSystem: this.decodeHex(bootInfo.mrSystem || '', 32), + osImageHash: this.decodeHex(bootInfo.osImageHash, 32), + tcbStatus: bootInfo.tcbStatus || '', + advisoryIds: bootInfo.advisoryIds || [] + }; + + } + + async checkAuthorization(appInfo: AuthorizationRequest, method: 'authorizeKms' | 'authorizeApp') { + const blockNumber = await this.resolveBlock(); + const result = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: method, + args: [{ + appInfo: this.contractAppInfo(appInfo), + operation: keccak256(toBytes(appInfo.operation)), + requestedAppEpoch: { isSet: appInfo.requestedAppEpoch !== undefined, value: BigInt(appInfo.requestedAppEpoch ?? 0) }, + requestedRootEpoch: { isSet: appInfo.requestedRootEpoch !== undefined, value: BigInt(appInfo.requestedRootEpoch ?? 0) }, + }], + blockNumber, + }); + const currentRootEpoch = Number(result.currentRootEpoch); + if (!Number.isSafeInteger(currentRootEpoch)) { + throw new Error('current root epoch exceeds the supported range'); + } + const currentAppEpoch = Number(result.currentAppEpoch); + if (!Number.isSafeInteger(currentAppEpoch)) throw new Error('current app epoch exceeds the supported range'); + const latestRootEpoch = Number(result.latestRootEpoch); + if (!Number.isSafeInteger(latestRootEpoch)) throw new Error('latest root epoch exceeds the supported range'); + return { ...result, currentRootEpoch, currentAppEpoch, latestRootEpoch }; + } + + async checkBoot(bootInfo: BootInfo, isKms: boolean): Promise { + const bootInfoStruct = this.contractAppInfo(bootInfo); + const blockNumber = await this.resolveBlock(); + let response; + if (isKms) { + response = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'isKmsAllowed', + args: [bootInfoStruct], + blockNumber + }); + } else { + response = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'isAppAllowed', + args: [bootInfoStruct], + blockNumber + }); + } + + const [allowed, contractReason] = response; + const gatewayAppId = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'gatewayAppId', + blockNumber + }); + + const epochDenial = allowed + ? rootEpochDenial(bootInfo, await this.getRootEpoch(blockNumber)) + : undefined; + return { + isAllowed: allowed && !epochDenial, + reason: epochDenial ?? contractReason, + gatewayAppId: gatewayAppId as string, + }; + } + + // The current root epoch and its chain hash; empty only when no epoch is registered. + async getRootEpoch(blockNumber?: bigint): Promise { + blockNumber ??= await this.resolveBlock(); + const count = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'rootEpochCount', + blockNumber + }); + if (count === 0n) return {}; + const [epoch, chainHash] = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'currentRootEpoch', + blockNumber + }); + return { rootEpoch: Number(epoch), rootEpochHash: chainHash }; + } + + async getAppEpoch(appId: string): Promise { + const epoch = Number(await this.client.readContract({ + address: this.kmsContractAddr, abi: DSTACK_KMS_ABI, functionName: 'currentAppEpoch', + args: [this.decodeHex(appId, 20)], blockNumber: await this.resolveBlock(), + })); + if (!Number.isSafeInteger(epoch)) throw new Error('app epoch exceeds the supported range'); + return epoch; + } + + async getGatewayAppId(blockNumber?: bigint): Promise { + const result = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'gatewayAppId', + blockNumber: blockNumber ?? await this.resolveBlock(), + }); + return result as string; + } + + async getChainId(): Promise { + const chainId = await this.client.getChainId(); + return Number(chainId); + } + + async getAppImplementation(blockNumber?: bigint): Promise { + const result = await this.client.readContract({ + address: this.kmsContractAddr, + abi: DSTACK_KMS_ABI, + functionName: 'appImplementation', + blockNumber: blockNumber ?? await this.resolveBlock(), + }); + return result as string; + } +} + +// initialize app +const app = new Hono(); + +// initialize ethereum backend +const rpcUrl = process.env.ETH_RPC_URL || 'http://localhost:8545'; +const kmsContractAddr = process.env.KMS_CONTRACT_ADDR || '0x0000000000000000000000000000000000000000'; +const parseNonNegativeInteger = (name: string, value: string | undefined): number | undefined => { + if (value === undefined || value === '') return undefined; + if (!/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error(`${name} must be a non-negative integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) throw new Error(`${name} exceeds the safe integer range`); + return parsed; +}; +const expectedChainId = parseNonNegativeInteger('ETH_CHAIN_ID', process.env.ETH_CHAIN_ID); +const finalityConfirmations = BigInt( + parseNonNegativeInteger('ETH_FINALITY_CONFIRMATIONS', process.env.ETH_FINALITY_CONFIRMATIONS) ?? 0, +); +const client = createPublicClient({ + transport: http(rpcUrl) +}); +const ethereum = new EthereumBackend( + client, kmsContractAddr, expectedChainId, parseBlockTag(process.env.ETH_BLOCK_TAG), finalityConfirmations, +); + +const publicRpcEndpoint = (value: string): string => { + try { + const endpoint = new URL(value); + return `${endpoint.protocol}//${endpoint.host}`; + } catch { + return 'configured'; + } +}; + +const backendUnavailable = 'authorization backend unavailable'; +const invalidRequest = { isAllowed: false, reason: 'invalid authorization request', gatewayAppId: '' }; +const invalidAuthorization = { + authorized: false, + rejectionReason: 'invalid authorization request', + gatewayAppId: '', + currentRootEpoch: 0, + currentRootEpochHash: '0x' + '00'.repeat(32), + currentAppEpoch: 0, + latestRootEpoch: 0, + latestRootEpochHash: '0x' + '00'.repeat(32), +}; + +// The schema caps a valid BootInfo well under this (advisoryIds alone at +// 128 x 256 chars is 32 KiB); the limit stops an oversized body from being +// buffered and parsed before validation rejects it. +const MAX_BOOT_INFO_BYTES = 128 * 1024; + +// Every response is JSON for the KMS, never a page; the headers keep a browser +// that reaches this port from sniffing or framing one. +app.use('*', secureHeaders()); +app.use('/bootAuth/*', bodyLimit({ + maxSize: MAX_BOOT_INFO_BYTES, + onError: (c) => c.json(invalidRequest, 413), +})); + +// health check and info endpoint +app.get('/', async (c) => { + try { + const block = await ethereum.resolveBlock(); + const batch = await Promise.all([ + ethereum.getGatewayAppId(block), + ethereum.getChainId(), + ethereum.getAppImplementation(block), + ethereum.getRootEpoch(block), + ]); + + return c.json({ + status: 'ok', + kmsContractAddr: kmsContractAddr, + ethRpcUrl: publicRpcEndpoint(rpcUrl), + gatewayAppId: batch[0], + chainId: batch[1], + appAuthImplementation: batch[2], // NOTE: for backward compatibility + appImplementation: batch[2], + ...batch[3], + }); + } catch { + console.error('authorization backend health check failed'); + return c.json({ + status: 'error', + message: backendUnavailable + }, 500); + } +}); + +app.get('/app/:appId', async (c) => { + const appId = c.req.param('appId'); + if (!/^0x[0-9a-fA-F]{40}$/.test(appId)) return c.json({ error: 'invalid app ID' }, 400); + try { + return c.json({ currentAppEpoch: await ethereum.getAppEpoch(appId) }); + } catch { + return c.json({ error: 'authorization backend unavailable' }, 503); + } +}); + +// Malformed JSON is rejected before the validator hook runs; keep the +// AuthorizationResult shape for it on the authorization routes. +const AUTHORIZATION_PATHS = new Set(['/authorizeKms', '/authorizeApp']); +app.onError((error, c) => { + if (error instanceof HTTPException) { + if (error.status === 400 && AUTHORIZATION_PATHS.has(c.req.path)) return c.json(invalidAuthorization, 400); + return error.getResponse(); + } + console.error(error); + return c.text('Internal Server Error', 500); +}); + +for (const method of ['authorizeKms', 'authorizeApp'] as const) { + app.post(`/${method}`, + bodyLimit({ maxSize: MAX_BOOT_INFO_BYTES, onError: (c) => c.json(invalidAuthorization, 413) }), + zValidator('json', AuthorizationRequestSchema, (result, c) => { + if (!result.success) return c.json(invalidAuthorization, 400); + }), + async (c) => { + try { + return c.json(await ethereum.checkAuthorization(c.req.valid('json'), method)); + } catch { + console.error('authorization backend failed'); + return c.json({ status: 'error', message: backendUnavailable }, 503); + } + } + ); +} + +// app boot authentication +app.post('/bootAuth/app', + zValidator('json', BootInfoSchema, (result, c) => { + if (!result.success) return c.json(invalidRequest, 400); + }), + async (c) => { + try { + const bootInfo = c.req.valid('json'); + const result = await ethereum.checkBoot(bootInfo, false); + return c.json(result); + } catch { + console.error('application authorization backend failed'); + return c.json({ + isAllowed: false, + gatewayAppId: '', + reason: backendUnavailable + }); + } + } +); + +// KMS boot authentication +app.post('/bootAuth/kms', + zValidator('json', BootInfoSchema, (result, c) => { + if (!result.success) return c.json(invalidRequest, 400); + }), + async (c) => { + try { + const bootInfo = c.req.valid('json'); + const result = await ethereum.checkBoot(bootInfo, true); + return c.json(result); + } catch (error) { + // don't log test backend errors + if (!(error instanceof Error && "Test backend error" === error.message)) { + console.error('KMS authorization backend failed'); + } + return c.json({ + isAllowed: false, + gatewayAppId: '', + reason: backendUnavailable + }); + } + } +); + +// start server +if (import.meta.main) await ethereum.probeBlockTag(); +const port = parseInt(process.env.PORT || '3000'); +console.log(`starting server on port ${port}`); + +// Bun defaults idleTimeout to 10s and closes the socket while the handler is +// still awaiting eth_call. KMS's webhook timeout is 60s; stay above that so the +// client times out first. Bun's documented maximum is 255. +export default { + port, + idleTimeout: 255, + fetch: app.fetch, +}; diff --git a/dstack/kms/auth-eth/jest.config.js b/dstack/kms/auth-eth/jest.config.js deleted file mode 100644 index dafec6254..000000000 --- a/dstack/kms/auth-eth/jest.config.js +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src'], - testMatch: ['**/main.test.ts'], - moduleFileExtensions: ['ts', 'js', 'json', 'node'], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts' - ], - coverageDirectory: 'coverage', - verbose: true -}; diff --git a/dstack/kms/auth-eth/openapi.json b/dstack/kms/auth-eth/openapi.json new file mode 100644 index 000000000..c0af9430d --- /dev/null +++ b/dstack/kms/auth-eth/openapi.json @@ -0,0 +1,499 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "dstack KMS Ethereum Backend API", + "description": "API for dstack KMS Ethereum backend authentication and system information", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Development server" + } + ], + "paths": { + "/": { + "get": { + "summary": "Health check and system information", + "description": "Returns system status and configuration information", + "operationId": "getSystemInfo", + "responses": { + "200": { + "description": "System information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemInfo" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/bootAuth/app": { + "post": { + "summary": "Application boot authentication", + "description": "Validates application boot information against smart contract", + "operationId": "authenticateAppBoot", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootInfo" + } + } + } + }, + "responses": { + "200": { + "description": "Authentication result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootResponse" + } + } + } + } + } + } + }, + "/bootAuth/kms": { + "post": { + "summary": "KMS boot authentication", + "description": "Validates KMS boot information against smart contract", + "operationId": "authenticateKmsBoot", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootInfo" + } + } + } + }, + "responses": { + "200": { + "description": "Authentication result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootResponse" + } + } + } + } + } + } + }, + "/authorizeKms": { + "post": { + "summary": "Authorize a Kms identity and return the current root epoch", + "operationId": "authorizeKms", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Authorization decision, including the epoch on rejection.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationResult" + } + } + } + }, + "503": { + "description": "Authorization backend unavailable; no legacy fallback." + } + } + } + }, + "/authorizeApp": { + "post": { + "summary": "Authorize a App identity and return the current root epoch", + "operationId": "authorizeApp", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Authorization decision, including the epoch on rejection.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationResult" + } + } + } + }, + "503": { + "description": "Authorization backend unavailable; no legacy fallback." + } + } + } + }, + "/app/{appId}": { + "get": { + "summary": "Read the current application key epoch", + "parameters": [ + { + "name": "appId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + } + } + ], + "responses": { + "200": { + "description": "Current application epoch", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "currentAppEpoch" + ], + "properties": { + "currentAppEpoch": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "BootInfo": { + "type": "object", + "required": [ + "mrAggregated", + "osImageHash", + "appId", + "composeHash", + "instanceId", + "deviceId" + ], + "properties": { + "mrAggregated": { + "type": "string", + "description": "Aggregated MR measurement" + }, + "osImageHash": { + "type": "string", + "description": "OS Image hash" + }, + "appId": { + "type": "string", + "description": "Application ID" + }, + "composeHash": { + "type": "string", + "description": "Compose hash" + }, + "instanceId": { + "type": "string", + "description": "Instance ID" + }, + "deviceId": { + "type": "string", + "description": "Device ID" + }, + "tcbStatus": { + "type": "string", + "description": "TCB status (optional for compatibility)" + }, + "advisoryIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory IDs (optional for compatibility)" + }, + "mrSystem": { + "type": "string", + "description": "MR System measurement (optional for compatibility)" + }, + "rootEpoch": { + "type": "integer", + "description": "KMS root epoch the asking KMS serves; absent from a KMS that predates root epochs and from KMS checks made while onboarding" + }, + "rootEpochHash": { + "type": "string", + "description": "Chain hash committing to the root public keys of epochs 0..=rootEpoch (hex)" + } + } + }, + "BootResponse": { + "type": "object", + "required": [ + "isAllowed", + "reason", + "gatewayAppId" + ], + "properties": { + "isAllowed": { + "type": "boolean", + "description": "Whether the boot is allowed" + }, + "reason": { + "type": "string", + "description": "Reason for the decision" + }, + "gatewayAppId": { + "type": "string", + "description": "Gateway application ID" + } + } + }, + "SystemInfo": { + "type": "object", + "required": [ + "status", + "kmsContractAddr", + "ethRpcUrl", + "gatewayAppId", + "chainId", + "appAuthImplementation", + "appImplementation" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ], + "description": "System status" + }, + "kmsContractAddr": { + "type": "string", + "description": "KMS contract address" + }, + "ethRpcUrl": { + "type": "string", + "description": "Ethereum RPC URL" + }, + "gatewayAppId": { + "type": "string", + "description": "Gateway application ID" + }, + "chainId": { + "type": "number", + "description": "Blockchain chain ID" + }, + "appAuthImplementation": { + "type": "string", + "description": "App auth implementation address (deprecated, use appImplementation)" + }, + "appImplementation": { + "type": "string", + "description": "App implementation address" + }, + "rootEpoch": { + "type": "integer", + "description": "Current KMS root epoch; absent when none is registered" + }, + "rootEpochHash": { + "type": "string", + "description": "Chain hash committing to the root public keys of epochs 0..=rootEpoch (hex)" + } + } + }, + "Error": { + "type": "object", + "required": [ + "status", + "message" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "message": { + "type": "string", + "description": "Error message" + } + } + }, + "AppInfo": { + "type": "object", + "required": [ + "mrAggregated", + "osImageHash", + "appId", + "composeHash", + "instanceId", + "deviceId" + ], + "properties": { + "mrAggregated": { + "type": "string", + "description": "Aggregated MR measurement" + }, + "osImageHash": { + "type": "string", + "description": "OS Image hash" + }, + "appId": { + "type": "string", + "description": "Application ID" + }, + "composeHash": { + "type": "string", + "description": "Compose hash" + }, + "instanceId": { + "type": "string", + "description": "Instance ID" + }, + "deviceId": { + "type": "string", + "description": "Device ID" + }, + "tcbStatus": { + "type": "string", + "description": "TCB status (optional for compatibility)" + }, + "advisoryIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory IDs (optional for compatibility)" + }, + "mrSystem": { + "type": "string", + "description": "MR System measurement (optional for compatibility)" + } + }, + "description": "Verified attestation identity. The KMS app ID is the KMS contract address." + }, + "AuthorizationResult": { + "type": "object", + "required": [ + "authorized", + "rejectionReason", + "gatewayAppId", + "currentRootEpoch", + "currentRootEpochHash", + "currentAppEpoch", + "latestRootEpoch", + "latestRootEpochHash" + ], + "properties": { + "authorized": { + "type": "boolean" + }, + "rejectionReason": { + "type": "string", + "description": "Empty when allowed." + }, + "gatewayAppId": { + "type": "string" + }, + "currentRootEpoch": { + "type": "integer", + "minimum": 0 + }, + "currentRootEpochHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$", + "description": "Zero when no epoch is registered; authorization is denied." + }, + "currentAppEpoch": { + "type": "integer", + "minimum": 0, + "description": "Current application epoch; zero until app rotation is supported." + }, + "latestRootEpoch": { + "type": "integer", + "minimum": 0, + "description": "Latest registered root epoch; may be pending activation. Zero when none is registered." + }, + "latestRootEpochHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$", + "description": "Chain hash of the latest registered root epoch; zero when none is registered." + } + } + }, + "AuthorizationRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/AppInfo" + } + ], + "type": "object", + "properties": { + "requestedRootEpoch": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "Omit to use the current epoch. An explicit zero selects epoch 0; future epochs are denied." + }, + "requestedAppEpoch": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "Omit to use the current application epoch. Explicit zero selects epoch 0; future epochs are denied." + }, + "operation": { + "type": "string", + "enum": [ + "kms_bootstrap", + "kms_serve", + "kms_export_keys", + "kms_import_keys", + "app_get_key", + "app_get_cert" + ] + } + }, + "required": [ + "operation" + ] + } + } + } +} diff --git a/dstack/kms/auth-eth/package-lock.json b/dstack/kms/auth-eth/package-lock.json deleted file mode 100644 index 46bc81226..000000000 --- a/dstack/kms/auth-eth/package-lock.json +++ /dev/null @@ -1,5249 +0,0 @@ -{ - "name": "auth-eth", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "auth-eth", - "version": "1.0.0", - "dependencies": { - "@fastify/swagger": "^8.12.0", - "@fastify/swagger-ui": "^6.1.1", - "dotenv": "^16.3.1", - "ethers": "^6.17.0", - "fastify": "^5.12.6", - "yargs": "^17.7.2" - }, - "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^20.17.12", - "jest": "^29.7.0", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.1", - "typescript": "^5.3.3" - } - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", - "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@fastify/accept-negotiator": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", - "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" - } - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", - "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^6.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@fastify/send": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", - "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "escape-html": "~1.0.3", - "fast-decode-uri-component": "^1.0.1", - "http-errors": "^2.0.0", - "mime": "^3" - } - }, - "node_modules/@fastify/static": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz", - "integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "@fastify/error": "^4.0.0", - "@fastify/send": "^4.0.0", - "content-disposition": "^2.0.1", - "fastify-plugin": "^6.0.0", - "fastq": "^1.17.1", - "glob": "^13.0.0" - } - }, - "node_modules/@fastify/static/node_modules/fastify-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", - "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/swagger": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.15.0.tgz", - "integrity": "sha512-zy+HEEKFqPMS2sFUsQU5X0MHplhKJvWeohBwTCkBAJA/GDYGLGUWQaETEhptiqxK7Hs0fQB9B4MDb3pbwIiCwA==", - "license": "MIT", - "dependencies": { - "fastify-plugin": "^4.0.0", - "json-schema-resolver": "^2.0.0", - "openapi-types": "^12.0.0", - "rfdc": "^1.3.0", - "yaml": "^2.2.2" - } - }, - "node_modules/@fastify/swagger-ui": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-6.1.1.tgz", - "integrity": "sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/static": "^10.1.0", - "fastify-plugin": "^6.0.0", - "openapi-types": "^12.1.3", - "rfdc": "^1.3.1", - "yaml": "^2.4.1" - } - }, - "node_modules/@fastify/swagger-ui/node_modules/fastify-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", - "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@jest/reporters/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@types/node": { - "version": "20.17.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.12.tgz", - "integrity": "sha512-vo/wmBgMIiEA23A/knMfn/cf37VnuF52nZh5ZoW0GWt4e4sxNquibrMRJ7UQsA06+MBx9r/H1jsI9grYjQCQlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "license": "MIT" - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", - "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", - "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", - "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", - "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.20", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", - "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", - "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.427", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", - "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ethers": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", - "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.11.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.21.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", - "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", - "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.12.6", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.6.tgz", - "integrity": "sha512-XeErl1DDsxdbhE9RPRJ05iNnfMhHRHHewVNNAJXaGTOaCY4wHb6rEizphhgzBq5YQe4JyqQWPpGd+hLh2PXXJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^7.0.0", - "find-my-way": "^9.6.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.1.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", - "license": "MIT" - }, - "node_modules/fastify/node_modules/fast-json-stringify": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", - "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fastify/node_modules/fast-uri": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.3.tgz", - "integrity": "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-my-way": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", - "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/handlebars": { - "version": "4.7.10", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", - "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.8", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-config/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jest-config/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-config/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-resolver": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-2.0.0.tgz", - "integrity": "sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "rfdc": "^1.1.4", - "uri-js": "^4.2.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.55", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", - "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT" - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/process-warning": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/thread-stream": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", - "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", - "license": "MIT", - "dependencies": { - "real-require": "^1.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/thread-stream/node_modules/real-require": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", - "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT" - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toad-cache": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-jest": { - "version": "29.4.15", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz", - "integrity": "sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.10", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.8.5", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <9", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <7" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/dstack/kms/auth-eth/package.json b/dstack/kms/auth-eth/package.json index 7483c30aa..c21042f5c 100644 --- a/dstack/kms/auth-eth/package.json +++ b/dstack/kms/auth-eth/package.json @@ -1,36 +1,35 @@ { "name": "auth-eth", "version": "1.0.0", - "description": "dstack KMS Ethereum Backend", - "main": "dist/main.js", + "description": "dstack KMS ethereum backend with bun + hono + zod", + "main": "index.ts", "scripts": { - "build": "tsc", - "start": "node dist/main.js", - "dev": "ts-node src/main.ts", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:foundry": "forge test --ffi", - "test:foundry:all": "forge clean && forge build && forge test --ffi", - "test:setup": "./scripts/setup-local-chain.sh", - "test:run": "./scripts/run-tests.sh", - "test:all": "./scripts/test-all.sh", - "test:cleanup": "./scripts/cleanup.sh" + "dev": "bun run --watch index.ts", + "start": "bun run index.ts", + "build": "bun build index.ts --outdir ./dist --target bun", + "test": "vitest", + "test:run": "vitest run", + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "fmt": "oxlint --fix .", + "check": "bun run lint && bun run test:run" }, "dependencies": { - "@fastify/swagger": "^8.12.0", - "@fastify/swagger-ui": "^6.1.1", - "dotenv": "^16.3.1", - "ethers": "^6.17.0", - "fastify": "^5.12.6", - "yargs": "^17.7.2" + "@hono/zod-validator": "0.9.1", + "hono": "4.13.13", + "viem": "2.57.5", + "zod": "4.6.7" }, "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^20.17.12", - "jest": "^29.7.0", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.1", - "typescript": "^5.3.3" + "@types/bun": "1.4.2", + "@types/node": "24.19.2", + "oxlint": "1.88.0", + "typescript": "5.9.3", + "vite": "8.3.3", + "vitest": "5.0.4" + }, + "type": "module", + "overrides": { + "source-map-js": "1.2.2" } } diff --git a/dstack/kms/auth-eth/run-tests.sh b/dstack/kms/auth-eth/run-tests.sh deleted file mode 100644 index e8dd6ea0f..000000000 --- a/dstack/kms/auth-eth/run-tests.sh +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -npm run test diff --git a/dstack/kms/auth-eth/script/Manage.s.sol b/dstack/kms/auth-eth/script/Manage.s.sol index 110612b7e..63ddd6f67 100644 --- a/dstack/kms/auth-eth/script/Manage.s.sol +++ b/dstack/kms/auth-eth/script/Manage.s.sol @@ -9,6 +9,17 @@ pragma solidity ^0.8.24; import "forge-std/Script.sol"; import "../contracts/DstackKms.sol"; import "../contracts/DstackApp.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; + +// The epoch-less setters of app policies predating app epochs (v0.5.x, +// v0.6.0), which report IAppAuthBasicManagement instead of IAppEpochManagement. +interface ILegacyAppManagement { + function addComposeHash(bytes32 composeHash) external; + function removeComposeHash(bytes32 composeHash) external; + function addDevice(bytes32 deviceId) external; + function removeDevice(bytes32 deviceId) external; + function setAllowAnyDevice(bool allowed) external; +} // Base contract with common functionality abstract contract BaseScript is Script { @@ -25,6 +36,48 @@ abstract contract BaseScript is Script { app = DstackApp(appAddr); } } + + // The rows a policy change applies to: `single` names one epoch, `from` and + // `to` a range. There is no default, so a change never misses a row + // silently. + function epochRange( + string memory single, + string memory from, + string memory to + ) + internal + view + returns (uint256, uint256) + { + if (vm.envExists(single)) { + uint256 epoch = vm.envUint(single); + return (epoch, epoch); + } + require(vm.envExists(from) && vm.envExists(to), string.concat("set ", single, ", or ", from, " and ", to)); + return (vm.envUint(from), vm.envUint(to)); + } + + function rootEpochs() internal view returns (uint256, uint256) { + return epochRange("ROOT_EPOCH", "FROM_ROOT_EPOCH", "TO_ROOT_EPOCH"); + } + + function appEpochs() internal view returns (uint256, uint256) { + return epochRange("APP_EPOCH", "FROM_APP_EPOCH", "TO_APP_EPOCH"); + } + + // The legacy setters of an app without IAppEpochManagement, or none. Such + // an app has only app epoch 0, so only APP_EPOCH=0, or no epoch, applies. + function legacyApp() internal view returns (ILegacyAppManagement legacy) { + require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); + if (ERC165Checker.supportsInterface(address(app), type(IAppEpochManagement).interfaceId)) { + return ILegacyAppManagement(address(0)); + } + require( + !vm.envExists("FROM_APP_EPOCH") && !vm.envExists("TO_APP_EPOCH") && vm.envOr("APP_EPOCH", uint256(0)) == 0, + "this app predates app epochs: only APP_EPOCH=0 applies" + ); + return ILegacyAppManagement(address(app)); + } } // KMS Management Scripts @@ -32,8 +85,9 @@ contract AddKmsAggregatedMr is BaseScript { function run() external { bytes32 mrAggregated = vm.envBytes32("MR_AGGREGATED"); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.addKmsAggregatedMr(mrAggregated); + kms.addKmsAggregatedMrAtEpochs(from, to, mrAggregated); vm.stopBroadcast(); console.log("Added KMS aggregated MR:", vm.toString(mrAggregated)); @@ -44,8 +98,9 @@ contract RemoveKmsAggregatedMr is BaseScript { function run() external { bytes32 mrAggregated = vm.envBytes32("MR_AGGREGATED"); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.removeKmsAggregatedMr(mrAggregated); + kms.removeKmsAggregatedMrAtEpochs(from, to, mrAggregated); vm.stopBroadcast(); console.log("Removed KMS aggregated MR:", vm.toString(mrAggregated)); @@ -56,8 +111,9 @@ contract AddOsImage is BaseScript { function run() external { bytes32 imageHash = vm.envBytes32("OS_IMAGE_HASH"); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.addOsImageHash(imageHash); + kms.addOsImageHashAtEpochs(from, to, imageHash); vm.stopBroadcast(); console.log("Added OS image hash:", vm.toString(imageHash)); @@ -68,8 +124,9 @@ contract AddKmsDevice is BaseScript { function run() external { bytes32 deviceId = vm.envBytes32("DEVICE_ID"); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.addKmsDevice(deviceId); + kms.addKmsDeviceAtEpochs(from, to, deviceId); vm.stopBroadcast(); console.log("Added KMS device:", vm.toString(deviceId)); @@ -88,36 +145,73 @@ contract SetGatewayAppId is BaseScript { } } -contract SetKmsInfo is BaseScript { +contract RegisterRootEpoch is BaseScript { function run() external { - bytes memory k256Pubkey = vm.envBytes("K256_PUBKEY"); bytes memory caPubkey = vm.envBytes("CA_PUBKEY"); - bytes memory quote = vm.envBytes("QUOTE"); - bytes memory eventlog = vm.envBytes("EVENTLOG"); + bytes memory k256Pubkey = vm.envBytes("K256_PUBKEY"); + string memory evidenceUrl = vm.envString("EVIDENCE_URL"); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.setKmsInfo( - DstackKms.KmsInfo({ k256Pubkey: k256Pubkey, caPubkey: caPubkey, quote: quote, eventlog: eventlog }) - ); + uint256 epoch = kms.registerRootEpoch(caPubkey, k256Pubkey, evidenceUrl); + vm.stopBroadcast(); + + console.log("Registered KMS root epoch", epoch); + } +} + +contract ActivateRootEpoch is BaseScript { + function run() external { + uint256 epoch = vm.envUint("ROOT_EPOCH"); + bytes32 chainHash = vm.envBytes32("CHAIN_HASH"); + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + kms.activateRootEpoch(epoch, chainHash); vm.stopBroadcast(); - console.log("KMS info set successfully"); - console.log(" K256 Pubkey length:", k256Pubkey.length); - console.log(" CA Pubkey length:", caPubkey.length); - console.log(" Quote length:", quote.length); - console.log(" Eventlog length:", eventlog.length); + console.log("Activated KMS root epoch", epoch); + } +} + +contract SetRootEpochEvidenceUrl is BaseScript { + function run() external { + uint256 epoch = vm.envUint("ROOT_EPOCH"); + string memory evidenceUrl = vm.envString("EVIDENCE_URL"); + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + kms.setRootEpochEvidenceUrl(epoch, evidenceUrl); + vm.stopBroadcast(); + + console.log("Updated the evidence URL of KMS root epoch", epoch); + } +} + +contract PopRootEpoch is BaseScript { + function run() external { + uint256 epoch = vm.envUint("ROOT_EPOCH"); + bytes32 chainHash = vm.envBytes32("CHAIN_HASH"); + + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + kms.popRootEpoch(epoch, chainHash); + vm.stopBroadcast(); + + console.log("Popped pending KMS root epoch", epoch); } } // App Management Scripts contract AddComposeHash is BaseScript { function run() external { - require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); bytes32 composeHash = vm.envBytes32("COMPOSE_HASH"); - vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.addComposeHash(composeHash); - vm.stopBroadcast(); + ILegacyAppManagement legacy = legacyApp(); + if (address(legacy) != address(0)) { + vm.broadcast(vm.envUint("PRIVATE_KEY")); + legacy.addComposeHash(composeHash); + } else { + (uint256 from, uint256 to) = appEpochs(); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.addComposeHashAtEpochs(from, to, composeHash); + } console.log("Added compose hash:", vm.toString(composeHash)); } @@ -125,12 +219,17 @@ contract AddComposeHash is BaseScript { contract AddDevice is BaseScript { function run() external { - require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); bytes32 deviceId = vm.envBytes32("DEVICE_ID"); - vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.addDevice(deviceId); - vm.stopBroadcast(); + ILegacyAppManagement legacy = legacyApp(); + if (address(legacy) != address(0)) { + vm.broadcast(vm.envUint("PRIVATE_KEY")); + legacy.addDevice(deviceId); + } else { + (uint256 from, uint256 to) = appEpochs(); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.addDeviceAtEpochs(from, to, deviceId); + } console.log("Added device:", vm.toString(deviceId)); } @@ -138,12 +237,17 @@ contract AddDevice is BaseScript { contract SetAllowAnyDevice is BaseScript { function run() external { - require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); bool allow = vm.envBool("ALLOW_ANY_DEVICE"); - vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.setAllowAnyDevice(allow); - vm.stopBroadcast(); + ILegacyAppManagement legacy = legacyApp(); + if (address(legacy) != address(0)) { + vm.broadcast(vm.envUint("PRIVATE_KEY")); + legacy.setAllowAnyDevice(allow); + } else { + (uint256 from, uint256 to) = appEpochs(); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.setAllowAnyDeviceAtEpochs(from, to, allow); + } console.log("Set allowAnyDevice to:", allow); } @@ -202,6 +306,7 @@ contract BatchKmsSetup is BaseScript { bytes32[] memory osImages = vm.envOr("OS_IMAGE_LIST", ",", new bytes32[](0)); bytes32[] memory devices = vm.envOr("DEVICE_LIST", ",", new bytes32[](0)); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); // Set gateway ID if provided @@ -212,19 +317,19 @@ contract BatchKmsSetup is BaseScript { // Add aggregated MRs for (uint256 i = 0; i < mrAggregated.length; i++) { - kms.addKmsAggregatedMr(mrAggregated[i]); + kms.addKmsAggregatedMrAtEpochs(from, to, mrAggregated[i]); console.log("Added MR aggregated:", vm.toString(mrAggregated[i])); } // Add OS images for (uint256 i = 0; i < osImages.length; i++) { - kms.addOsImageHash(osImages[i]); + kms.addOsImageHashAtEpochs(from, to, osImages[i]); console.log("Added OS image:", vm.toString(osImages[i])); } // Add devices for (uint256 i = 0; i < devices.length; i++) { - kms.addKmsDevice(devices[i]); + kms.addKmsDeviceAtEpochs(from, to, devices[i]); console.log("Added device:", vm.toString(devices[i])); } @@ -240,8 +345,9 @@ contract RemoveOsImage is BaseScript { function run() external { bytes32 imageHash = vm.envBytes32("OS_IMAGE_HASH"); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.removeOsImageHash(imageHash); + kms.removeOsImageHashAtEpochs(from, to, imageHash); vm.stopBroadcast(); console.log("Removed OS image hash:", vm.toString(imageHash)); @@ -252,8 +358,9 @@ contract RemoveKmsDevice is BaseScript { function run() external { bytes32 deviceId = vm.envBytes32("DEVICE_ID"); + (uint256 from, uint256 to) = rootEpochs(); vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - kms.removeKmsDevice(deviceId); + kms.removeKmsDeviceAtEpochs(from, to, deviceId); vm.stopBroadcast(); console.log("Removed KMS device:", vm.toString(deviceId)); @@ -263,12 +370,17 @@ contract RemoveKmsDevice is BaseScript { // Remove operations for App management contract RemoveComposeHash is BaseScript { function run() external { - require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); bytes32 composeHash = vm.envBytes32("COMPOSE_HASH"); - vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.removeComposeHash(composeHash); - vm.stopBroadcast(); + ILegacyAppManagement legacy = legacyApp(); + if (address(legacy) != address(0)) { + vm.broadcast(vm.envUint("PRIVATE_KEY")); + legacy.removeComposeHash(composeHash); + } else { + (uint256 from, uint256 to) = appEpochs(); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.removeComposeHashAtEpochs(from, to, composeHash); + } console.log("Removed compose hash:", vm.toString(composeHash)); } @@ -276,12 +388,17 @@ contract RemoveComposeHash is BaseScript { contract RemoveDevice is BaseScript { function run() external { - require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); bytes32 deviceId = vm.envBytes32("DEVICE_ID"); - vm.startBroadcast(vm.envUint("PRIVATE_KEY")); - app.removeDevice(deviceId); - vm.stopBroadcast(); + ILegacyAppManagement legacy = legacyApp(); + if (address(legacy) != address(0)) { + vm.broadcast(vm.envUint("PRIVATE_KEY")); + legacy.removeDevice(deviceId); + } else { + (uint256 from, uint256 to) = appEpochs(); + vm.broadcast(vm.envUint("PRIVATE_KEY")); + app.removeDeviceAtEpochs(from, to, deviceId); + } console.log("Removed device:", vm.toString(deviceId)); } @@ -331,3 +448,14 @@ contract DisableAppUpgrades is BaseScript { // a live proxy to the current `contracts/DstackKms.sol` / `contracts/DstackApp.sol` // source. The `UpgradeKmsToV2` / `UpgradeAppToV2` variants target the V2 test // mocks under `contracts/test-utils/` and are for upgrade-flow tests only. + +contract AdvanceAppEpoch is BaseScript { + function run() external { + require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); + uint256 expected = vm.envUint("EXPECTED_APP_EPOCH"); + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + app.advanceAppEpoch(expected); + vm.stopBroadcast(); + console.log("Current app epoch:", app.currentAppEpoch()); + } +} diff --git a/dstack/kms/auth-eth/script/Query.s.sol b/dstack/kms/auth-eth/script/Query.s.sol index 4a9a01496..534b2de46 100644 --- a/dstack/kms/auth-eth/script/Query.s.sol +++ b/dstack/kms/auth-eth/script/Query.s.sol @@ -124,7 +124,7 @@ contract CheckKmsAllowed is BaseQueryScript { require(address(kms) != address(0), "KMS_CONTRACT_ADDR not set"); // Read boot info from environment - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: vm.envAddress("APP_ID"), composeHash: vm.envBytes32("COMPOSE_HASH"), instanceId: vm.envOr("INSTANCE_ID", address(0)), @@ -150,7 +150,7 @@ contract CheckAppAllowed is BaseQueryScript { require(address(kms) != address(0), "KMS_CONTRACT_ADDR not set"); // Read boot info from environment - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: vm.envAddress("APP_ID"), composeHash: vm.envBytes32("COMPOSE_HASH"), instanceId: vm.envOr("INSTANCE_ID", address(0)), @@ -211,6 +211,22 @@ contract GetKmsSettings is BaseQueryScript { console.log(" Quote length:", quote.length); console.log(" Eventlog length:", eventlog.length); + uint256 epochCount = kms.rootEpochCount(); + console.log("\nRegistered root epochs:", epochCount); + if (epochCount > 0) { + (uint256 current,) = kms.currentRootEpoch(); + console.log("Current root epoch:", current); + } + for (uint256 epoch = 0; epoch < epochCount; epoch++) { + (bytes memory epochCa, bytes memory epochK256, string memory evidenceUrl, bytes32 chainHash) = + kms.rootEpoch(epoch); + console.log(" Epoch", epoch); + console.log(" CA Pubkey:", vm.toString(epochCa)); + console.log(" K256 Pubkey:", vm.toString(epochK256)); + console.log(" Evidence URL:", evidenceUrl); + console.log(" Chain hash:", vm.toString(chainHash)); + } + // Check implementation via storage bytes32 implSlot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; address implementation = address(uint160(uint256(vm.load(address(kms), implSlot)))); @@ -234,3 +250,11 @@ contract GetAppSettings is BaseQueryScript { console.log("\nProxy Implementation:", implementation); } } + +contract GetAppEpoch is BaseQueryScript { + function run() external view { + require(address(kms) != address(0), "KMS_CONTRACT_ADDR not set"); + require(address(app) != address(0), "APP_CONTRACT_ADDR not set"); + console.log("Current app epoch:", kms.currentAppEpoch(address(app))); + } +} diff --git a/dstack/kms/auth-eth/script/README.md b/dstack/kms/auth-eth/script/README.md index 8ff68a00e..1f50fae69 100644 --- a/dstack/kms/auth-eth/script/README.md +++ b/dstack/kms/auth-eth/script/README.md @@ -28,21 +28,26 @@ Type-safe contract management scripts. Set the environment variables and run the ### KMS Management +The KMS material scripts below write root epoch rows: `ROOT_EPOCH=e` one row, or +`FROM_ROOT_EPOCH` through `TO_ROOT_EPOCH`. One of them is required. A request +may select any epoch up to the current one, so revoke a value with +`FROM_ROOT_EPOCH=0` and `TO_ROOT_EPOCH` the latest registered epoch. + #### Add KMS Aggregated MR ```bash -KMS_CONTRACT_ADDR=0x... MR_AGGREGATED=0x1234... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 MR_AGGREGATED=0x1234... \ forge script script/Manage.s.sol:AddKmsAggregatedMr --broadcast --rpc-url $RPC_URL ``` #### Add OS Image ```bash -KMS_CONTRACT_ADDR=0x... OS_IMAGE_HASH=0x1234... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 OS_IMAGE_HASH=0x1234... \ forge script script/Manage.s.sol:AddOsImage --broadcast --rpc-url $RPC_URL ``` #### Add KMS Device ```bash -KMS_CONTRACT_ADDR=0x... DEVICE_ID=0x1234... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 DEVICE_ID=0x1234... \ forge script script/Manage.s.sol:AddKmsDevice --broadcast --rpc-url $RPC_URL ``` @@ -52,27 +57,67 @@ KMS_CONTRACT_ADDR=0x... GATEWAY_APP_ID="my-gateway" \ forge script script/Manage.s.sol:SetGatewayAppId --broadcast --rpc-url $RPC_URL ``` -#### Set KMS Info +#### Register a KMS Root Epoch +Use this for initial setup as well as subsequent root rotations. The legacy +`setKmsInfo`, `setKmsQuote`, and `setKmsEventlog` entry points have been removed; +calls to them revert even before epoch 0 is registered. For an upgraded proxy +with legacy roots in `kmsInfo`, epoch 0 must register those same public keys. + +Appends the next root epoch (the original roots are epoch 0). Epoch 0 takes +effect when registered; later epochs take effect when activated, and each takes +over `kmsInfo` then, clearing its quote and event log. `EVIDENCE_URL` +points to the attestation the KMS returned when it generated the keys (Bootstrap, +or Onboard with `rotate`), published where verifiers can fetch it; verify it +before registering. Registered keys cannot be changed, only a pending epoch popped; +the evidence URL can be updated when the evidence moves. +```bash +KMS_CONTRACT_ADDR=0x... CA_PUBKEY=0x... K256_PUBKEY=0x... EVIDENCE_URL=ipfs://... \ +forge script script/Manage.s.sol:RegisterRootEpoch --broadcast --rpc-url $RPC_URL +``` + +#### Activate a KMS Root Epoch +Makes the next registered root epoch the one KMS instances serve. Activate it +only once every KMS node holds it; nodes still at the previous epoch stop +serving, and nodes holding it restart to switch. Epochs only move forward. +`CHAIN_HASH` is the expected epoch chain hash (see `Query.s.sol`), +so a stale activation cannot activate replacement keys at the same epoch. ```bash -KMS_CONTRACT_ADDR=0x... K256_PUBKEY=0x... CA_PUBKEY=0x... QUOTE=0x... EVENTLOG=0x... \ -forge script script/Manage.s.sol:SetKmsInfo --broadcast --rpc-url $RPC_URL +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=1 CHAIN_HASH=0x... \ +forge script script/Manage.s.sol:ActivateRootEpoch --broadcast --rpc-url $RPC_URL +``` + +#### Update a Root Epoch's Evidence URL +Points an epoch at a new copy of its attestation, e.g. when the old host goes away. +```bash +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 EVIDENCE_URL=ipfs://... \ +forge script script/Manage.s.sol:SetRootEpochEvidenceUrl --broadcast --rpc-url $RPC_URL +``` + +#### Pop a Pending KMS Root Epoch +Drops the last registered epoch while it is not yet current, e.g. when the nodes +holding it are lost before it could be activated. Re-rotate to register a new one. +`CHAIN_HASH` is the epoch's chain hash (see `Query.s.sol`), so only that exact +registration is dropped. +```bash +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=1 CHAIN_HASH=0x... \ +forge script script/Manage.s.sol:PopRootEpoch --broadcast --rpc-url $RPC_URL ``` #### Remove KMS Aggregated MR ```bash -KMS_CONTRACT_ADDR=0x... MR_AGGREGATED=0x1234... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 MR_AGGREGATED=0x1234... \ forge script script/Manage.s.sol:RemoveKmsAggregatedMr --broadcast --rpc-url $RPC_URL ``` #### Remove OS Image ```bash -KMS_CONTRACT_ADDR=0x... OS_IMAGE_HASH=0x1234... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 OS_IMAGE_HASH=0x1234... \ forge script script/Manage.s.sol:RemoveOsImage --broadcast --rpc-url $RPC_URL ``` #### Remove KMS Device ```bash -KMS_CONTRACT_ADDR=0x... DEVICE_ID=0x1234... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 DEVICE_ID=0x1234... \ forge script script/Manage.s.sol:RemoveKmsDevice --broadcast --rpc-url $RPC_URL ``` @@ -91,7 +136,7 @@ forge script script/Manage.s.sol:RegisterApp --broadcast --rpc-url $RPC_URL #### Batch KMS Setup Configure multiple settings at once: ```bash -KMS_CONTRACT_ADDR=0x... \ +KMS_CONTRACT_ADDR=0x... ROOT_EPOCH=0 \ GATEWAY_APP_ID="my-gateway" \ MR_AGGREGATED_LIST=0x1111...,0x2222...,0x3333... \ OS_IMAGE_LIST=0xaaaa...,0xbbbb... \ @@ -101,33 +146,39 @@ forge script script/Manage.s.sol:BatchKmsSetup --broadcast --rpc-url $RPC_URL ### App Management +The app scripts write app epoch rows: `APP_EPOCH=e`, or `FROM_APP_EPOCH` through +`TO_APP_EPOCH` (at most the next epoch); one of them is required. Revoke a value +with `FROM_APP_EPOCH=0` and `TO_APP_EPOCH` the current app epoch. App contracts +deployed before app epochs (no `IAppEpochManagement`) have only app epoch 0: the +scripts call their epoch-less setters and accept only `APP_EPOCH=0` or no epoch. + #### Add Compose Hash ```bash -KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... COMPOSE_HASH=0x1234... \ +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... APP_EPOCH=0 COMPOSE_HASH=0x1234... \ forge script script/Manage.s.sol:AddComposeHash --broadcast --rpc-url $RPC_URL ``` #### Add Device ```bash -KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... DEVICE_ID=0x1234... \ +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... APP_EPOCH=0 DEVICE_ID=0x1234... \ forge script script/Manage.s.sol:AddDevice --broadcast --rpc-url $RPC_URL ``` #### Set Allow Any Device ```bash -KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... ALLOW_ANY_DEVICE=true \ +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... APP_EPOCH=0 ALLOW_ANY_DEVICE=true \ forge script script/Manage.s.sol:SetAllowAnyDevice --broadcast --rpc-url $RPC_URL ``` #### Remove Compose Hash ```bash -KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... COMPOSE_HASH=0x1234... \ +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... APP_EPOCH=0 COMPOSE_HASH=0x1234... \ forge script script/Manage.s.sol:RemoveComposeHash --broadcast --rpc-url $RPC_URL ``` #### Remove Device ```bash -KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... DEVICE_ID=0x1234... \ +KMS_CONTRACT_ADDR=0x... APP_CONTRACT_ADDR=0x... APP_EPOCH=0 DEVICE_ID=0x1234... \ forge script script/Manage.s.sol:RemoveDevice --broadcast --rpc-url $RPC_URL ``` diff --git a/dstack/kms/auth-eth/scripts/README.md b/dstack/kms/auth-eth/scripts/README.md deleted file mode 100644 index c58b743d7..000000000 --- a/dstack/kms/auth-eth/scripts/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# Test Scripts - -This directory contains automated test scripts for the dstack KMS Ethereum backend. - -## Scripts Overview - -### 🚀 setup-local-chain.sh -Sets up a local Anvil blockchain and deploys the dstack contracts. -- Starts Anvil on port 8545 -- Deploys DstackKms and DstackApp contracts -- Saves configuration to `.env.test` - -```bash -npm run test:setup -# or -./scripts/setup-local-chain.sh -``` - -### 🧪 run-tests.sh -Runs all tests against the deployed contracts on the local chain. -- Requires local chain to be already set up -- Runs Jest unit tests -- Runs integration tests -- Optionally runs Foundry tests - -```bash -npm run test:run # Run JS/TS tests only -npm run test:run:foundry # Include Foundry tests -# or -./scripts/run-tests.sh -./scripts/run-tests.sh --with-foundry -``` - -### 🎯 test-all.sh -Complete test suite - sets up chain and runs all tests. -- Combines setup-local-chain.sh and run-tests.sh -- Perfect for CI/CD or fresh test runs - -```bash -npm run test:all # Complete test suite -npm run test:all:foundry # Include Foundry tests -# or -./scripts/test-all.sh -./scripts/test-all.sh --with-foundry -``` - -### 🧹 cleanup.sh -Cleans up all test processes and temporary files. -- Stops Anvil and API server -- Removes temporary files and logs - -```bash -npm run test:cleanup -# or -./scripts/cleanup.sh -``` - -## Typical Workflows - -### One-time Setup, Multiple Test Runs -```bash -# Set up once -npm run test:setup - -# Run tests multiple times -npm run test:run -npm run test:run -npm run test:run - -# Clean up when done -npm run test:cleanup -``` - -### Complete Test Run -```bash -# Run everything in one go -npm run test:all - -# Or with Foundry tests -npm run test:all:foundry -``` - -### Development Workflow -```bash -# Set up chain -npm run test:setup - -# Keep chain running, develop and test -npm run test:run -# ... make changes ... -npm run test:run -# ... make more changes ... -npm run test:run - -# Clean up when done -npm run test:cleanup -``` - -## Environment Variables - -After running `setup-local-chain.sh`, the following environment variables are saved to `.env.test`: - -- `ANVIL_PID` - Process ID of the Anvil instance -- `ETH_RPC_URL` - RPC endpoint (http://127.0.0.1:8545) -- `CHAIN_ID` - Chain ID (31337) -- `KMS_CONTRACT_ADDR` - Deployed KMS proxy contract address -- `APP_IMPLEMENTATION` - DstackApp implementation address -- `KMS_IMPLEMENTATION` - DstackKms implementation address -- `DEPLOYER_ADDRESS` - Address that deployed the contracts -- `DEPLOYER_PRIVATE_KEY` - Private key of the deployer - -## Logs - -The scripts generate the following log files: - -- `anvil.log` - Anvil blockchain logs -- `deploy.log` - Contract deployment logs -- `server-test.log` - API server logs during tests - -## Requirements - -- Node.js and npm -- Foundry (forge, anvil) -- All npm dependencies installed (`npm install`) diff --git a/dstack/kms/auth-eth/scripts/cleanup.sh b/dstack/kms/auth-eth/scripts/cleanup.sh deleted file mode 100755 index 8b428d4b2..000000000 --- a/dstack/kms/auth-eth/scripts/cleanup.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -# Cleanup script to stop all test processes - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ENV_FILE="$PROJECT_ROOT/.env.test" - -# Colors for output -GREEN='\033[0;32m' -NC='\033[0m' - -echo "🧹 Cleaning up test environment..." - -# Kill any running API servers -echo "Stopping API servers..." -pkill -f "ts-node src/main.ts" || true -pkill -f "node dist/main.js" || true - -# Load environment if exists -if [ -f "$ENV_FILE" ]; then - # shellcheck source=/dev/null - source "$ENV_FILE" - - # Kill Anvil if PID is set - if [ -n "$ANVIL_PID" ]; then - echo "Stopping Anvil (PID: $ANVIL_PID)..." - kill "$ANVIL_PID" 2>/dev/null || true - fi -fi - -# Kill any other Anvil processes -echo "Stopping any other Anvil processes..." -pkill -f "anvil" || true - -# Clean up files -echo "Removing temporary files..." -rm -f "$PROJECT_ROOT/.env.test" -rm -f "$PROJECT_ROOT/anvil.log" -rm -f "$PROJECT_ROOT/deploy.log" -rm -f "$PROJECT_ROOT/server-test.log" -rm -f "$PROJECT_ROOT/integration-test.js" - -echo -e "${GREEN}✅ Cleanup complete!${NC}" diff --git a/dstack/kms/auth-eth/scripts/run-tests.sh b/dstack/kms/auth-eth/scripts/run-tests.sh deleted file mode 100755 index 0835def67..000000000 --- a/dstack/kms/auth-eth/scripts/run-tests.sh +++ /dev/null @@ -1,265 +0,0 @@ -#!/bin/bash - -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -# Script to run all tests against the local chain - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ENV_FILE="$PROJECT_ROOT/.env.test" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo "🧪 Running test suite..." - -# Check if environment file exists -if [ ! -f "$ENV_FILE" ]; then - echo -e "${RED}❌ Error: Environment file not found at $ENV_FILE${NC}" - echo -e "${YELLOW}Please run ./scripts/setup-local-chain.sh first${NC}" - exit 1 -fi - -# Load environment variables -# shellcheck source=/dev/null -source "$ENV_FILE" - -# Export for child processes -export ETH_RPC_URL -export KMS_CONTRACT_ADDR -export APP_IMPLEMENTATION - -# Check if Anvil is running -if ! kill -0 "$ANVIL_PID" 2>/dev/null; then - echo -e "${RED}❌ Error: Anvil is not running (PID: $ANVIL_PID)${NC}" - echo -e "${YELLOW}Please run ./scripts/setup-local-chain.sh first${NC}" - exit 1 -fi - -echo -e "${GREEN}✅ Local chain is running${NC}" -echo " KMS Contract: $KMS_CONTRACT_ADDR" -echo " App Implementation: $APP_IMPLEMENTATION" -echo "" - -# Change to project root -cd "$PROJECT_ROOT" - -# Build TypeScript -echo -e "${BLUE}🔨 Building TypeScript...${NC}" -npm run build - -# Function to cleanup on exit -cleanup() { - echo "" - echo "🧹 Cleaning up..." - if [ -n "$SERVER_PID" ]; then - kill "$SERVER_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -# Start API server -echo -e "${BLUE}🌐 Starting API server...${NC}" -npm run dev > "$PROJECT_ROOT/server-test.log" 2>&1 & -SERVER_PID=$! - -# Wait for server to be ready -echo "⏳ Waiting for API server to start..." -for i in {1..30}; do - if curl -s http://127.0.0.1:8000 > /dev/null 2>&1; then - echo -e "${GREEN}✅ API server is ready!${NC}" - break - fi - if [ "$i" -eq 30 ]; then - echo -e "${RED}❌ API server failed to start within 30 seconds${NC}" - cat "$PROJECT_ROOT/server-test.log" - exit 1 - fi - sleep 1 -done - -# Run Jest unit tests -echo "" -echo -e "${BLUE}📋 Running Jest unit tests...${NC}" -npm test - -# Run integration tests -echo "" -echo -e "${BLUE}🔗 Running integration tests...${NC}" - -# Create and run integration test -cat > "$PROJECT_ROOT/integration-test.js" << 'EOF' -const { ethers } = require('ethers'); - -async function runIntegrationTests() { - const baseUrl = 'http://127.0.0.1:8000'; - const rpcUrl = process.env.ETH_RPC_URL; - const kmsAddress = process.env.KMS_CONTRACT_ADDR; - - console.log('Testing against:'); - console.log(' API URL:', baseUrl); - console.log(' RPC URL:', rpcUrl); - console.log(' KMS Contract:', kmsAddress); - console.log(''); - - const testData = { - tcbStatus: 'UpToDate', - advisoryIds: [], - mrAggregated: '0x' + '1234567890abcdef'.repeat(4), - osImageHash: '0x' + 'abcdefabcdefabcd'.repeat(4), - mrSystem: '0x' + '9012901290129012'.repeat(4), - appId: '0x9012345678901234567890123456789012345678', - composeHash: '0x' + 'abcdabcdabcdabcd'.repeat(4), - instanceId: '0x3456789012345678901234567890123456789012', - deviceId: '0x' + 'ef12ef12ef12ef12'.repeat(4) - }; - - const tests = [ - { - name: 'Health Check', - run: async () => { - const res = await fetch(baseUrl + '/'); - const data = await res.json(); - return { - passed: res.status === 200 && data.kmsContractAddr === kmsAddress, - details: `Status: ${res.status}, Contract: ${data.kmsContractAddr}` - }; - } - }, - { - name: 'App Authorization', - run: async () => { - const res = await fetch(baseUrl + '/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(testData) - }); - const data = await res.json(); - return { - passed: res.status === 200 && data.hasOwnProperty('isAllowed'), - details: `Status: ${res.status}, Response: ${JSON.stringify(data)}` - }; - } - }, - { - name: 'KMS Authorization', - run: async () => { - const res = await fetch(baseUrl + '/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(testData) - }); - const data = await res.json(); - return { - passed: res.status === 200 && data.hasOwnProperty('isAllowed'), - details: `Status: ${res.status}, Response: ${JSON.stringify(data)}` - }; - } - }, - { - name: 'Invalid Request Validation', - run: async () => { - const res = await fetch(baseUrl + '/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mrAggregated: '0x1234' }) - }); - return { - passed: res.status === 400, - details: `Status: ${res.status} (expected 400)` - }; - } - }, - { - name: 'Contract Interaction', - run: async () => { - const provider = new ethers.JsonRpcProvider(rpcUrl); - const abi = ['function owner() view returns (address)']; - const contract = new ethers.Contract(kmsAddress, abi, provider); - const owner = await contract.owner(); - return { - passed: ethers.isAddress(owner), - details: `Owner: ${owner}` - }; - } - } - ]; - - let passed = 0; - let failed = 0; - - for (const test of tests) { - try { - const result = await test.run(); - if (result.passed) { - console.log(`✅ ${test.name}`); - console.log(` ${result.details}`); - passed++; - } else { - console.log(`❌ ${test.name}`); - console.log(` ${result.details}`); - failed++; - } - } catch (error) { - console.log(`❌ ${test.name}`); - console.log(` Error: ${error.message}`); - failed++; - } - } - - console.log(''); - console.log(`Summary: ${passed} passed, ${failed} failed`); - - return failed === 0; -} - -runIntegrationTests().then(success => { - process.exit(success ? 0 : 1); -}).catch(error => { - console.error('Test runner error:', error); - process.exit(1); -}); -EOF - -node "$PROJECT_ROOT/integration-test.js" -INTEGRATION_RESULT=$? - -# Clean up integration test file -rm -f "$PROJECT_ROOT/integration-test.js" - -# Run Foundry tests -echo "" -echo -e "${BLUE}🔨 Running Foundry tests...${NC}" -ETHERSCAN_API_KEY=dummy forge test --ffi --rpc-url "$ETH_RPC_URL" -FOUNDRY_RESULT=$? - -# Summary -echo "" -echo "📊 Test Summary:" -echo " Jest Tests: ${GREEN}✅ Passed${NC}" -if [ $INTEGRATION_RESULT -eq 0 ]; then - echo " Integration Tests: ${GREEN}✅ Passed${NC}" -else - echo " Integration Tests: ${RED}❌ Failed${NC}" -fi -if [ $FOUNDRY_RESULT -eq 0 ]; then - echo " Foundry Tests: ${GREEN}✅ Passed${NC}" -else - echo " Foundry Tests: ${RED}❌ Failed${NC}" -fi - -# Exit with appropriate code -if [ $INTEGRATION_RESULT -ne 0 ] || [ $FOUNDRY_RESULT -ne 0 ]; then - exit 1 -fi - -echo "" -echo -e "${GREEN}✅ All tests passed!${NC}" diff --git a/dstack/kms/auth-eth/scripts/setup-local-chain.sh b/dstack/kms/auth-eth/scripts/setup-local-chain.sh deleted file mode 100755 index ef306eb99..000000000 --- a/dstack/kms/auth-eth/scripts/setup-local-chain.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/bin/bash - -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -# Script to set up local Anvil chain and deploy contracts - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ENV_FILE="$PROJECT_ROOT/.env.test" - -echo "🔧 Setting up local test environment..." - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Check if anvil is available -if ! command -v anvil &> /dev/null; then - echo -e "${RED}❌ Error: anvil not found. Install Foundry first:${NC}" - echo "curl -L https://foundry.paradigm.xyz | bash" - echo "foundryup" - exit 1 -fi - -# Clean up any existing Anvil process -echo "🧹 Cleaning up existing Anvil processes..." -pkill -f "anvil" || true -sleep 1 - -# Start Anvil in the background -echo "🚀 Starting Anvil local node..." -anvil \ - --host 0.0.0.0 \ - --port 8545 \ - --accounts 10 \ - --balance 1000 \ - --block-time 1 \ - > "$PROJECT_ROOT/anvil.log" 2>&1 & - -ANVIL_PID=$! -echo " Anvil PID: $ANVIL_PID" - -# Wait for Anvil to be ready -echo "⏳ Waiting for Anvil to start..." -for i in {1..30}; do - if curl -s http://127.0.0.1:8545 > /dev/null 2>&1; then - echo -e "${GREEN}✅ Anvil is ready!${NC}" - break - fi - if [ "$i" -eq 30 ]; then - echo -e "${RED}❌ Anvil failed to start within 30 seconds${NC}" - cat "$PROJECT_ROOT/anvil.log" - exit 1 - fi - sleep 1 -done - -# Deploy contracts -echo "📦 Deploying contracts..." -cd "$PROJECT_ROOT" - -# Use the first Anvil account private key -PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - -# Run deployment and capture output -DEPLOY_OUTPUT=$(PRIVATE_KEY=$PRIVATE_KEY \ - ETHERSCAN_API_KEY=dummy \ - forge script script/Deploy.s.sol:DeployScript \ - --broadcast \ - --rpc-url http://127.0.0.1:8545 \ - -vvv 2>&1) - -echo "$DEPLOY_OUTPUT" > "$PROJECT_ROOT/deploy.log" - -# Extract contract addresses -KMS_PROXY=$(echo "$DEPLOY_OUTPUT" | grep "DstackKms proxy deployed to:" | awk '{print $NF}') -APP_IMPL=$(echo "$DEPLOY_OUTPUT" | grep "DstackApp implementation deployed to:" | awk '{print $NF}') -KMS_IMPL=$(echo "$DEPLOY_OUTPUT" | grep "DstackKms implementation deployed to:" | awk '{print $NF}') - -if [ -z "$KMS_PROXY" ] || [ -z "$APP_IMPL" ]; then - echo -e "${RED}❌ Failed to extract contract addresses from deployment${NC}" - echo "Check deploy.log for details" - kill $ANVIL_PID 2>/dev/null - exit 1 -fi - -# Save environment variables -cat > "$ENV_FILE" << EOF -# Auto-generated test environment configuration -# Generated at: $(date) - -# Anvil Configuration -ANVIL_PID=$ANVIL_PID -ETH_RPC_URL=http://127.0.0.1:8545 -CHAIN_ID=31337 - -# Deployed Contracts -KMS_CONTRACT_ADDR=$KMS_PROXY -APP_IMPLEMENTATION=$APP_IMPL -KMS_IMPLEMENTATION=$KMS_IMPL - -# Test Account (Anvil account #0) -DEPLOYER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -DEPLOYER_PRIVATE_KEY=$PRIVATE_KEY -EOF - -echo -e "${GREEN}✅ Local chain setup complete!${NC}" -echo "" -echo "📊 Deployment Summary:" -echo " Chain ID: 31337" -echo " RPC URL: http://127.0.0.1:8545" -echo " KMS Proxy: $KMS_PROXY" -echo " App Implementation: $APP_IMPL" -echo "" -echo "📄 Configuration saved to: $ENV_FILE" -echo "" -echo "🔍 Logs available at:" -echo " Anvil: $PROJECT_ROOT/anvil.log" -echo " Deploy: $PROJECT_ROOT/deploy.log" -echo "" -echo -e "${YELLOW}ℹ️ To stop Anvil: kill $ANVIL_PID${NC}" -echo -e "${YELLOW}ℹ️ To run tests: ./scripts/run-tests.sh${NC}" diff --git a/dstack/kms/auth-eth/scripts/test-all.sh b/dstack/kms/auth-eth/scripts/test-all.sh deleted file mode 100755 index 581495233..000000000 --- a/dstack/kms/auth-eth/scripts/test-all.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -# Complete test runner - sets up chain and runs all tests - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Colors for output -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo "🚀 Complete Test Suite" -echo "====================" -echo "" - -# Setup local chain -echo "Step 1: Setting up local chain..." -"$SCRIPT_DIR/setup-local-chain.sh" - -echo "" -echo "Step 2: Running tests..." -"$SCRIPT_DIR/run-tests.sh" "$@" - -# Load env to get PID -# shellcheck source=/dev/null -source "$SCRIPT_DIR/../.env.test" - -echo "" -echo -e "${GREEN}✅ Complete test suite finished!${NC}" -echo "" -echo -e "${YELLOW}ℹ️ To stop the local chain: kill $ANVIL_PID${NC}" -echo -e "${YELLOW}ℹ️ To run tests again: ./scripts/run-tests.sh${NC}" diff --git a/dstack/kms/auth-eth/src/ethereum.ts b/dstack/kms/auth-eth/src/ethereum.ts deleted file mode 100644 index ebd655140..000000000 --- a/dstack/kms/auth-eth/src/ethereum.ts +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { ethers } from 'ethers'; -import { BootInfo, BootResponse } from './types'; - -// Minimal ABI for DstackKms contract -const DSTACK_KMS_ABI = [ - "function isAppAllowed((address appId,bytes32 composeHash,address instanceId,bytes32 deviceId,bytes32 mrAggregated,bytes32 mrSystem,bytes32 osImageHash,string tcbStatus,string[] advisoryIds) bootInfo) view returns (bool, string)", - "function isKmsAllowed((address appId,bytes32 composeHash,address instanceId,bytes32 deviceId,bytes32 mrAggregated,bytes32 mrSystem,bytes32 osImageHash,string tcbStatus,string[] advisoryIds) bootInfo) view returns (bool, string)", - "function gatewayAppId() view returns (string)", - "function appImplementation() view returns (address)" -]; - -export class EthereumBackend { - private provider: ethers.JsonRpcProvider; - private kmsContract: ethers.Contract; - - constructor(provider: ethers.JsonRpcProvider, kmsContractAddr: string) { - this.provider = provider; - this.kmsContract = new ethers.Contract( - ethers.getAddress(kmsContractAddr), - DSTACK_KMS_ABI, - provider - ); - } - - private decodeHex(hex: string, sz: number = 32): string { - // Remove '0x' prefix if present - hex = hex.startsWith('0x') ? hex.slice(2) : hex; - - // Pad hex string to 64 characters (32 bytes) - hex = hex.padStart(sz * 2, '0'); - - // Add '0x' prefix back - return '0x' + hex; - } - - async checkBoot(bootInfo: BootInfo, isKms: boolean): Promise { - // Create boot info struct for contract call - const bootInfoStruct = { - appId: this.decodeHex(bootInfo.appId, 20), - instanceId: this.decodeHex(bootInfo.instanceId, 20), - composeHash: this.decodeHex(bootInfo.composeHash, 32), - deviceId: this.decodeHex(bootInfo.deviceId, 32), - mrSystem: this.decodeHex(bootInfo.mrSystem, 32), - mrAggregated: this.decodeHex(bootInfo.mrAggregated, 32), - osImageHash: this.decodeHex(bootInfo.osImageHash, 32), - tcbStatus: bootInfo.tcbStatus, - advisoryIds: bootInfo.advisoryIds - }; - // Read the whole decision from one block. - const blockTag = await this.provider.getBlockNumber(); - let response; - if (isKms) { - response = await this.kmsContract.isKmsAllowed(bootInfoStruct, { blockTag }); - } else { - response = await this.kmsContract.isAppAllowed(bootInfoStruct, { blockTag }); - } - const [isAllowed, reason] = response; - const gatewayAppId = await this.kmsContract.gatewayAppId({ blockTag }); - return { - isAllowed, - reason, - gatewayAppId, - } - } - - async getGatewayAppId(): Promise { - return await this.kmsContract.gatewayAppId(); - } - - async getChainId(): Promise { - const chainId = await this.provider.getNetwork().then((network) => network.chainId); - return Number(chainId); - } - - async getAppImplementation(): Promise { - return await this.kmsContract.appImplementation(); - } -} diff --git a/dstack/kms/auth-eth/src/main.test.ts b/dstack/kms/auth-eth/src/main.test.ts deleted file mode 100644 index 842f1e36b..000000000 --- a/dstack/kms/auth-eth/src/main.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { FastifyInstance } from 'fastify'; -import { build } from './server'; -import { BootInfo } from './types'; - -// Mock EthereumBackend -jest.mock('./ethereum', () => { - return { - EthereumBackend: jest.fn().mockImplementation(() => ({ - checkBoot: jest.fn() - })) - }; -}); - -describe('Server', () => { - let app: FastifyInstance; - const mockBootInfo: BootInfo = { - tcbStatus: "UpToDate", - advisoryIds: [], - mrAggregated: '0x1234', - osImageHash: '0x5678', - mrSystem: '0x9012', - appId: '0x9012345678901234567890123456789012345678', - composeHash: '0xabcd', - instanceId: '0x3456789012345678901234567890123456789012', - deviceId: '0xef12' - }; - - beforeAll(async () => { - app = await build(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('POST /bootAuth/app', () => { - it('should return 200 when backend check passes', async () => { - // Mock successful response - const mockCheckBoot = jest.fn().mockResolvedValue({ - isAllowed: true, - reason: '', - gatewayAppId: '' - }); - app.ethereum.checkBoot = mockCheckBoot; - - const response = await app.inject({ - method: 'POST', - url: '/bootAuth/app', - payload: mockBootInfo - }); - - expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); - expect(result.isAllowed).toBe(true); - expect(result.reason).toBe(''); - expect(mockCheckBoot).toHaveBeenCalledWith(mockBootInfo, false); - }); - - it('should reject oversized and non-hex measurements before backend use', async () => { - jest.clearAllMocks(); - for (const mrAggregated of ['0x' + 'ab'.repeat(33), 'not-hex']) { - const response = await app.inject({ - method: 'POST', - url: '/bootAuth/app', - payload: { ...mockBootInfo, mrAggregated } - }); - expect(response.statusCode).toBe(400); - expect(JSON.parse(response.payload)).toEqual({ isAllowed: false, reason: 'invalid authorization request', gatewayAppId: '' }); - } - expect(app.ethereum.checkBoot).not.toHaveBeenCalled(); - }); - - it('should return 400 for invalid boot info', async () => { - const response = await app.inject({ - method: 'POST', - url: '/bootAuth/app', - payload: { - // Missing required fields - mrAggregated: '0x1234' - } - }); - - expect(response.statusCode).toBe(400); - }); - }); - - describe('POST /bootAuth/kms', () => { - it('should return 200 when backend check passes', async () => { - // Mock successful response - const mockCheckBoot = jest.fn().mockResolvedValue({ - isAllowed: true, - reason: '', - gatewayAppId: '0x1234', - }); - app.ethereum.checkBoot = mockCheckBoot; - - const response = await app.inject({ - method: 'POST', - url: '/bootAuth/kms', - payload: mockBootInfo, - }); - - const result = JSON.parse(response.payload); - expect(response.statusCode).toBe(200); - expect(result.isAllowed).toBe(true); - expect(result.reason).toBe(''); - expect(mockCheckBoot).toHaveBeenCalledWith(mockBootInfo, true); - }); - - it('should handle backend errors gracefully', async () => { - // Mock error response - const mockCheckBoot = jest.fn().mockRejectedValue(new Error('Test backend error')); - app.ethereum.checkBoot = mockCheckBoot; - - const response = await app.inject({ - method: 'POST', - url: '/bootAuth/kms', - payload: mockBootInfo - }); - - expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); - expect(result.isAllowed).toBe(false); - expect(result.reason).toBe('authorization backend unavailable'); - }); - }); -}); diff --git a/dstack/kms/auth-eth/src/main.ts b/dstack/kms/auth-eth/src/main.ts deleted file mode 100644 index cdbf2e7c5..000000000 --- a/dstack/kms/auth-eth/src/main.ts +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { config } from 'dotenv'; -import { build } from './server'; - -// Load environment variables -config(); - -async function main() { - try { - const port = process.env.PORT ? parseInt(process.env.PORT) : 8000; - const host = process.env.HOST || '127.0.0.1'; - - const server = await build(); - await server.listen({ port, host }); - console.log(`Server listening on ${host}:${port}`); - - // Handle graceful shutdown - const signals = ['SIGINT', 'SIGTERM']; - for (const signal of signals) { - process.on(signal, async () => { - try { - await server.close(); - process.exit(0); - } catch (err) { - console.error('Error during shutdown:', err); - process.exit(1); - } - }); - } - } catch (err) { - console.error('Error starting server:', err); - process.exit(1); - } -} - -main(); diff --git a/dstack/kms/auth-eth/src/server.ts b/dstack/kms/auth-eth/src/server.ts deleted file mode 100644 index 54dd14346..000000000 --- a/dstack/kms/auth-eth/src/server.ts +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import fastify, { FastifyInstance } from 'fastify'; -import { EthereumBackend } from './ethereum'; -import { BootInfo, BootResponse } from './types'; -import { ethers } from 'ethers'; - -declare module 'fastify' { - interface FastifyInstance { - ethereum: EthereumBackend; - } -} - -export async function build(): Promise { - const server = fastify({ - logger: true - }); - - // Register schema for request/response validation - const hex = (bytes: number, description: string) => ({ - type: 'string', - pattern: `^(?:0x[0-9a-fA-F]{0,${bytes * 2}}|[0-9a-fA-F]{0,${bytes * 2}})$`, - description, - }); - - server.addSchema({ - $id: 'bootInfo', - type: 'object', - required: ['mrAggregated', 'osImageHash', 'appId', 'composeHash', 'instanceId', 'deviceId'], - properties: { - mrAggregated: hex(32, 'Aggregated MR measurement'), - osImageHash: hex(32, 'OS Image hash'), - appId: hex(20, 'Application ID'), - composeHash: hex(32, 'Compose hash'), - instanceId: hex(20, 'Instance ID'), - deviceId: hex(32, 'Device ID'), - tcbStatus: { type: 'string', maxLength: 128, default: '' }, - advisoryIds: { type: 'array', maxItems: 128, items: { type: 'string', maxLength: 256 }, default: [] }, - mrSystem: { ...hex(32, 'System MR measurement'), default: '' }, - } - }); - - server.addSchema({ - $id: 'bootResponse', - type: 'object', - required: ['isAllowed', 'reason', 'gatewayAppId'], - properties: { - isAllowed: { type: 'boolean' }, - reason: { type: 'string' }, - gatewayAppId: { type: 'string' }, - } - }); - - // Initialize backend - const rpcUrl = process.env.ETH_RPC_URL || 'http://localhost:8545'; - const kmsContractAddr = process.env.KMS_CONTRACT_ADDR || '0x0000000000000000000000000000000000000000'; - const provider = new ethers.JsonRpcProvider(rpcUrl); - server.decorate('ethereum', new EthereumBackend(provider, kmsContractAddr)); - - const publicRpcEndpoint = (value: string): string => { - try { - const endpoint = new URL(value); - return `${endpoint.protocol}//${endpoint.host}`; - } catch { - return 'configured'; - } - }; - const backendUnavailable = 'authorization backend unavailable'; - const invalidRequest = { isAllowed: false, reason: 'invalid authorization request', gatewayAppId: '' }; - - server.setErrorHandler((error, request, reply) => { - if (typeof error === 'object' && error !== null && 'validation' in error) { - return reply.code(400).send(invalidRequest); - } - request.log.error('authorization request failed'); - return reply.code(500).send({ status: 'error', message: backendUnavailable }); - }); - - server.get('/', async (_request, reply) => { - try { - const batch = await Promise.all([ - server.ethereum.getGatewayAppId(), - server.ethereum.getChainId(), - server.ethereum.getAppImplementation(), - ]); - return { - status: 'ok', - kmsContractAddr: kmsContractAddr, - ethRpcUrl: publicRpcEndpoint(rpcUrl), - gatewayAppId: batch[0], - chainId: batch[1], - appAuthImplementation: batch[2], // NOTE: for backward compatibility - appImplementation: batch[2], - }; - } catch { - _request.log.error('authorization backend health check failed'); - return reply.code(500).send({ status: 'error', message: backendUnavailable }); - } - }); - - // Define routes - server.post<{ - Body: BootInfo; - Reply: BootResponse; - }>('/bootAuth/app', { - schema: { - body: { $ref: 'bootInfo#' }, - response: { - 200: { $ref: 'bootResponse#' } - } - } - }, async (request, reply) => { - try { - return await server.ethereum.checkBoot(request.body, false); - } catch (error) { - request.log.error('application authorization backend failed'); - reply.code(200).send({ - isAllowed: false, - gatewayAppId: '', - reason: backendUnavailable - }); - } - }); - - server.post<{ - Body: BootInfo; - Reply: BootResponse; - }>('/bootAuth/kms', { - schema: { - body: { $ref: 'bootInfo#' }, - response: { - 200: { $ref: 'bootResponse#' } - } - } - }, async (request, reply) => { - try { - return await server.ethereum.checkBoot(request.body, true); - } catch (error) { - if (!(error instanceof Error && "Test backend error" == error.message)) { - request.log.error('KMS authorization backend failed'); - } - reply.code(200).send({ - isAllowed: false, - gatewayAppId: '', - reason: backendUnavailable - }); - } - }); - - return server; -} diff --git a/dstack/kms/auth-eth/src/types.ts b/dstack/kms/auth-eth/src/types.ts deleted file mode 100644 index fd1a89143..000000000 --- a/dstack/kms/auth-eth/src/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -export interface BootInfo { - tcbStatus: string; - advisoryIds: string[]; - mrAggregated: string; - mrSystem: string; - osImageHash: string; - appId: string; - composeHash: string; - instanceId: string; - deviceId: string; -} - -export interface BootResponse { - isAllowed: boolean; - gatewayAppId: string; - reason: string; -} - -// Removed KMS_CONTRACT_ABI and APP_CONTRACT_ABI since we're using typechain types now diff --git a/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol index 2af3492f8..c1ee862d2 100644 --- a/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.symbolic.t.sol @@ -111,18 +111,31 @@ contract DstackAppSymbolicTest is Test { assert(_upgradesDisabledFlag(address(app))); // base: flag is set bytes memory data; - if (which == 0) data = abi.encodeWithSelector(DstackApp.addComposeHash.selector, word); - else if (which == 1) data = abi.encodeWithSelector(DstackApp.removeComposeHash.selector, word); - else if (which == 2) data = abi.encodeWithSelector(DstackApp.addDevice.selector, word); - else if (which == 3) data = abi.encodeWithSelector(DstackApp.removeDevice.selector, word); - else if (which == 4) data = abi.encodeWithSelector(DstackApp.setAllowAnyDevice.selector, flag); - else if (which == 5) data = abi.encodeWithSelector(DstackApp.setRequireTcbUpToDate.selector, flag); - else if (which == 6) data = abi.encodeWithSelector(DstackApp.disableUpgrades.selector); - else if (which == 7) data = abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, addr, ""); - else if (which == 8) data = abi.encodeWithSelector(Ownable2StepUpgradeable.transferOwnership.selector, addr); - else if (which == 9) data = abi.encodeWithSelector(Ownable2StepUpgradeable.acceptOwnership.selector); - else if (which == 10) data = abi.encodeWithSelector(OwnableUpgradeable.renounceOwnership.selector); - else return; // outside the enumerated mutating surface + if (which == 0) { + data = abi.encodeWithSelector(DstackApp.addComposeHashAtEpoch.selector, uint256(0), word); + } else if (which == 1) { + data = abi.encodeWithSelector(DstackApp.removeComposeHashAtEpoch.selector, uint256(0), word); + } else if (which == 2) { + data = abi.encodeWithSelector(DstackApp.addDeviceAtEpoch.selector, uint256(0), word); + } else if (which == 3) { + data = abi.encodeWithSelector(DstackApp.removeDeviceAtEpoch.selector, uint256(0), word); + } else if (which == 4) { + data = abi.encodeWithSelector(DstackApp.setAllowAnyDeviceAtEpoch.selector, uint256(0), flag); + } else if (which == 5) { + data = abi.encodeWithSelector(DstackApp.setRequireTcbUpToDate.selector, flag); + } else if (which == 6) { + data = abi.encodeWithSelector(DstackApp.disableUpgrades.selector); + } else if (which == 7) { + data = abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, addr, ""); + } else if (which == 8) { + data = abi.encodeWithSelector(Ownable2StepUpgradeable.transferOwnership.selector, addr); + } else if (which == 9) { + data = abi.encodeWithSelector(Ownable2StepUpgradeable.acceptOwnership.selector); + } else if (which == 10) { + data = abi.encodeWithSelector(OwnableUpgradeable.renounceOwnership.selector); + } else { + return; // outside the enumerated mutating surface + } vm.prank(caller); (bool ok,) = address(app).call(data); diff --git a/dstack/kms/auth-eth/test/DstackApp.t.sol b/dstack/kms/auth-eth/test/DstackApp.t.sol index 9f590d2e3..a94a3d38f 100644 --- a/dstack/kms/auth-eth/test/DstackApp.t.sol +++ b/dstack/kms/auth-eth/test/DstackApp.t.sol @@ -76,7 +76,7 @@ contract DstackAppTest is Test { vm.expectEmit(true, true, true, true); emit ComposeHashAdded(hash); - app.addComposeHash(hash); + app.addComposeHashAtEpoch(0, hash); assertTrue(app.allowedComposeHashes(hash)); } @@ -84,13 +84,13 @@ contract DstackAppTest is Test { bytes32 hash = bytes32(uint256(123)); vm.startPrank(owner); - app.addComposeHash(hash); + app.addComposeHashAtEpoch(0, hash); assertTrue(app.allowedComposeHashes(hash)); vm.expectEmit(true, true, true, true); emit ComposeHashRemoved(hash); - app.removeComposeHash(hash); + app.removeComposeHashAtEpoch(0, hash); assertFalse(app.allowedComposeHashes(hash)); vm.stopPrank(); } @@ -102,7 +102,7 @@ contract DstackAppTest is Test { vm.expectEmit(true, true, true, true); emit DeviceAdded(deviceId); - app.addDevice(deviceId); + app.addDeviceAtEpoch(0, deviceId); assertTrue(app.allowedDeviceIds(deviceId)); } @@ -110,13 +110,13 @@ contract DstackAppTest is Test { bytes32 deviceId = bytes32(uint256(456)); vm.startPrank(owner); - app.addDevice(deviceId); + app.addDeviceAtEpoch(0, deviceId); assertTrue(app.allowedDeviceIds(deviceId)); vm.expectEmit(true, true, true, true); emit DeviceRemoved(deviceId); - app.removeDevice(deviceId); + app.removeDeviceAtEpoch(0, deviceId); assertFalse(app.allowedDeviceIds(deviceId)); vm.stopPrank(); } @@ -126,14 +126,14 @@ contract DstackAppTest is Test { vm.expectEmit(true, true, true, true); emit AllowAnyDeviceSet(true); - app.setAllowAnyDevice(true); + app.setAllowAnyDeviceAtEpoch(0, true); assertTrue(app.allowAnyDevice()); vm.prank(owner); vm.expectEmit(true, true, true, true); emit AllowAnyDeviceSet(false); - app.setAllowAnyDevice(false); + app.setAllowAnyDeviceAtEpoch(0, false); assertFalse(app.allowAnyDevice()); } @@ -144,19 +144,19 @@ contract DstackAppTest is Test { vm.startPrank(user); vm.expectRevert(); - app.addComposeHash(hash); + app.addComposeHashAtEpoch(0, hash); vm.expectRevert(); - app.removeComposeHash(hash); + app.removeComposeHashAtEpoch(0, hash); vm.expectRevert(); - app.addDevice(deviceId); + app.addDeviceAtEpoch(0, deviceId); vm.expectRevert(); - app.removeDevice(deviceId); + app.removeDeviceAtEpoch(0, deviceId); vm.expectRevert(); - app.setAllowAnyDevice(true); + app.setAllowAnyDeviceAtEpoch(0, true); vm.stopPrank(); } @@ -166,11 +166,11 @@ contract DstackAppTest is Test { bytes32 composeHash = bytes32(uint256(456)); vm.startPrank(owner); - app.addDevice(deviceId); - app.addComposeHash(composeHash); + app.addDeviceAtEpoch(0, deviceId); + app.addComposeHashAtEpoch(0, composeHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: address(app), composeHash: composeHash, instanceId: address(0), @@ -195,7 +195,7 @@ contract DstackAppTest is Test { // Test with allowAnyDevice = true vm.prank(owner); - app.setAllowAnyDevice(true); + app.setAllowAnyDeviceAtEpoch(0, true); (allowed, reason) = app.isAppAllowed(bootInfo); assertTrue(allowed); @@ -208,11 +208,11 @@ contract DstackAppTest is Test { bytes32 unallowedHash = bytes32(uint256(789)); vm.startPrank(owner); - app.addDevice(deviceId); - app.addComposeHash(allowedHash); + app.addDeviceAtEpoch(0, deviceId); + app.addComposeHashAtEpoch(0, allowedHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: address(app), composeHash: unallowedHash, instanceId: address(0), @@ -263,14 +263,14 @@ contract DstackAppTest is Test { bytes32 composeHash = bytes32(uint256(456)); vm.startPrank(owner); - app.addDevice(deviceId); - app.addComposeHash(composeHash); + app.addDeviceAtEpoch(0, deviceId); + app.addComposeHashAtEpoch(0, composeHash); vm.stopPrank(); string[] memory advisoryIds = new string[](1); advisoryIds[0] = "INTEL-SA-00614"; - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: address(app), composeHash: composeHash, instanceId: address(0), @@ -282,8 +282,8 @@ contract DstackAppTest is Test { advisoryIds: advisoryIds }); - // Legacy 5-arg init skips the TCB check (specification.md §3.4). This deliberately - // diverges from auth-simple's strict default; see auth-simple/index.test.ts. + // Legacy 5-arg init skips the TCB check (specification.md §3.4), like dstack-auth + // without `allowedTcbStatuses`. (bool allowed, string memory reason) = app.isAppAllowed(bootInfo); assertTrue(allowed); @@ -318,7 +318,7 @@ contract DstackAppTest is Test { assertTrue(tcbApp.allowedDeviceIds(deviceId)); assertTrue(tcbApp.allowedComposeHashes(composeHash)); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: address(tcbApp), composeHash: composeHash, instanceId: address(0), @@ -352,8 +352,8 @@ contract DstackAppTest is Test { // Test IAppAuth interface assertTrue(app.supportsInterface(0x1e079198)); - // Test IAppAuthBasicManagement interface - assertTrue(app.supportsInterface(0x8fd37527)); + // IAppAuthBasicManagement, whose epoch-less setters are gone + assertFalse(app.supportsInterface(0x8fd37527)); // Test IERC165 interface assertTrue(app.supportsInterface(0x01ffc9a7)); @@ -394,19 +394,336 @@ contract DstackAppTest is Test { // owner-only authority follows the new owner vm.prank(owner); vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", owner)); - app.addComposeHash(bytes32(uint256(1))); + app.addComposeHashAtEpoch(0, bytes32(uint256(1))); vm.prank(newOwner); - app.addComposeHash(bytes32(uint256(1))); + app.addComposeHashAtEpoch(0, bytes32(uint256(1))); assertTrue(app.allowedComposeHashes(bytes32(uint256(1)))); } function test_AddZeroValuedPolicyEntriesIsRejected() public { vm.startPrank(owner); vm.expectRevert("invalid compose hash"); - app.addComposeHash(bytes32(0)); + app.addComposeHashAtEpoch(0, bytes32(0)); vm.expectRevert("invalid device ID"); - app.addDevice(bytes32(0)); + app.addDeviceAtEpoch(0, 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 appRequest( + IAppAuth.AppInfo memory info, + OptionalUint256 memory appEpoch, + bytes32 operation + ) + internal + pure + returns (AuthorizationRequest memory) + { + return AuthorizationRequest(info, operation, appEpoch, OptionalUint256(false, 0)); + } + + function authorizeAt( + IAppAuth.AppInfo memory info, + uint256 epoch, + bytes32 operation + ) + internal + view + returns (bool, string memory) + { + AuthorizationResult memory result = app.authorizeApp(appRequest(info, OptionalUint256(true, epoch), operation)); + return (result.authorized, result.rejectionReason); + } + + function test_AuthorizeAppOwnsOperationScopeAndAppEpoch() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHashAtEpoch(0, info.composeHash); + app.addDeviceAtEpoch(0, info.deviceId); + app.advanceAppEpoch(0); + vm.stopPrank(); + + AuthorizationResult memory result = + app.authorizeApp(appRequest(info, OptionalUint256(true, 0), keccak256("kms_serve"))); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Invalid authorization operation"); + assertEq(result.currentAppEpoch, 1); + + result = app.authorizeApp(appRequest(info, OptionalUint256(true, 2), keccak256("app_get_key"))); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Requested app epoch is not active"); + assertEq(result.currentAppEpoch, 1); + + // Omission selects the current epoch, whose row is still empty. + result = app.authorizeApp(appRequest(info, OptionalUint256(false, 0), keccak256("app_get_key"))); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Compose hash not allowed"); + assertEq(result.currentAppEpoch, 1); + + result = app.authorizeApp(appRequest(info, OptionalUint256(true, 0), keccak256("app_get_key"))); + assertTrue(result.authorized); + assertEq(result.rejectionReason, ""); + assertEq(result.currentAppEpoch, 1); + // Gateway and root epoch fields belong to the KMS. + assertEq(result.gatewayAppId, ""); + assertEq(result.currentRootEpoch, 0); + assertEq(result.currentRootEpochHash, bytes32(0)); + } + + function test_AppOperationsSharePolicyButRejectUnknownOrKmsOperations() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHashAtEpoch(0, info.composeHash); + app.addDeviceAtEpoch(0, info.deviceId); + vm.stopPrank(); + (bool allowed,) = authorizeAt(info, 0, keccak256("app_get_cert")); + assertTrue(allowed); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + (allowed,) = authorizeAt(info, 0, keccak256("kms_import_keys")); + assertFalse(allowed); + (allowed,) = authorizeAt(info, 0, bytes32(0)); + assertFalse(allowed); + } + + function test_AppEpochRowsAreIndependentAndDefaultsUseCurrentEpoch() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHashAtEpoch(0, info.composeHash); + app.addDeviceAtEpoch(0, info.deviceId); + app.advanceAppEpoch(0); + bool allowed = authorizeCurrent(info); + assertFalse(allowed); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + app.addComposeHashAtEpoch(1, info.composeHash); + allowed = authorizeCurrent(info); + assertFalse(allowed, "each material must be approved in the selected row"); + app.addDeviceAtEpoch(1, info.deviceId); + allowed = authorizeCurrent(info); + assertTrue(allowed); + app.removeComposeHashAtEpoch(1, info.composeHash); + assertFalse(app.allowedComposeHashesAtEpoch(1, info.composeHash)); + assertTrue(app.allowedComposeHashes(info.composeHash)); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + app.addComposeHashAtEpoch(1, info.composeHash); + app.removeDeviceAtEpoch(0, info.deviceId); + assertFalse(app.allowedDeviceIds(info.deviceId)); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertFalse(allowed); + allowed = authorizeCurrent(info); + assertTrue(allowed, "revoking epoch zero must not revoke epoch one"); + (allowed,) = authorizeAt(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.addComposeHashAtEpoch(0, oldInfo.composeHash); + app.addDeviceAtEpoch(0, oldInfo.deviceId); + app.advanceAppEpoch(0); + app.addComposeHashAtEpoch(1, newInfo.composeHash); + app.addDeviceAtEpoch(1, newInfo.deviceId); + (bool allowed,) = authorizeAt(newInfo, 0, keccak256("app_get_key")); + assertFalse(allowed, "new epoch approval must not imply historical access"); + app.addComposeHashAtEpoch(0, newInfo.composeHash); + (allowed,) = authorizeAt(newInfo, 0, keccak256("app_get_key")); + assertTrue(allowed); + allowed = authorizeCurrent(oldInfo); + assertFalse(allowed, "old image cannot read new keys"); + app.removeComposeHashAtEpoch(0, oldInfo.composeHash); + (allowed,) = authorizeAt(oldInfo, 0, keccak256("app_get_key")); + assertFalse(allowed); + allowed = authorizeCurrent(newInfo); + assertTrue(allowed); + vm.stopPrank(); + } + + function test_AllowAnyDeviceIsScopedToExactlyOneEpoch() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHashAtEpoch(0, info.composeHash); + app.setAllowAnyDeviceAtEpoch(0, true); + app.advanceAppEpoch(0); + app.addComposeHashAtEpoch(1, info.composeHash); + bool allowed = authorizeCurrent(info); + assertFalse(allowed); + app.addDeviceAtEpoch(1, info.deviceId); + allowed = authorizeCurrent(info); + assertTrue(allowed, "explicit device can replace wildcard in a newer row"); + app.removeDeviceAtEpoch(1, info.deviceId); + app.setAllowAnyDeviceAtEpoch(1, true); + app.setAllowAnyDeviceAtEpoch(0, false); + assertFalse(app.allowAnyDevice()); + assertTrue(app.allowAnyDeviceAtEpoch(1)); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertFalse(allowed); + allowed = authorizeCurrent(info); + assertTrue(allowed); + app.setAllowAnyDeviceAtEpoch(1, false); + allowed = authorizeCurrent(info); + assertFalse(allowed); + vm.stopPrank(); + } + + function authorizeCurrent(IAppAuth.AppInfo memory info) internal view returns (bool allowed) { + (allowed,) = authorizeAt(info, app.currentAppEpoch(), keccak256("app_get_key")); + } + + function test_LegacyAppCheckRefusesOnceTheAppEpochAdvances() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.addComposeHashAtEpochs(0, 1, info.composeHash); + app.addDeviceAtEpochs(0, 1, info.deviceId); + // At epoch 0 the legacy check reads row 0, as in v0.6.0. + (bool allowed, string memory reason) = app.isAppAllowed(info); + assertTrue(allowed, reason); + app.removeComposeHashAtEpoch(1, info.composeHash); + (allowed,) = app.isAppAllowed(info); + assertTrue(allowed, "the prepared next row does not affect the legacy check"); + app.addComposeHashAtEpoch(1, info.composeHash); + + app.advanceAppEpoch(0); + (allowed, reason) = app.isAppAllowed(info); + assertFalse(allowed); + assertEq(reason, "App epoch requires epoch-aware authorization"); + // Epoch-aware callers keep working at both epochs. + (allowed,) = authorizeAt(info, 1, keccak256("app_get_key")); + assertTrue(allowed); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + vm.stopPrank(); + } + + function test_NextAppEpochRowCanBePreparedBeforeAdvancing() public { + IAppAuth.AppInfo memory info = epochInfo(); + IAppAuth.AppInfo memory next = epochInfo(); + next.composeHash = bytes32(uint256(3)); + vm.startPrank(owner); + app.addComposeHashAtEpoch(0, info.composeHash); + app.addDeviceAtEpoch(0, info.deviceId); + app.addComposeHashAtEpoch(1, next.composeHash); + app.addDeviceAtEpoch(1, next.deviceId); + app.setAllowAnyDeviceAtEpoch(1, true); + // Preparing the next row leaves the current one as it was. + bool allowed = authorizeCurrent(info); + assertTrue(allowed); + allowed = authorizeCurrent(next); + assertFalse(allowed); + (allowed,) = authorizeAt(next, 1, keccak256("app_get_key")); + assertFalse(allowed, "the next row is not active before advancing"); + vm.expectRevert("Unknown app epoch"); + app.addComposeHashAtEpoch(2, next.composeHash); + + app.advanceAppEpoch(0); + allowed = authorizeCurrent(next); + assertTrue(allowed, "a prepared row authorizes as soon as it is current"); + (allowed,) = authorizeAt(next, 1, keccak256("app_get_key")); + assertTrue(allowed); + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + app.addComposeHashAtEpoch(2, next.composeHash); + vm.stopPrank(); + } + + function test_RangeAppSettersChangeEveryRowInRange() public { + IAppAuth.AppInfo memory info = epochInfo(); + vm.startPrank(owner); + app.advanceAppEpoch(0); + app.addComposeHashAtEpochs(0, 2, info.composeHash); + app.addDeviceAtEpochs(0, 2, info.deviceId); + app.setAllowAnyDeviceAtEpochs(0, 2, true); + for (uint256 epoch = 0; epoch <= 2; ++epoch) { + assertTrue(app.allowedComposeHashesAtEpoch(epoch, info.composeHash)); + assertTrue(app.allowedDeviceIdsAtEpoch(epoch, info.deviceId)); + assertTrue(app.allowAnyDeviceAtEpoch(epoch)); + } + (bool allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertTrue(allowed); + + // Removing from every row revokes at every selectable epoch. + app.removeComposeHashAtEpochs(0, 2, info.composeHash); + app.removeDeviceAtEpochs(0, 2, info.deviceId); + app.setAllowAnyDeviceAtEpochs(0, 2, false); + for (uint256 epoch = 0; epoch <= 2; ++epoch) { + assertFalse(app.allowedComposeHashesAtEpoch(epoch, info.composeHash)); + assertFalse(app.allowedDeviceIdsAtEpoch(epoch, info.deviceId)); + assertFalse(app.allowAnyDeviceAtEpoch(epoch)); + } + (allowed,) = authorizeAt(info, 0, keccak256("app_get_key")); + assertFalse(allowed); + assertFalse(authorizeCurrent(info)); + + vm.expectRevert("Invalid app epoch range"); + app.removeComposeHashAtEpochs(1, 0, info.composeHash); + vm.expectRevert("Unknown app epoch"); + app.removeComposeHashAtEpochs(0, 3, info.composeHash); + vm.expectRevert("invalid compose hash"); + app.addComposeHashAtEpochs(0, 1, bytes32(0)); + vm.stopPrank(); + } + + function test_ExplicitAppEpochSettersRequireOwnerAndKnownEpoch() public { + vm.startPrank(owner); + // Only the current row and the next one can be written. + vm.expectRevert("Unknown app epoch"); + app.addComposeHashAtEpoch(2, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.removeComposeHashAtEpoch(2, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.addDeviceAtEpoch(2, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.removeDeviceAtEpoch(2, bytes32(uint256(1))); + vm.expectRevert("Unknown app epoch"); + app.setAllowAnyDeviceAtEpoch(2, 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,) = authorizeAt(info, selected, keccak256("app_get_key")); + assertEq(allowed, selected == row); + } } diff --git a/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol new file mode 100644 index 000000000..76ba5b148 --- /dev/null +++ b/dstack/kms/auth-eth/test/DstackAppEpoch.symbolic.t.sol @@ -0,0 +1,373 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../contracts/AuthorizationOperation.sol"; +import "../contracts/DstackApp.sol"; + +function _appAuth( + DstackApp target, + IAppAuth.AppInfo memory info, + uint256 epoch, + bytes32 operation +) + view + returns (bool, string memory) +{ + AuthorizationResult memory result = target.authorizeApp( + AuthorizationRequest(info, operation, OptionalUint256(true, epoch), OptionalUint256(false, 0)) + ); + return (result.authorized, result.rejectionReason); +} + +/// @notice Halmos symbolic tests for DstackApp app epochs. +/// +/// Halmos has no symbolic storage, so tests that quantify over policy states +/// first apply one symbolic owner write on top of setUp's concrete state. +/// DstackApp reads only the compose hash, device ID and TCB status of an +/// AppInfo, so those are the symbolic inputs; the remaining fields stay zero. +/// +/// Run with `halmos --contract DstackAppEpochSymbolicTest`. +/// @custom:halmos --loop 5 +contract DstackAppEpochSymbolicTest is Test { + DstackApp internal app; + address internal constant OWNER = address(0xA11CE); + uint256 internal constant POLICY_SETTERS = 10; + + function setUp() public { + DstackApp impl = new DstackApp(); + bytes memory initData = abi.encodeWithSignature( + "initialize(address,bool,bool,bytes32,bytes32)", OWNER, false, false, bytes32(0), bytes32(0) + ); + app = DstackApp(address(new ERC1967Proxy(address(impl), initData))); + // Epoch 0 keeps the legacy storage fields; epochs 1 and 2 use the + // per-epoch maps. Each epoch admits some app so that decisions can be + // allowed as well as denied. + vm.startPrank(OWNER); + app.addComposeHashAtEpoch(0, bytes32(uint256(0xc0))); + app.addDeviceAtEpoch(0, bytes32(uint256(0xd0))); + app.advanceAppEpoch(0); + app.addComposeHashAtEpoch(1, bytes32(uint256(0xc1))); + app.setAllowAnyDeviceAtEpoch(1, true); + app.advanceAppEpoch(1); + app.addComposeHashAtEpoch(2, bytes32(uint256(0xc2))); + app.addDeviceAtEpoch(2, bytes32(uint256(0xd2))); + vm.stopPrank(); + } + + /// Calldata of one policy setter: 0-4 write the row of `epoch`, 5-9 are + /// their range forms, writing every row from `epoch` through the next one. + function _policyCall(uint256 which, uint256 epoch, bytes32 word, bool flag) internal view returns (bytes memory) { + return _rangePolicyCall(which, epoch, app.currentAppEpoch() + 1, word, flag); + } + + /// As _policyCall, with the range setters writing `epoch` through `next`. + function _rangePolicyCall( + uint256 which, + uint256 epoch, + uint256 next, + bytes32 word, + bool flag + ) + internal + pure + returns (bytes memory data) + { + if (which == 0) data = abi.encodeCall(DstackApp.addComposeHashAtEpoch, (epoch, word)); + else if (which == 1) data = abi.encodeCall(DstackApp.removeComposeHashAtEpoch, (epoch, word)); + else if (which == 2) data = abi.encodeCall(DstackApp.addDeviceAtEpoch, (epoch, word)); + else if (which == 3) data = abi.encodeCall(DstackApp.removeDeviceAtEpoch, (epoch, word)); + else if (which == 4) data = abi.encodeCall(DstackApp.setAllowAnyDeviceAtEpoch, (epoch, flag)); + else if (which == 5) data = abi.encodeCall(DstackApp.addComposeHashAtEpochs, (epoch, next, word)); + else if (which == 6) data = abi.encodeCall(DstackApp.removeComposeHashAtEpochs, (epoch, next, word)); + else if (which == 7) data = abi.encodeCall(DstackApp.addDeviceAtEpochs, (epoch, next, word)); + else if (which == 8) data = abi.encodeCall(DstackApp.removeDeviceAtEpochs, (epoch, next, word)); + else data = abi.encodeCall(DstackApp.setAllowAnyDeviceAtEpochs, (epoch, next, flag)); + } + + function _ownerPolicyWrite(uint256 which, uint256 epoch, bytes32 word, bool flag) internal { + if (which >= POLICY_SETTERS) return; + bytes memory data = _policyCall(which, epoch, word, flag); + vm.prank(OWNER); + (bool ok,) = address(app).call(data); + ok; // a reverted write leaves the setUp state, which is also a valid pre-state + } + + function _info( + bytes32 composeHash, + bytes32 deviceId, + bool upToDate + ) + internal + pure + returns (IAppAuth.AppInfo memory info) + { + info.composeHash = composeHash; + info.deviceId = deviceId; + info.tcbStatus = upToDate ? "UpToDate" : "OutOfDate"; + } + + function _decision(IAppAuth.AppInfo memory info, uint256 epoch) internal view returns (bool, bytes32) { + (bool allowed, string memory reason) = _appAuth(app, info, epoch, AuthorizationOperation.APP_GET_KEY); + return (allowed, keccak256(bytes(reason))); + } + + // --------------------------------------------------------------- + // The app epoch never decreases, and only the owner's + // advanceAppEpoch(current) moves it, by exactly one. + // --------------------------------------------------------------- + + function check_AppEpoch_StepMonotonic( + address caller, + uint256 which, + uint256 epoch, + bytes32 word, + bool flag, + address addr + ) + external + { + uint256 before = app.currentAppEpoch(); + + bytes memory data; + if (which < POLICY_SETTERS) data = _policyCall(which, epoch, word, flag); + else if (which == 10) data = abi.encodeCall(DstackApp.advanceAppEpoch, (epoch)); + else if (which == 11) data = abi.encodeCall(DstackApp.setRequireTcbUpToDate, (flag)); + else if (which == 12) data = abi.encodeCall(DstackApp.disableUpgrades, ()); + else if (which == 13) data = abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (addr, "")); + else if (which == 14) data = abi.encodeCall(Ownable2StepUpgradeable.transferOwnership, (addr)); + else if (which == 15) data = abi.encodeCall(Ownable2StepUpgradeable.acceptOwnership, ()); + else if (which == 16) data = abi.encodeCall(OwnableUpgradeable.renounceOwnership, ()); + else return; + + vm.prank(caller); + (bool ok,) = address(app).call(data); + ok; + + uint256 afterEpoch = app.currentAppEpoch(); + if (afterEpoch != before) { + assert(afterEpoch == before + 1); + assert(caller == OWNER && which == 10 && epoch == before); + } + } + + // --------------------------------------------------------------- + // No caller can write policy for an epoch that is not active yet. + // --------------------------------------------------------------- + + function check_PolicyWrite_RejectsEpochPastNext( + address caller, + uint256 which, + uint256 epoch, + uint256 to, + bytes32 word, + bool flag + ) + external + { + vm.assume(which < POLICY_SETTERS && (which < 5 ? epoch : to) > app.currentAppEpoch() + 1); + bytes memory data = _rangePolicyCall(which, epoch, to, word, flag); + vm.prank(caller); + (bool ok,) = address(app).call(data); + assert(!ok); + } + + // --------------------------------------------------------------- + // An epoch that is not active is never authorized, and only app + // operations are authorized. + // --------------------------------------------------------------- + + function check_AuthorizeApp_RejectsInactiveEpochAndForeignOperation( + uint256 which, + uint256 writeEpoch, + bytes32 word, + bool flag, + bytes32 composeHash, + bytes32 deviceId, + bool upToDate, + uint256 epoch, + bytes32 operation + ) + external + { + _ownerPolicyWrite(which, writeEpoch, word, flag); + IAppAuth.AppInfo memory info = _info(composeHash, deviceId, upToDate); + vm.assume(epoch > app.currentAppEpoch() || !AuthorizationOperation.isApp(operation)); + (bool allowed,) = _appAuth(app, info, epoch, operation); + assert(!allowed); + } + + // --------------------------------------------------------------- + // The legacy IAppAuth entry point decides exactly as the + // epoch-aware one at the current epoch, never an older policy. + // --------------------------------------------------------------- + + function check_IsAppAllowed_RefusesAtNonzeroEpoch( + uint256 which, + uint256 writeEpoch, + bytes32 word, + bool flag, + bytes32 composeHash, + bytes32 deviceId, + bool upToDate + ) + external + { + _ownerPolicyWrite(which, writeEpoch, word, flag); + (bool allowed, string memory reason) = app.isAppAllowed(_info(composeHash, deviceId, upToDate)); + assert(!allowed); + assert(keccak256(bytes(reason)) == keccak256("App epoch requires epoch-aware authorization")); + } + + function check_IsAppAllowed_EqualsEpochZeroDecisionBeforeAdvance( + uint256 which, + uint256 writeEpoch, + bytes32 word, + bool flag, + bytes32 composeHash, + bytes32 deviceId, + bool upToDate + ) + external + { + bytes memory initData = abi.encodeWithSignature( + "initialize(address,bool,bool,bytes32,bytes32)", OWNER, false, false, bytes32(0), bytes32(0) + ); + app = DstackApp(address(new ERC1967Proxy(address(new DstackApp()), initData))); + vm.startPrank(OWNER); + app.addComposeHashAtEpoch(0, bytes32(uint256(0xc0))); + app.addDeviceAtEpoch(0, bytes32(uint256(0xd0))); + // A prepared next row must not leak into the legacy decision. + app.addComposeHashAtEpoch(1, bytes32(uint256(0xc1))); + app.setAllowAnyDeviceAtEpoch(1, true); + vm.stopPrank(); + _ownerPolicyWrite(which, writeEpoch, word, flag); + IAppAuth.AppInfo memory info = _info(composeHash, deviceId, upToDate); + (bool allowed, string memory reason) = app.isAppAllowed(info); + (bool expected, bytes32 expectedReason) = _decision(info, 0); + assert(allowed == expected && keccak256(bytes(reason)) == expectedReason); + } + + // --------------------------------------------------------------- + // Epoch isolation: a policy write never changes an authorization decision + // at an epoch it did not write, including epoch 0's legacy fields. + // --------------------------------------------------------------- + + function check_PolicyWrite_PreservesOtherEpochDecisions( + uint256 which, + uint256 writeEpoch, + uint256 writeTo, + bytes32 word, + bool flag, + bytes32 composeHash, + bytes32 deviceId, + bool upToDate, + uint256 epoch + ) + external + { + vm.assume(which < POLICY_SETTERS); + vm.assume(which < 5 ? writeEpoch != epoch : epoch < writeEpoch || epoch > writeTo); + bytes memory data = _rangePolicyCall(which, writeEpoch, writeTo, word, flag); + + IAppAuth.AppInfo memory info = _info(composeHash, deviceId, upToDate); + (bool allowedBefore, bytes32 reasonBefore) = _decision(info, epoch); + vm.prank(OWNER); + (bool ok,) = address(app).call(data); + ok; + (bool allowedAfter, bytes32 reasonAfter) = _decision(info, epoch); + + assert(allowedBefore == allowedAfter && reasonBefore == reasonAfter); + } + + function check_AuthorizeApp_MatchesPolicy( + uint256 which, + uint256 writeEpoch, + bytes32 word, + bool flag, + bool tcbFlag, + bytes32 composeHash, + bytes32 deviceId, + bool upToDate, + uint256 epoch, + bool getCert + ) + external + { + vm.prank(OWNER); + app.setRequireTcbUpToDate(tcbFlag); + _ownerPolicyWrite(which, writeEpoch, word, flag); + IAppAuth.AppInfo memory info = _info(composeHash, deviceId, upToDate); + bytes32 op = getCert ? AuthorizationOperation.APP_GET_CERT : AuthorizationOperation.APP_GET_KEY; + (bool allowed,) = _appAuth(app, info, epoch, op); + bool expected = epoch <= app.currentAppEpoch() && (!tcbFlag || upToDate) + && app.allowedComposeHashesAtEpoch(epoch, composeHash) + && (app.allowAnyDeviceAtEpoch(epoch) || app.allowedDeviceIdsAtEpoch(epoch, deviceId)); + assert(allowed == expected); + } + + // A write takes effect on its row, and a range write on every row in its + // range. + function check_PolicyWrite_TakesEffect( + uint256 which, + uint256 from, + uint256 to, + uint256 epoch, + bytes32 word, + bool flag + ) + external + { + vm.assume(which < POLICY_SETTERS && epoch <= app.currentAppEpoch() + 1); + vm.assume(which < 5 ? epoch == from : from <= epoch && epoch <= to && to <= app.currentAppEpoch() + 1); + bytes memory data = _rangePolicyCall(which, from, to, word, flag); + vm.prank(OWNER); + (bool ok,) = address(app).call(data); + uint256 kind = which % 5; + if (!ok) { + assert(word == bytes32(0) && (kind == 0 || kind == 2)); + return; + } + if (kind == 0) assert(app.allowedComposeHashesAtEpoch(epoch, word)); + if (kind == 1) assert(!app.allowedComposeHashesAtEpoch(epoch, word)); + if (kind == 2) assert(app.allowedDeviceIdsAtEpoch(epoch, word)); + if (kind == 3) assert(!app.allowedDeviceIdsAtEpoch(epoch, word)); + if (kind == 4) assert(app.allowAnyDeviceAtEpoch(epoch) == flag); + } + + // --------------------------------------------------------------- + // Revocation: removing a compose hash, or a device and the any-device + // flag, from every row, 0 through the next epoch, leaves no selectable + // app epoch at which it is authorized. + // --------------------------------------------------------------- + + function check_FullRangeRemoval_RevokesAtEveryAppEpoch( + bool byDevice, + bytes32 composeHash, + bytes32 deviceId, + bool upToDate, + uint256 epoch, + bool getCert + ) + external + { + uint256 next = app.currentAppEpoch() + 1; + vm.startPrank(OWNER); + if (byDevice) { + app.removeDeviceAtEpochs(0, next, deviceId); + app.setAllowAnyDeviceAtEpochs(0, next, false); + } else { + app.removeComposeHashAtEpochs(0, next, composeHash); + } + vm.stopPrank(); + bytes32 op = getCert ? AuthorizationOperation.APP_GET_CERT : AuthorizationOperation.APP_GET_KEY; + (bool allowed,) = _appAuth(app, _info(composeHash, deviceId, upToDate), epoch, op); + assert(!allowed); + } +} diff --git a/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol index 573aa8cc2..ba34bb846 100644 --- a/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.symbolic.t.sol @@ -32,7 +32,7 @@ contract MockConfigurableApp { _ret = ret; } - function isAppAllowed(IAppAuth.AppBootInfo calldata) external view returns (bool, string memory) { + function isAppAllowed(IAppAuth.AppInfo calldata) external view returns (bool, string memory) { return (_ret, ""); } } @@ -41,7 +41,7 @@ contract MockConfigurableApp { /// to verify that a malicious registered contract's revert /// propagates out of `kms.isAppAllowed` rather than being swallowed. contract MockRevertingApp { - function isAppAllowed(IAppAuth.AppBootInfo calldata) external pure returns (bool, string memory) { + function isAppAllowed(IAppAuth.AppInfo calldata) external pure returns (bool, string memory) { revert("malicious app revert"); } } @@ -87,14 +87,14 @@ contract DstackKmsSymbolicTest is Test { // the (false, reason) return without delegating. // --------------------------------------------------------------- - function check_IsAppAllowed_RejectsUnregisteredApp(IAppAuth.AppBootInfo calldata bootInfo) external view { + function check_IsAppAllowed_RejectsUnregisteredApp(IAppAuth.AppInfo calldata bootInfo) external view { // No apps registered in setUp(), so any bootInfo.appId is rejected. (bool allowed, string memory reason) = kms.isAppAllowed(bootInfo); assert(!allowed); assert(keccak256(bytes(reason)) == keccak256(bytes("App not registered"))); } - function check_IsAppAllowed_RejectsUnknownOsImage(address appId, IAppAuth.AppBootInfo calldata bootInfo) external { + function check_IsAppAllowed_RejectsUnknownOsImage(address appId, IAppAuth.AppInfo calldata bootInfo) external { vm.assume(appId != address(0)); // Register the appId so the registration check passes. @@ -125,10 +125,10 @@ contract DstackKmsSymbolicTest is Test { vm.startPrank(OWNER); kms.registerApp(address(mock)); - kms.addOsImageHash(osImageHash); + kms.addOsImageHashAtEpoch(0, osImageHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory b = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory b = IAppAuth.AppInfo({ appId: address(mock), composeHash: bytes32(0), instanceId: address(0), @@ -155,10 +155,10 @@ contract DstackKmsSymbolicTest is Test { vm.startPrank(OWNER); kms.registerApp(address(mock)); - kms.addOsImageHash(osImageHash); + kms.addOsImageHashAtEpoch(0, osImageHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory b = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory b = IAppAuth.AppInfo({ appId: address(mock), composeHash: bytes32(0), instanceId: address(0), @@ -190,9 +190,9 @@ contract DstackKmsSymbolicTest is Test { // Allow the OS image so the second gate passes; leave // kmsAllowedAggregatedMrs empty. vm.prank(OWNER); - kms.addOsImageHash(osImageHash); + kms.addOsImageHashAtEpoch(0, osImageHash); - IAppAuth.AppBootInfo memory b = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory b = IAppAuth.AppInfo({ appId: address(0), composeHash: bytes32(0), instanceId: address(0), @@ -217,12 +217,12 @@ contract DstackKmsSymbolicTest is Test { external { vm.startPrank(OWNER); - kms.addOsImageHash(osImageHash); - kms.addKmsAggregatedMr(mrAggregated); + kms.addOsImageHashAtEpoch(0, osImageHash); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); vm.stopPrank(); // kmsAllowedDeviceIds is left empty. - IAppAuth.AppBootInfo memory b = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory b = IAppAuth.AppInfo({ appId: address(0), composeHash: bytes32(0), instanceId: address(0), @@ -338,17 +338,18 @@ contract DstackKmsSymbolicTest is Test { } else if (which == 2) { data = abi.encodeWithSelector(DstackKms.registerApp.selector, addr); } else if (which == 3) { - data = abi.encodeWithSelector(DstackKms.addKmsAggregatedMr.selector, word); + data = abi.encodeWithSelector(DstackKms.addKmsAggregatedMrAtEpochs.selector, uint256(0), uint256(0), word); } else if (which == 4) { - data = abi.encodeWithSelector(DstackKms.removeKmsAggregatedMr.selector, word); + data = + abi.encodeWithSelector(DstackKms.removeKmsAggregatedMrAtEpochs.selector, uint256(0), uint256(0), word); } else if (which == 5) { - data = abi.encodeWithSelector(DstackKms.addKmsDevice.selector, word); + data = abi.encodeWithSelector(DstackKms.addKmsDeviceAtEpochs.selector, uint256(0), uint256(0), word); } else if (which == 6) { - data = abi.encodeWithSelector(DstackKms.removeKmsDevice.selector, word); + data = abi.encodeWithSelector(DstackKms.removeKmsDeviceAtEpochs.selector, uint256(0), uint256(0), word); } else if (which == 7) { - data = abi.encodeWithSelector(DstackKms.addOsImageHash.selector, word); + data = abi.encodeWithSelector(DstackKms.addOsImageHashAtEpochs.selector, uint256(0), uint256(0), word); } else if (which == 8) { - data = abi.encodeWithSelector(DstackKms.removeOsImageHash.selector, word); + data = abi.encodeWithSelector(DstackKms.removeOsImageHashAtEpochs.selector, uint256(0), uint256(0), word); } else if (which == 9) { data = abi.encodeWithSelector( bytes4(keccak256("deployAndRegisterApp(address,bool,bool,bool,bytes32,bytes32)")), @@ -369,10 +370,6 @@ contract DstackKmsSymbolicTest is Test { word ); } else if (which == 11) { - data = abi.encodeWithSelector(DstackKms.setKmsQuote.selector, ""); - } else if (which == 12) { - data = abi.encodeWithSelector(DstackKms.setKmsEventlog.selector, ""); - } else if (which == 13) { data = abi.encodeWithSelector(UUPSUpgradeable.upgradeToAndCall.selector, addr, ""); } 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 899b2b7b9..0281634a8 100644 --- a/dstack/kms/auth-eth/test/DstackKms.t.sol +++ b/dstack/kms/auth-eth/test/DstackKms.t.sol @@ -11,6 +11,7 @@ import "openzeppelin-foundry-upgrades/Upgrades.sol"; import "../contracts/DstackKms.sol"; import "../contracts/DstackApp.sol"; import "../contracts/IAppAuth.sol"; +import { DstackKms as DstackKmsV059 } from "../contracts/test-utils/reference/v0.5.9/DstackKmsV059.sol"; contract DstackKmsTest is Test { DstackKms public kms; @@ -19,6 +20,12 @@ contract DstackKmsTest is Test { address public user; event KmsInfoSet(bytes k256Pubkey); + event RootEpochRegistered( + uint256 indexed epoch, bytes32 chainHash, bytes caPubkey, bytes k256Pubkey, string evidenceUrl + ); + event RootEpochActivated(uint256 indexed epoch); + event RootEpochPopped(uint256 indexed epoch, bytes32 chainHash); + event RootEpochEvidenceUrlSet(uint256 indexed epoch, string evidenceUrl); event AppRegistered(address appId); @@ -44,46 +51,350 @@ contract DstackKmsTest is Test { assertEq(kms.appImplementation(), address(appImpl)); } - function test_SetKmsInfo() public { - bytes memory k256Pubkey = hex"123456"; - bytes memory caPubkey = hex"789abc"; - bytes memory quote = hex"defdef"; - bytes memory eventlog = hex"abcabc"; - - vm.prank(owner); - vm.expectEmit(true, true, true, true); - emit KmsInfoSet(k256Pubkey); - - kms.setKmsInfo( - DstackKms.KmsInfo({ k256Pubkey: k256Pubkey, caPubkey: caPubkey, quote: quote, eventlog: eventlog }) + function upgradeLegacyKms() internal { + vm.startPrank(owner); + address proxy = Upgrades.deployUUPSProxy( + "DstackKmsV059.sol:DstackKms", abi.encodeCall(DstackKmsV059.initialize, (owner, address(appImpl))) ); + DstackKmsV059(proxy).setKmsInfo(DstackKmsV059.KmsInfo(k256(0xb0), ca(0xa0), hex"0d", hex"0e")); + Options memory opts; + opts.referenceContract = "DstackKmsV059.sol:DstackKms"; + Upgrades.upgradeProxy(proxy, "DstackKms.sol", "", opts); + vm.stopPrank(); + kms = DstackKms(proxy); + + assertEq(kms.owner(), owner); + assertEq(kms.appImplementation(), address(appImpl)); + assertEq(kms.rootEpochCount(), 0); + (bytes memory storedK256, bytes memory storedCa, bytes memory quote, bytes memory eventlog) = kms.kmsInfo(); + assertEq(storedK256, k256(0xb0)); + assertEq(storedCa, ca(0xa0)); + assertEq(quote, hex"0d"); + assertEq(eventlog, hex"0e"); + } - (bytes memory storedK256, bytes memory storedCa, bytes memory storedQuote, bytes memory storedEventlog) = + function assertLegacySettersDisabled(address caller) internal { + (bytes memory key, bytes memory caKey, bytes memory quote, bytes memory eventlog) = kms.kmsInfo(); + vm.startPrank(caller); + (bool ok,) = address(kms) + .call( + abi.encodeWithSignature( + "setKmsInfo((bytes,bytes,bytes,bytes))", DstackKms.KmsInfo(k256(0xb1), ca(0xa1), hex"ff", hex"ff") + ) + ); + assertFalse(ok); + (ok,) = address(kms).call(abi.encodeWithSignature("setKmsQuote(bytes)", hex"ff")); + assertFalse(ok); + (ok,) = address(kms).call(abi.encodeWithSignature("setKmsEventlog(bytes)", hex"ff")); + assertFalse(ok); + vm.stopPrank(); + (bytes memory storedKey, bytes memory storedCa, bytes memory storedQuote, bytes memory storedEventlog) = kms.kmsInfo(); - assertEq(storedK256, k256Pubkey); - assertEq(storedCa, caPubkey); + assertEq(storedKey, key); + assertEq(storedCa, caKey); assertEq(storedQuote, quote); assertEq(storedEventlog, eventlog); } - function test_SetKmsInfoOnlyOwner() public { - bytes memory k256Pubkey = hex"123456"; - bytes memory caPubkey = hex"789abc"; - bytes memory quote = hex"defdef"; - bytes memory eventlog = hex"abcabc"; + function test_legacy_setters_disabled_before_and_after_epoch_registration() public { + assertLegacySettersDisabled(owner); + assertLegacySettersDisabled(user); + vm.prank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + assertLegacySettersDisabled(owner); + assertLegacySettersDisabled(user); + } + + function test_legacy_setters_disabled_after_upgrade() public { + upgradeLegacyKms(); + assertLegacySettersDisabled(owner); + assertLegacySettersDisabled(user); + } + + // Well-formed root public keys whose point bytes are all `fill`; shared with + // the off-chain implementations' tests. + function ca(uint8 fill) internal pure returns (bytes memory key) { + key = bytes.concat(hex"3059301306072a8648ce3d020106082a8648ce3d03010703420004", new bytes(64)); + for (uint256 i = 27; i < 91; i++) { + key[i] = bytes1(fill); + } + } + + function k256(uint8 fill) internal pure returns (bytes memory key) { + key = new bytes(33); + key[0] = 0x02; + for (uint256 i = 1; i < 33; i++) { + key[i] = bytes1(fill); + } + } + + function chainHash(bytes32 previous, bytes memory caKey, bytes memory k256Key) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(previous, keccak256(caKey), keccak256(k256Key))); + } + + function test_RegisterRootEpochAppendsAndChains() public { + assertEq(kms.rootEpochCount(), 0); + vm.expectRevert("No root epoch registered"); + kms.currentRootEpoch(); + + bytes32 h0 = chainHash(bytes32(0), ca(0xa0), k256(0xb0)); + bytes32 h1 = chainHash(h0, ca(0xa1), k256(0xb1)); + // Shared with the off-chain implementations' tests. + assertEq(h0, 0x10ab6b6c8e7601901b0f1767985bf40d35bf3129e0866fe12dcc7c8f04a298f2); + assertEq(h1, 0x50152a82023f2219a31723b476c13bc5f608ab4147946dabdec15d4f3d648ed4); + vm.startPrank(owner); + vm.expectEmit(true, true, true, true); + emit RootEpochRegistered(0, h0, ca(0xa0), k256(0xb0), "ipfs://c0"); + vm.expectEmit(true, true, true, true); + emit RootEpochActivated(0); + assertEq(kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"), 0); + assertEq(kms.registerRootEpoch(ca(0xa1), k256(0xb1), "ipfs://c1"), 1); + vm.stopPrank(); + + assertEq(kms.rootEpochCount(), 2); + (bytes memory caKey, bytes memory k256Key, string memory evidenceUrl, bytes32 h) = kms.rootEpoch(1); + assertEq(caKey, ca(0xa1)); + assertEq(k256Key, k256(0xb1)); + assertEq(evidenceUrl, "ipfs://c1"); + assertEq(h, h1); + vm.expectRevert("Unknown root epoch"); + kms.rootEpoch(2); + + // Epoch 1 is registered but epoch 0 is still served. + (uint256 current, bytes32 currentHash) = kms.currentRootEpoch(); + assertEq(current, 0); + assertEq(currentHash, h0); + } + + function test_ActivateRootEpochMovesForwardOneAtATime() public { + bytes32 h0 = chainHash(bytes32(0), ca(0xa0), k256(0xb0)); + bytes32 h1 = chainHash(h0, ca(0xa1), k256(0xb1)); + bytes32 h2 = chainHash(h1, ca(0xa2), k256(0xb2)); + vm.startPrank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + vm.expectRevert("Invalid root epoch activation"); + kms.activateRootEpoch(1, h1); + kms.registerRootEpoch(ca(0xa1), k256(0xb1), "ipfs://c1"); + kms.registerRootEpoch(ca(0xa2), k256(0xb2), "ipfs://c2"); + vm.expectRevert("Invalid root epoch activation"); + kms.activateRootEpoch(2, h2); + vm.expectEmit(true, true, true, true); + emit RootEpochActivated(1); + kms.activateRootEpoch(1, h1); + vm.expectRevert("Invalid root epoch activation"); + kms.activateRootEpoch(1, h1); + vm.expectRevert("Invalid root epoch activation"); + kms.activateRootEpoch(0, h0); + vm.stopPrank(); + + (uint256 current,) = kms.currentRootEpoch(); + assertEq(current, 1); vm.prank(user); - vm.expectRevert(); - kms.setKmsInfo( - DstackKms.KmsInfo({ k256Pubkey: k256Pubkey, caPubkey: caPubkey, quote: quote, eventlog: eventlog }) - ); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user)); + kms.activateRootEpoch(2, h2); + } + + function test_activate_root_epoch_rejects_replaced_pending_epoch() public { + bytes32 h0 = chainHash(bytes32(0), ca(0xa0), k256(0xb0)); + bytes32 h1 = chainHash(h0, ca(0xa1), k256(0xb1)); + bytes32 replacementHash = chainHash(h0, ca(0xa2), k256(0xb2)); + vm.startPrank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + kms.registerRootEpoch(ca(0xa1), k256(0xb1), "ipfs://c1"); + kms.popRootEpoch(1, h1); + kms.registerRootEpoch(ca(0xa2), k256(0xb2), "ipfs://replacement"); + + vm.expectRevert("root epoch chain hash mismatch"); + kms.activateRootEpoch(1, h1); + (uint256 current, bytes32 currentHash) = kms.currentRootEpoch(); + assertEq(current, 0); + assertEq(currentHash, h0); + (bytes memory k256Key, bytes memory caKey,,) = kms.kmsInfo(); + assertEq(caKey, ca(0xa0)); + assertEq(k256Key, k256(0xb0)); + + kms.activateRootEpoch(1, replacementHash); + (current, currentHash) = kms.currentRootEpoch(); + assertEq(current, 1); + assertEq(currentHash, replacementHash); + (k256Key, caKey,,) = kms.kmsInfo(); + assertEq(caKey, ca(0xa2)); + assertEq(k256Key, k256(0xb2)); + vm.stopPrank(); + } + + function test_RegisterRootEpochZeroMustMatchKmsInfo() public { + upgradeLegacyKms(); + vm.startPrank(owner); + vm.expectRevert("Epoch 0 keys differ from kmsInfo"); + kms.registerRootEpoch(ca(0xa1), k256(0xb0), "ipfs://c0"); + vm.expectRevert("Epoch 0 keys differ from kmsInfo"); + kms.registerRootEpoch(ca(0xa0), k256(0xb1), "ipfs://c0"); + assertEq(kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"), 0); + // Later epochs are new keys by definition. + assertEq(kms.registerRootEpoch(ca(0xa1), k256(0xb1), "ipfs://c1"), 1); + vm.stopPrank(); + } + + function test_PopRootEpochDropsOnlyThePendingOne() public { + bytes32 h0 = chainHash(bytes32(0), ca(0xa0), k256(0xb0)); + bytes32 h1 = chainHash(h0, ca(0xa1), k256(0xb1)); + bytes32 h1b = chainHash(h0, ca(0xa2), k256(0xb2)); + vm.startPrank(owner); + vm.expectRevert("Not the pending root epoch"); + kms.popRootEpoch(0, h0); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + vm.expectRevert("Not the pending root epoch"); + kms.popRootEpoch(0, h0); + kms.registerRootEpoch(ca(0xa1), k256(0xb1), "ipfs://c1"); + vm.expectRevert("Not the pending root epoch"); + kms.popRootEpoch(2, h1); + vm.expectRevert("Root epoch chain hash mismatch"); + kms.popRootEpoch(1, h1b); + + vm.expectEmit(true, true, true, true); + emit RootEpochPopped(1, h1); + kms.popRootEpoch(1, h1); + assertEq(kms.rootEpochCount(), 1); + + // A new epoch 1 replaces the dropped one; a stale pop of the old one + // cannot drop it. + assertEq(kms.registerRootEpoch(ca(0xa2), k256(0xb2), "ipfs://c2"), 1); + vm.expectRevert("Root epoch chain hash mismatch"); + kms.popRootEpoch(1, h1); + kms.activateRootEpoch(1, h1b); + vm.expectRevert("Not the pending root epoch"); + kms.popRootEpoch(1, h1b); + vm.stopPrank(); + + (uint256 epoch, bytes32 h) = kms.currentRootEpoch(); + assertEq(epoch, 1); + assertEq(h, h1b); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user)); + kms.popRootEpoch(1, h1b); + } + + function test_PolicyRowsBelongToTheRegisteredKeysOfAnEpoch() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + // Epoch 0 keeps the legacy mappings, even before any registration. + approveKmsAtEpoch(info, 0); + assertTrue(kms.allowedOsImages(info.osImageHash)); + assertTrue(kms.kmsAllowedAggregatedMrs(info.mrAggregated)); + assertTrue(kms.kmsAllowedDeviceIds(info.deviceId)); + // Rows of an unregistered epoch can be neither written nor read. + vm.expectRevert("Unknown root epoch"); + kms.addOsImageHashAtEpoch(1, info.osImageHash); + vm.expectRevert("Unknown root epoch"); + kms.addKmsAggregatedMrAtEpoch(1, info.mrAggregated); + vm.expectRevert("Unknown root epoch"); + kms.addKmsDeviceAtEpoch(1, info.deviceId); + registerNextRootEpoch(); + registerNextRootEpoch(); + approveKmsAtEpoch(info, 1); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + + (,,, bytes32 h1) = kms.rootEpoch(1); + kms.popRootEpoch(1, h1); + assertFalse(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); + // Other keys registered under the same number start with empty rows. + kms.registerRootEpoch(ca(0xc1), k256(0xd1), "ipfs://replacement"); + assertFalse(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); + assertFalse(kms.kmsAllowedAggregatedMrsAtEpoch(1, info.mrAggregated)); + assertFalse(kms.kmsAllowedDeviceIdsAtEpoch(1, info.deviceId)); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + approveKmsAtEpoch(info, 1); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + // Epoch 0 is untouched throughout. + assertTrue(kms.allowedOsImagesAtEpoch(0, info.osImageHash)); + vm.stopPrank(); + } + + function test_SetRootEpochEvidenceUrl() public { + vm.startPrank(owner); + vm.expectRevert("Unknown root epoch"); + kms.setRootEpochEvidenceUrl(0, "ipfs://moved"); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + vm.expectRevert("Empty epoch evidence URL"); + kms.setRootEpochEvidenceUrl(0, ""); + vm.expectEmit(true, true, true, true); + emit RootEpochEvidenceUrlSet(0, "https://mirror/c0"); + kms.setRootEpochEvidenceUrl(0, "https://mirror/c0"); + vm.stopPrank(); + + (,, string memory evidenceUrl, bytes32 h) = kms.rootEpoch(0); + assertEq(evidenceUrl, "https://mirror/c0"); + assertEq(h, chainHash(bytes32(0), ca(0xa0), k256(0xb0))); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user)); + kms.setRootEpochEvidenceUrl(0, "ipfs://evil"); + } + + function test_KmsInfoFollowsTheCurrentRootEpoch() public { + upgradeLegacyKms(); + vm.startPrank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + (bytes memory k256Key, bytes memory caKey, bytes memory quote, bytes memory eventlog) = kms.kmsInfo(); + assertEq(caKey, ca(0xa0)); + assertEq(k256Key, k256(0xb0)); + assertEq(quote.length + eventlog.length, 0); + + // A pending epoch is not the KMS identity yet. + kms.registerRootEpoch(ca(0xa1), k256(0xb1), "ipfs://c1"); + (k256Key, caKey,,) = kms.kmsInfo(); + assertEq(caKey, ca(0xa0)); + (,,, bytes32 h1) = kms.rootEpoch(1); + kms.activateRootEpoch(1, h1); + (k256Key, caKey,,) = kms.kmsInfo(); + assertEq(caKey, ca(0xa1)); + assertEq(k256Key, k256(0xb1)); + + vm.stopPrank(); + assertLegacySettersDisabled(owner); + assertLegacySettersDisabled(user); + } + + function test_RegisterRootEpochOnlyOwner() public { + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, user)); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "ipfs://c0"); + } + + function test_RegisterRootEpochRejectsMalformedKeys() public { + bytes memory compressedCa = ca(0xa0); + compressedCa[26] = 0x02; + bytes memory otherCurve = ca(0xa0); + otherCurve[25] = 0x0a; + bytes memory uncompressedK256 = bytes.concat(hex"04", new bytes(64)); + vm.startPrank(owner); + vm.expectRevert("Invalid epoch CA public key"); + kms.registerRootEpoch("", k256(0xb0), "ipfs://c0"); + vm.expectRevert("Invalid epoch CA public key"); + kms.registerRootEpoch(bytes.concat(ca(0xa0), hex"00"), k256(0xb0), "ipfs://c0"); + vm.expectRevert("Invalid epoch CA public key"); + kms.registerRootEpoch(compressedCa, k256(0xb0), "ipfs://c0"); + vm.expectRevert("Invalid epoch CA public key"); + kms.registerRootEpoch(otherCurve, k256(0xb0), "ipfs://c0"); + vm.expectRevert("Invalid epoch k256 public key"); + kms.registerRootEpoch(ca(0xa0), "", "ipfs://c0"); + vm.expectRevert("Invalid epoch k256 public key"); + kms.registerRootEpoch(ca(0xa0), uncompressedK256, "ipfs://c0"); + vm.expectRevert("Invalid epoch k256 public key"); + kms.registerRootEpoch(ca(0xa0), bytes.concat(hex"04", new bytes(32)), "ipfs://c0"); + vm.expectRevert("Empty epoch evidence URL"); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), ""); + vm.stopPrank(); } function test_AddKmsAggregatedMr() public { bytes32 mr = bytes32(uint256(123)); vm.prank(owner); - kms.addKmsAggregatedMr(mr); + kms.addKmsAggregatedMrAtEpoch(0, mr); assertTrue(kms.kmsAllowedAggregatedMrs(mr)); } @@ -92,10 +403,10 @@ contract DstackKmsTest is Test { bytes32 mr = bytes32(uint256(123)); vm.startPrank(owner); - kms.addKmsAggregatedMr(mr); + kms.addKmsAggregatedMrAtEpoch(0, mr); assertTrue(kms.kmsAllowedAggregatedMrs(mr)); - kms.removeKmsAggregatedMr(mr); + kms.removeKmsAggregatedMrAtEpoch(0, mr); assertFalse(kms.kmsAllowedAggregatedMrs(mr)); vm.stopPrank(); } @@ -147,10 +458,10 @@ contract DstackKmsTest is Test { bytes32 deviceId = bytes32(uint256(999)); vm.startPrank(owner); - kms.addKmsDevice(deviceId); + kms.addKmsDeviceAtEpoch(0, deviceId); assertTrue(kms.kmsAllowedDeviceIds(deviceId)); - kms.removeKmsDevice(deviceId); + kms.removeKmsDeviceAtEpoch(0, deviceId); assertFalse(kms.kmsAllowedDeviceIds(deviceId)); vm.stopPrank(); } @@ -159,26 +470,258 @@ contract DstackKmsTest is Test { bytes32 imageHash = bytes32(uint256(888)); vm.startPrank(owner); - kms.addOsImageHash(imageHash); + kms.addOsImageHashAtEpoch(0, imageHash); assertTrue(kms.allowedOsImages(imageHash)); - kms.removeOsImageHash(imageHash); + kms.removeOsImageHashAtEpoch(0, imageHash); assertFalse(kms.allowedOsImages(imageHash)); vm.stopPrank(); } - function test_SetKmsQuoteAndEventlog() public { - bytes memory newQuote = hex"deadbeef"; - bytes memory newEventlog = hex"cafebabe"; + function authorizationInfo(address appId) internal pure returns (IAppAuth.AppInfo memory) { + return IAppAuth.AppInfo({ + appId: appId, + composeHash: bytes32(uint256(789)), + instanceId: address(0), + deviceId: bytes32(uint256(123)), + mrAggregated: bytes32(uint256(456)), + mrSystem: bytes32(0), + osImageHash: bytes32(uint256(111)), + tcbStatus: "UpToDate", + advisoryIds: new string[](0) + }); + } + + function authorize(IAppAuth.AppInfo memory info, bool isKms) internal view returns (AuthorizationResult memory) { + AuthorizationRequest memory request = AuthorizationRequest( + info, + isKms ? keccak256("kms_serve") : keccak256("app_get_key"), + OptionalUint256(false, 0), + OptionalUint256(false, 0) + ); + return isKms ? kms.authorizeKms(request) : kms.authorizeApp(request); + } + + function test_AuthorizationOperationsFailClosedAndBootstrapBeforeRegistration() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + kms.addKmsDeviceAtEpoch(0, info.deviceId); + kms.addKmsAggregatedMrAtEpoch(0, info.mrAggregated); + vm.stopPrank(); + AuthorizationRequest memory request = AuthorizationRequest( + info, keccak256("kms_bootstrap"), OptionalUint256(false, 0), OptionalUint256(false, 0) + ); + assertTrue(kms.authorizeKms(request).authorized); + assertEq(kms.authorizeKms(request).currentRootEpochHash, bytes32(0)); + request.operation = keccak256("kms_export_keys"); + assertEq(kms.authorizeKms(request).rejectionReason, "Root epoch not registered"); + vm.prank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "https://example.com/epoch-0"); + assertTrue(kms.authorizeKms(request).authorized); + request.operation = keccak256("kms_import_keys"); + assertTrue(kms.authorizeKms(request).authorized); + assertEq(kms.authorizeApp(request).rejectionReason, "Invalid authorization operation"); + request.operation = keccak256("app_get_cert"); + assertEq(kms.authorizeKms(request).rejectionReason, "Invalid authorization operation"); + request.operation = bytes32(0); + assertEq(kms.authorizeKms(request).rejectionReason, "Invalid authorization operation"); + assertEq(kms.authorizeApp(request).rejectionReason, "Invalid authorization operation"); + } + + function test_CheckAuthorization_OptionalUint256DistinguishesZeroFromDefault() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + registerNextRootEpoch(); + registerNextRootEpoch(); + approveKmsAtEpoch(info, 1); + assertFalse(kms.allowedOsImages(info.osImageHash)); + assertTrue(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); + assertFalse(authorize(info, true).authorized); + assertTrue( + kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized, "pending handover must be explicitly approved" + ); + assertFalse(kms.authorizeApp(epochRequest(info, 1, 0)).authorized, "apps cannot fetch pending keys"); + AuthorizationResult memory result = kms.authorizeKms(kmsEpochRequest(info, 2, 0)); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Requested root epoch is not registered"); + activateNextRootEpoch(); + result = authorize(info, true); + assertTrue(result.authorized); + assertEq(result.currentRootEpoch, 1); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).authorized, "epoch one does not imply epoch zero"); + approveKmsAtEpoch(info, 0); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).authorized); + vm.stopPrank(); + } + + function test_CheckAuthorization_UnregisteredEpochReturnsRejection() public view { + AuthorizationResult memory result = authorize(authorizationInfo(address(kms)), true); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Root epoch not registered"); + assertEq(result.currentRootEpoch, 0); + assertEq(result.currentRootEpochHash, bytes32(0)); + } + function test_CheckAuthorization_ValidatesRequestedPolicy() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); vm.startPrank(owner); - kms.setKmsQuote(newQuote); - kms.setKmsEventlog(newEventlog); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "https://example.com/epoch-0"); + kms.setGatewayAppId("gateway"); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + kms.addKmsDeviceAtEpoch(0, info.deviceId); + kms.addKmsAggregatedMrAtEpoch(0, info.mrAggregated); + address appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); vm.stopPrank(); - (,, bytes memory storedQuote, bytes memory storedEventlog) = kms.kmsInfo(); - assertEq(storedQuote, newQuote); - assertEq(storedEventlog, newEventlog); + AuthorizationResult memory result = authorize(info, true); + assertTrue(result.authorized); + assertEq(result.rejectionReason, ""); + assertEq(result.gatewayAppId, "gateway"); + assertEq(result.currentRootEpochHash, chainHash(bytes32(0), ca(0xa0), k256(0xb0))); + + info.appId = user; + result = authorize(info, false); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "App not registered"); + + info.appId = appId; + info.mrAggregated = bytes32(0); + result = authorize(info, false); + assertTrue(result.authorized); + result = authorize(info, true); + 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)); + result = kms.authorizeApp(appRequest); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Requested app epoch is not active"); + assertEq(result.currentRootEpochHash, chainHash(bytes32(0), ca(0xa0), k256(0xb0))); + info.appId = address(kms); + result = authorize(info, false); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "App not registered"); + result = authorize(info, true); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Aggregated MR not allowed"); + } + + function test_AppEpochCapabilityIsOptionalButGetterFailuresPropagate() public { + address policy = address(new DstackApp()); + kms.registerApp(policy); + 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(IAppEpochAuth.currentAppEpoch, ()), "epoch unavailable"); + vm.expectRevert("epoch unavailable"); + kms.currentAppEpoch(policy); + } + + function test_AppEpochQueryRevertsForUnregisteredOrUndeployedApps() public { + address policy = address(new DstackApp()); + vm.expectRevert("App not registered"); + kms.currentAppEpoch(policy); + address eoa = address(0xbeef); + kms.registerApp(eoa); + vm.expectRevert("App not deployed or invalid address"); + kms.currentAppEpoch(eoa); + // A registered legacy policy without the capability is epoch zero. + kms.registerApp(policy); + vm.mockCall( + policy, abi.encodeCall(IERC165.supportsInterface, (type(IAppEpochAuth).interfaceId)), abi.encode(false) + ); + assertEq(kms.currentAppEpoch(policy), 0); + // The authorization paths still report the rejection instead of reverting. + DstackKms.AppInfo memory info = authorizationInfo(address(0x1234)); + (bool allowed, string memory reason) = kms.isAppAllowed(info); + assertFalse(allowed); + assertEq(reason, "App not registered"); + vm.prank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "https://example.com/epoch-0"); + assertEq(authorize(info, false).rejectionReason, "App not registered"); + } + + function test_AppEpochRotationIsOwnerScopedAndDoesNotChangeRootEpoch() public { + DstackKms.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + kms.registerRootEpoch(ca(0xa0), k256(0xb0), "https://example.com/epoch-0"); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + address appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + address otherApp = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + info.appId = appId; + assertEq(kms.currentAppEpoch(appId), 0); + vm.prank(owner); + vm.expectRevert(); + DstackApp(appId).advanceAppEpoch(0); + vm.prank(user); + DstackApp(appId).advanceAppEpoch(0); + vm.prank(user); + vm.expectRevert("App epoch changed"); + DstackApp(appId).advanceAppEpoch(0); + assertEq(kms.currentAppEpoch(appId), 1); + assertEq(kms.currentAppEpoch(otherApp), 0); + (uint256 rootEpoch,) = kms.currentRootEpoch(); + assertEq(rootEpoch, 0); + AuthorizationRequest memory request = AuthorizationRequest( + info, keccak256("app_get_key"), OptionalUint256(false, 0), OptionalUint256(false, 0) + ); + AuthorizationResult memory result = kms.authorizeApp(request); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Compose hash not allowed"); + assertEq(result.currentAppEpoch, 1); + request.requestedAppEpoch = OptionalUint256(true, 0); + assertTrue(kms.authorizeApp(request).authorized); + request.requestedAppEpoch.value = 2; + result = kms.authorizeApp(request); + assertFalse(result.authorized); + assertEq(result.currentAppEpoch, 1); + assertEq(result.rejectionReason, "Requested app epoch is not active"); + request.requestedAppEpoch = OptionalUint256(false, 0); + request.appInfo.composeHash = bytes32(0); + result = kms.authorizeApp(request); + assertFalse(result.authorized); + assertEq(result.currentAppEpoch, 1); + vm.prank(user); + DstackApp(appId).advanceAppEpoch(1); + assertEq(kms.currentAppEpoch(appId), 2); + } + + function test_CheckAuthorization_RejectionIncludesActivatedEpoch() public { + 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"); + kms.activateRootEpoch(1, chainHash(chainHash(bytes32(0), ca(0xa0), k256(0xb0)), ca(0xa1), k256(0xb1))); + vm.stopPrank(); + + bytes32 hash1 = chainHash(chainHash(bytes32(0), ca(0xa0), k256(0xb0)), ca(0xa1), k256(0xb1)); + AuthorizationResult memory result = authorize(authorizationInfo(user), false); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "App not registered"); + assertEq(result.currentRootEpoch, 1); + assertEq(result.currentRootEpochHash, hash1); + + AuthorizationRequest memory request = AuthorizationRequest( + authorizationInfo(user), keccak256("kms_serve"), OptionalUint256(false, 0), OptionalUint256(false, 0) + ); + result = kms.authorizeApp(request); + assertEq(result.rejectionReason, "Invalid authorization operation"); + assertEq(result.currentRootEpoch, 1); + assertEq(result.currentRootEpochHash, hash1); } function test_IsKmsAllowed() public { @@ -188,12 +731,12 @@ contract DstackKmsTest is Test { // Setup allowed values vm.startPrank(owner); - kms.addKmsDevice(deviceId); - kms.addKmsAggregatedMr(mrAggregated); - kms.addOsImageHash(osImageHash); + kms.addKmsDeviceAtEpoch(0, deviceId); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); + kms.addOsImageHashAtEpoch(0, osImageHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: address(0), composeHash: bytes32(0), instanceId: address(0), @@ -217,12 +760,12 @@ contract DstackKmsTest is Test { // Setup allowed values vm.startPrank(owner); - kms.addKmsDevice(deviceId); - kms.addKmsAggregatedMr(mrAggregated); - kms.addOsImageHash(osImageHash); + kms.addKmsDeviceAtEpoch(0, deviceId); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); + kms.addOsImageHashAtEpoch(0, osImageHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: address(0), composeHash: bytes32(0), instanceId: address(0), @@ -246,11 +789,11 @@ contract DstackKmsTest is Test { bytes32 osImageHash = bytes32(uint256(111)); vm.startPrank(owner); - kms.addOsImageHash(osImageHash); + kms.addOsImageHashAtEpoch(0, osImageHash); address appId = kms.deployAndRegisterApp(user, false, false, deviceId, composeHash); vm.stopPrank(); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: appId, composeHash: composeHash, instanceId: address(0), @@ -378,11 +921,435 @@ contract DstackKmsTest is Test { // Old owner has lost authority. vm.prank(owner); vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", owner)); - kms.addKmsAggregatedMr(mr); + kms.addKmsAggregatedMrAtEpoch(0, mr); // New owner has it. vm.prank(newOwner); - kms.addKmsAggregatedMr(mr); + kms.addKmsAggregatedMrAtEpoch(0, 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 (AuthorizationRequest memory) + { + return AuthorizationRequest( + info, keccak256("app_get_key"), OptionalUint256(true, appEpoch), OptionalUint256(true, root) + ); + } + + function kmsEpochRequest( + IAppAuth.AppInfo memory info, + uint256 root, + uint256 appEpoch + ) + internal + pure + returns (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)).authorized); + assertFalse(authorize(info, true).authorized); + (bool allowed, string memory reason) = kms.isKmsAllowed(info); + assertFalse(allowed, "legacy KMS check ends at rotation"); + assertEq(reason, "Root epoch requires epoch-aware authorization"); + kms.addOsImageHashAtEpoch(1, info.osImageHash); + assertFalse(authorize(info, true).authorized); + kms.addKmsAggregatedMrAtEpoch(1, info.mrAggregated); + assertFalse(authorize(info, true).authorized); + kms.addKmsDeviceAtEpoch(1, info.deviceId); + assertTrue(authorize(info, true).authorized); + kms.removeOsImageHashAtEpoch(1, info.osImageHash); + assertFalse(authorize(info, true).authorized); + assertTrue(kms.allowedOsImages(info.osImageHash)); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).authorized); + kms.addOsImageHashAtEpoch(1, info.osImageHash); + kms.removeKmsAggregatedMrAtEpoch(0, info.mrAggregated); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).authorized); + assertTrue(authorize(info, true).authorized); + kms.addKmsAggregatedMrAtEpoch(0, info.mrAggregated); + kms.removeKmsDeviceAtEpoch(0, info.deviceId); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 0, 0)).authorized); + assertTrue(authorize(info, true).authorized); + vm.stopPrank(); + } + + function test_AppAndRootRowsAreSelectedIndependently() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHashAtEpoch(0, 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)).authorized); + assertFalse(kms.authorizeApp(epochRequest(info, 1, 0)).authorized); + assertTrue(kms.authorizeApp(epochRequest(info, 0, 0)).authorized); + vm.startPrank(user); + policy.addComposeHashAtEpoch(1, info.composeHash); + policy.addDeviceAtEpoch(1, info.deviceId); + vm.stopPrank(); + assertTrue(kms.authorizeApp(epochRequest(info, 0, 1)).authorized); + assertFalse(authorize(info, false).authorized); + vm.prank(owner); + kms.addOsImageHashAtEpoch(1, info.osImageHash); + assertTrue(authorize(info, false).authorized); + assertTrue(kms.authorizeApp(epochRequest(info, 1, 0)).authorized); + vm.prank(owner); + kms.removeOsImageHashAtEpoch(0, info.osImageHash); + assertFalse(kms.authorizeApp(epochRequest(info, 0, 0)).authorized); + assertTrue(authorize(info, false).authorized); + } + + 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_AppOperationIsForwardedAndLegacyAuthorizationEndsAtRotation() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + AuthorizationRequest memory request = epochRequest(info, 0, 0); + vm.mockCall( + info.appId, + abi.encodeCall(IAppEpochAuth.authorizeApp, (request)), + abi.encode(AuthorizationResult(false, "key retrieval denied", "", 0, bytes32(0), 0, 0, bytes32(0))) + ); + assertEq(kms.authorizeApp(request).rejectionReason, "key retrieval denied"); + request.operation = keccak256("app_get_cert"); + vm.mockCall( + info.appId, + abi.encodeCall(IAppEpochAuth.authorizeApp, (request)), + abi.encode(AuthorizationResult(true, "", "", 0, bytes32(0), 0, 0, bytes32(0))) + ); + assertTrue(kms.authorizeApp(request).authorized); + (bool allowed,) = kms.isAppAllowed(info); + assertTrue(allowed); + + vm.startPrank(owner); + registerNextRootEpoch(); + activateNextRootEpoch(); + vm.stopPrank(); + string memory reason; + (allowed, reason) = kms.isAppAllowed(info); + assertFalse(allowed); + assertEq(reason, "Root epoch requires epoch-aware authorization"); + } + + function test_LegacyAppCheckRefusesOnceTheAppEpochAdvances() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + (bool allowed,) = kms.isAppAllowed(info); + assertTrue(allowed); + + vm.prank(user); + DstackApp(info.appId).advanceAppEpoch(0); + string memory reason; + (allowed, reason) = kms.isAppAllowed(info); + assertFalse(allowed); + assertEq(reason, "App epoch requires epoch-aware authorization"); + } + + function test_AuthorizeAppForwardsRequestAndFillsKmsFields() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.setGatewayAppId("gateway"); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + (, bytes32 rootHash) = kms.currentRootEpoch(); + AuthorizationRequest memory request = epochRequest(info, 0, 3); + request.requestedRootEpoch = OptionalUint256(false, 0); + // The KMS overwrites whatever the policy reports for fields it owns. + vm.mockCall( + info.appId, + abi.encodeCall(IAppEpochAuth.authorizeApp, (request)), + abi.encode( + AuthorizationResult(true, "", "policy-gateway", 9, bytes32(uint256(9)), 3, 9, bytes32(uint256(9))) + ) + ); + vm.expectCall(info.appId, abi.encodeCall(IAppEpochAuth.authorizeApp, (request)), 1); + // The app epoch comes from the forwarded decision only. + vm.expectCall(info.appId, abi.encodeCall(IAppEpochAuth.currentAppEpoch, ()), 0); + AuthorizationResult memory result = kms.authorizeApp(request); + assertTrue(result.authorized); + assertEq(result.currentAppEpoch, 3); + assertEq(result.gatewayAppId, "gateway"); + assertEq(result.currentRootEpoch, 0); + assertEq(result.currentRootEpochHash, rootHash); + assertEq(result.latestRootEpoch, 0); + assertEq(result.latestRootEpochHash, rootHash); + } + + function test_AuthorizationReportsTheLatestRegisteredRootEpoch() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + AuthorizationResult memory result = authorize(authorizationInfo(address(kms)), true); + assertEq(result.latestRootEpoch, 0); + assertEq(result.latestRootEpochHash, bytes32(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + registerNextRootEpoch(); + vm.stopPrank(); + (,,, bytes32 currentHash) = kms.rootEpoch(0); + (,,, bytes32 pendingHash) = kms.rootEpoch(1); + assertTrue(currentHash != pendingHash); + AuthorizationRequest memory request = epochRequest(info, 0, 0); + AuthorizationResult[3] memory results = [ + kms.authorizeKms(kmsEpochRequest(authorizationInfo(address(kms)), 1, 0)), + kms.authorizeApp(request), + // A KMS-side rejection still reports both epochs. + kms.authorizeApp(epochRequest(info, 1, 0)) + ]; + for (uint256 i; i < results.length; i++) { + assertEq(results[i].currentRootEpoch, 0); + assertEq(results[i].currentRootEpochHash, currentHash); + assertEq(results[i].latestRootEpoch, 1); + assertEq(results[i].latestRootEpochHash, pendingHash); + } + assertTrue(results[1].authorized); + assertEq(results[2].rejectionReason, "Requested root epoch is not active"); + } + + function test_AuthorizeAppKeepsKmsChecksBeforeForwarding() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + vm.stopPrank(); + bytes memory forwarded = abi.encodeWithSelector(IAppEpochAuth.authorizeApp.selector); + vm.expectCall(info.appId, forwarded, 0); + assertEq(kms.authorizeApp(epochRequest(info, 0, 0)).rejectionReason, "OS image is not allowed"); + AuthorizationRequest memory request = epochRequest(info, 0, 0); + request.operation = keccak256("kms_serve"); + assertEq(kms.authorizeApp(request).rejectionReason, "Invalid authorization operation"); + assertEq(kms.authorizeApp(epochRequest(info, 1, 0)).rejectionReason, "Requested root epoch is not active"); + info.appId = user; + assertEq(kms.authorizeApp(epochRequest(info, 0, 0)).rejectionReason, "App not registered"); + } + + function test_LegacyAppPolicyCannotAuthorizeNonzeroAppEpochs() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHashAtEpoch(0, 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)).authorized); + AuthorizationResult memory result = authorize(info, false); + assertTrue(result.authorized); + assertEq(result.currentAppEpoch, 0); + result = kms.authorizeApp(epochRequest(info, 0, 1)); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Requested app epoch is not active"); + vm.clearMockedCalls(); + vm.mockCallRevert( + info.appId, abi.encodeCall(IAppEpochAuth.authorizeApp, (epochRequest(info, 0, 0))), "policy unavailable" + ); + vm.expectRevert(bytes("policy unavailable")); + kms.authorizeApp(epochRequest(info, 0, 0)); + } + + function test_RangeSettersCoverEveryRowInRange() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + registerNextRootEpoch(); + kms.addOsImageHashAtEpochs(0, 1, info.osImageHash); + kms.addKmsAggregatedMrAtEpochs(0, 1, info.mrAggregated); + kms.addKmsDeviceAtEpochs(0, 1, info.deviceId); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + for (uint256 epoch = 0; epoch < 2; ++epoch) { + assertTrue(kms.allowedOsImagesAtEpoch(epoch, info.osImageHash)); + assertTrue(kms.kmsAllowedAggregatedMrsAtEpoch(epoch, info.mrAggregated)); + assertTrue(kms.kmsAllowedDeviceIdsAtEpoch(epoch, info.deviceId)); + } + assertTrue(authorize(info, true).authorized); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + assertTrue(authorize(info, false).authorized); + + kms.removeKmsAggregatedMrAtEpochs(0, 1, info.mrAggregated); + assertFalse(authorize(info, true).authorized); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + kms.addKmsAggregatedMrAtEpochs(0, 1, info.mrAggregated); + kms.removeKmsDeviceAtEpochs(0, 1, info.deviceId); + assertFalse(authorize(info, true).authorized); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + kms.addKmsDeviceAtEpochs(0, 1, info.deviceId); + + vm.expectRevert("Invalid root epoch range"); + kms.removeOsImageHashAtEpochs(1, 0, info.osImageHash); + vm.expectRevert("Unknown root epoch"); + kms.removeOsImageHashAtEpochs(0, 2, info.osImageHash); + vm.stopPrank(); + } + + function test_RangeRemovalRevokesOlderRootRows() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(0)); + vm.startPrank(owner); + registerNextRootEpoch(); + kms.addOsImageHashAtEpoch(0, info.osImageHash); + info.appId = kms.deployAndRegisterApp(user, false, false, info.deviceId, info.composeHash); + registerNextRootEpoch(); + activateNextRootEpoch(); + kms.addOsImageHashAtEpoch(1, info.osImageHash); + assertTrue(authorize(info, false).authorized); + assertTrue(kms.authorizeApp(epochRequest(info, 0, 0)).authorized); + + kms.removeOsImageHashAtEpochs(0, 1, info.osImageHash); + vm.stopPrank(); + assertFalse(authorize(info, false).authorized); + for (uint256 epoch = 0; epoch < 2; ++epoch) { + assertFalse(kms.allowedOsImagesAtEpoch(epoch, info.osImageHash)); + AuthorizationResult memory result = kms.authorizeApp(epochRequest(info, epoch, 0)); + assertEq(result.rejectionReason, "OS image is not allowed"); + } + } + + function test_PopThenReRegisterIdenticalKeysStartsWithEmptyRows() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + registerNextRootEpoch(); + registerNextRootEpoch(); + approveKmsAtEpoch(info, 1); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + (,,, bytes32 popped) = kms.rootEpoch(1); + + kms.popRootEpoch(1, popped); + assertFalse(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); + vm.expectRevert("Unknown root epoch"); + kms.removeOsImageHashAtEpoch(1, info.osImageHash); + + registerNextRootEpoch(); + (,,, bytes32 again) = kms.rootEpoch(1); + assertEq(again, popped); + assertFalse(kms.allowedOsImagesAtEpoch(1, info.osImageHash)); + assertFalse(kms.kmsAllowedAggregatedMrsAtEpoch(1, info.mrAggregated)); + assertFalse(kms.kmsAllowedDeviceIdsAtEpoch(1, info.deviceId)); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + vm.stopPrank(); + } + + function test_KmsServeIsLimitedToTheCurrentRootEpoch() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + registerNextRootEpoch(); + registerNextRootEpoch(); + approveKmsAtEpoch(info, 0); + approveKmsAtEpoch(info, 1); + vm.stopPrank(); + AuthorizationRequest memory request = kmsEpochRequest(info, 1, 0); + assertTrue(kms.authorizeKms(request).authorized); + request.operation = keccak256("kms_export_keys"); + assertTrue(kms.authorizeKms(request).authorized); + request.operation = keccak256("kms_serve"); + assertEq(kms.authorizeKms(request).rejectionReason, "Requested root epoch is not active"); + request.requestedRootEpoch = OptionalUint256(true, 0); + assertTrue(kms.authorizeKms(request).authorized); + request.requestedRootEpoch = OptionalUint256(false, 0); + assertTrue(kms.authorizeKms(request).authorized); + } + + function test_AtEpochSettersChangeOnlyTheirRow() public { + IAppAuth.AppInfo memory info = authorizationInfo(address(kms)); + vm.startPrank(owner); + registerNextRootEpoch(); + registerNextRootEpoch(); + approveKmsAtEpoch(info, 1); + assertFalse(kms.allowedOsImagesAtEpoch(0, info.osImageHash)); + assertFalse(kms.kmsAllowedAggregatedMrsAtEpoch(0, info.mrAggregated)); + assertFalse(kms.kmsAllowedDeviceIdsAtEpoch(0, info.deviceId)); + assertFalse(authorize(info, true).authorized); + assertTrue(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + + approveKmsAtEpoch(info, 0); + kms.removeOsImageHashAtEpoch(1, info.osImageHash); + kms.removeKmsAggregatedMrAtEpoch(1, info.mrAggregated); + kms.removeKmsDeviceAtEpoch(1, info.deviceId); + assertTrue(kms.allowedOsImagesAtEpoch(0, info.osImageHash)); + assertTrue(kms.kmsAllowedAggregatedMrsAtEpoch(0, info.mrAggregated)); + assertTrue(kms.kmsAllowedDeviceIdsAtEpoch(0, info.deviceId)); + assertTrue(authorize(info, true).authorized); + assertFalse(kms.authorizeKms(kmsEpochRequest(info, 1, 0)).authorized); + vm.stopPrank(); + } } diff --git a/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol b/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol new file mode 100644 index 000000000..c7f2d6be2 --- /dev/null +++ b/dstack/kms/auth-eth/test/DstackKmsEpoch.symbolic.t.sol @@ -0,0 +1,635 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../contracts/AuthorizationOperation.sol"; +import "../contracts/DstackApp.sol"; +import "../contracts/DstackKms.sol"; + +function _appAuth( + DstackApp target, + IAppAuth.AppInfo memory info, + uint256 epoch, + bytes32 operation +) + view + returns (bool, string memory) +{ + AuthorizationResult memory result = target.authorizeApp( + AuthorizationRequest(info, operation, OptionalUint256(true, epoch), OptionalUint256(false, 0)) + ); + return (result.authorized, result.rejectionReason); +} + +/// An app policy predating app epochs: IAppAuth only, one allowed compose hash. +contract LegacyAppPolicy is IAppAuth { + bytes32 internal immutable allowed; + + constructor(bytes32 composeHash) { + allowed = composeHash; + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == 0x01ffc9a7 || interfaceId == 0x1e079198; + } + + function isAppAllowed(AppInfo calldata info) external view returns (bool, string memory) { + return info.composeHash == allowed ? (true, "") : (false, "Compose hash not allowed"); + } +} + +/// @notice Halmos symbolic tests for DstackKms root epochs and epoch-scoped +/// authorization. +/// +/// setUp registers root epochs 0, 1 and 2, activates 1 (so 2 is pending), +/// admits one KMS identity and OS image per epoch, and deploys an app at app +/// epoch 1. Halmos has no symbolic storage, so the properties are proved from +/// this state, after one symbolic call where noted. +/// +/// Run with `halmos --contract DstackKmsEpochSymbolicTest`. +contract DstackKmsEpochSymbolicTest is Test { + DstackKms internal kms; + DstackApp internal app; + address internal constant OWNER = address(0xA11CE); + bytes27 internal constant P256_SPKI_PREFIX = 0x3059301306072a8648ce3d020106082a8648ce3d03010703420004; + uint256 internal constant KMS_POLICY_SETTERS = 12; + + /// The AppInfo fields DstackKms and DstackApp read; the app ID is the + /// deployed app and the remaining fields stay zero. + struct Identity { + bytes32 osImageHash; + bytes32 mrAggregated; + bytes32 deviceId; + bytes32 composeHash; + bool upToDate; + } + + function setUp() public { + DstackKms impl = new DstackKms(); + bytes memory initData = abi.encodeCall(DstackKms.initialize, (OWNER, address(new DstackApp()))); + kms = DstackKms(address(new ERC1967Proxy(address(impl), initData))); + + vm.startPrank(OWNER); + for (uint256 epoch = 0; epoch < 3; epoch++) { + kms.registerRootEpoch(_ca(bytes32(epoch + 1), bytes32(0)), _k256(true, bytes32(epoch + 1)), "evidence"); + kms.addOsImageHashAtEpoch(epoch, _id(0x05, epoch)); + kms.addKmsAggregatedMrAtEpoch(epoch, _id(0x0a, epoch)); + kms.addKmsDeviceAtEpoch(epoch, _id(0x0d, epoch)); + } + (, bytes32 hash1) = _entry(1); + kms.activateRootEpoch(1, hash1); + + app = DstackApp(kms.deployAndRegisterApp(OWNER, false, false, true, bytes32(0), _id(0xc0, 0))); + app.advanceAppEpoch(0); + app.addComposeHashAtEpoch(1, _id(0xc0, 1)); + app.setAllowAnyDeviceAtEpoch(1, true); + vm.stopPrank(); + } + + function _id(uint256 kind, uint256 epoch) internal pure returns (bytes32) { + return bytes32((kind << 8) | epoch); + } + + function _ca(bytes32 x, bytes32 y) internal pure returns (bytes memory) { + return abi.encodePacked(P256_SPKI_PREFIX, x, y); + } + + function _k256(bool even, bytes32 x) internal pure returns (bytes memory) { + return abi.encodePacked(even ? bytes1(0x02) : bytes1(0x03), x); + } + + function _current() internal view returns (uint256 epoch) { + (epoch,) = kms.currentRootEpoch(); + } + + /// Hash of an epoch's keys, and its chain hash. + function _entry(uint256 epoch) internal view returns (bytes32 keys, bytes32 chainHash) { + (bytes memory ca, bytes memory k256,, bytes32 hash) = kms.rootEpoch(epoch); + return (keccak256(abi.encode(ca, k256)), hash); + } + + function _info(Identity memory id) internal view returns (IAppAuth.AppInfo memory info) { + info.appId = address(app); + info.osImageHash = id.osImageHash; + info.mrAggregated = id.mrAggregated; + info.deviceId = id.deviceId; + info.composeHash = id.composeHash; + info.tcbStatus = id.upToDate ? "UpToDate" : "OutOfDate"; + } + + function _request( + IAppAuth.AppInfo memory info, + bytes32 operation, + bool rootSet, + uint256 root, + bool appSet, + uint256 appEpoch + ) + internal + pure + returns (AuthorizationRequest memory) + { + return AuthorizationRequest(info, operation, OptionalUint256(appSet, appEpoch), OptionalUint256(rootSet, root)); + } + + /// Calldata of one KMS policy setter: 0-5 write the row of `epoch`, 6-11 + /// every row from `epoch` through `to`. + function _kmsPolicyCall( + uint256 which, + uint256 epoch, + uint256 to, + bytes32 word + ) + internal + pure + returns (bytes memory data) + { + uint256 latest = to; + if (which == 0) data = abi.encodeCall(DstackKms.addKmsAggregatedMrAtEpoch, (epoch, word)); + else if (which == 1) data = abi.encodeCall(DstackKms.removeKmsAggregatedMrAtEpoch, (epoch, word)); + else if (which == 2) data = abi.encodeCall(DstackKms.addKmsDeviceAtEpoch, (epoch, word)); + else if (which == 3) data = abi.encodeCall(DstackKms.removeKmsDeviceAtEpoch, (epoch, word)); + else if (which == 4) data = abi.encodeCall(DstackKms.addOsImageHashAtEpoch, (epoch, word)); + else if (which == 5) data = abi.encodeCall(DstackKms.removeOsImageHashAtEpoch, (epoch, word)); + else if (which == 6) data = abi.encodeCall(DstackKms.addKmsAggregatedMrAtEpochs, (epoch, latest, word)); + else if (which == 7) data = abi.encodeCall(DstackKms.removeKmsAggregatedMrAtEpochs, (epoch, latest, word)); + else if (which == 8) data = abi.encodeCall(DstackKms.addKmsDeviceAtEpochs, (epoch, latest, word)); + else if (which == 9) data = abi.encodeCall(DstackKms.removeKmsDeviceAtEpochs, (epoch, latest, word)); + else if (which == 10) data = abi.encodeCall(DstackKms.addOsImageHashAtEpochs, (epoch, latest, word)); + else data = abi.encodeCall(DstackKms.removeOsImageHashAtEpochs, (epoch, latest, word)); + } + + // --------------------------------------------------------------- + // Root registry step invariant. After any single call to any + // enumerated mutating function, by any caller with symbolic args: + // - the current epoch stays registered and only the owner's + // activation of the next epoch, naming its chain hash, moves it, + // by one; + // - the registry changes by at most one entry at its end, and only + // the owner's pop of a pending epoch, naming its chain hash, + // removes one; + // - existing entries keep their keys and chain hashes; + // - every chain hash commits to its keys and the previous hash; + // - kmsInfo holds the current epoch's keys and no legacy evidence. + // --------------------------------------------------------------- + + /// Calldata of any enumerated mutating function. + function _anyCall( + uint256 which, + uint256 epoch, + bytes32 word, + bytes32 hash, + bool flag, + address addr + ) + internal + view + returns (bytes memory data) + { + if (which < KMS_POLICY_SETTERS) { + data = _kmsPolicyCall(which, epoch, kms.rootEpochCount() - 1, word); + } else if (which == 12) { + data = abi.encodeCall(DstackKms.registerRootEpoch, (_ca(word, hash), _k256(flag, word), "url")); + } else if (which == 13) { + data = abi.encodeCall(DstackKms.activateRootEpoch, (epoch, hash)); + } else if (which == 14) { + data = abi.encodeCall(DstackKms.popRootEpoch, (epoch, hash)); + } else if (which == 15) { + data = abi.encodeCall(DstackKms.setRootEpochEvidenceUrl, (epoch, "url")); + } else if (which == 16) { + data = abi.encodeCall(DstackKms.setGatewayAppId, ("gateway")); + } else if (which == 17) { + data = abi.encodeCall(DstackKms.setAppImplementation, (addr)); + } else if (which == 18) { + data = abi.encodeCall(DstackKms.registerApp, (addr)); + } else if (which == 19) { + data = abi.encodeWithSignature( + "deployAndRegisterApp(address,bool,bool,bool,bytes32,bytes32)", addr, flag, flag, flag, word, hash + ); + } else if (which == 20) { + data = abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, (addr, "")); + } else if (which == 21) { + data = abi.encodeCall(Ownable2StepUpgradeable.transferOwnership, (addr)); + } else if (which == 22) { + data = abi.encodeCall(OwnableUpgradeable.renounceOwnership, ()); + } + } + + function _snapshot() internal view returns (bytes32[] memory entries, bytes32[] memory hashes) { + uint256 count = kms.rootEpochCount(); + entries = new bytes32[](count); + hashes = new bytes32[](count); + for (uint256 i = 0; i < count; i++) { + (entries[i], hashes[i]) = _entry(i); + } + } + + /// Every chain hash commits to its keys and the previous hash, entries + /// that existed before keep their keys and hashes, and kmsInfo holds + /// the current keys without legacy evidence. + function _assertRegistryConsistent(bytes32[] memory entries, bytes32[] memory hashes) internal view { + bytes32 previous; + for (uint256 i = 0; i < kms.rootEpochCount(); i++) { + (bytes memory ca, bytes memory k256,, bytes32 chainHash) = kms.rootEpoch(i); + assert(chainHash == keccak256(abi.encodePacked(previous, keccak256(ca), keccak256(k256)))); + if (i < entries.length) assert(keccak256(abi.encode(ca, k256)) == entries[i] && chainHash == hashes[i]); + previous = chainHash; + } + (bytes memory infoK256, bytes memory infoCa, bytes memory quote, bytes memory eventlog) = kms.kmsInfo(); + (bytes memory currentCa, bytes memory currentK256,,) = kms.rootEpoch(_current()); + assert(keccak256(infoCa) == keccak256(currentCa) && keccak256(infoK256) == keccak256(currentK256)); + assert(quote.length == 0 && eventlog.length == 0); + } + + /// @custom:halmos --loop 4 + function check_RootRegistry_StepInvariant( + address caller, + uint256 which, + uint256 epoch, + bytes32 word, + bytes32 hash, + bool flag, + address addr + ) + external + { + vm.assume(which <= 22); + // Registry functions index the root epoch array, which Halmos cannot + // do symbolically. Only epochs 0-2 exist, so every larger index + // behaves as 3; branching makes each case concrete. + if (which >= 13 && which <= 15) epoch = epoch == 0 ? 0 : epoch == 1 ? 1 : epoch == 2 ? 2 : 3; + uint256 currentBefore = _current(); + (bytes32[] memory entries, bytes32[] memory hashes) = _snapshot(); + + vm.prank(caller); + (bool ok,) = address(kms).call(_anyCall(which, epoch, word, hash, flag, addr)); + ok; + + uint256 count = kms.rootEpochCount(); + uint256 current = _current(); + assert(current < count); + if (current != currentBefore) { + assert(current == currentBefore + 1); + assert(caller == OWNER && which == 13 && epoch == current && hash == hashes[current]); + } + assert(count + 1 >= entries.length && count <= entries.length + 1); + if (count < entries.length) { + assert(caller == OWNER && which == 14 && epoch == count && epoch > currentBefore && hash == hashes[count]); + } + if (count > entries.length) assert(caller == OWNER && which == 12); + _assertRegistryConsistent(entries, hashes); + } + + /// Drop the last byte, keep the bytes, or append a zero byte. + function _resize(bytes memory data, int8 change) internal pure returns (bytes memory) { + if (change < 0) { + assembly { + mstore(data, sub(mload(data), 1)) + } + return data; + } + return change == 0 ? data : abi.encodePacked(data, bytes1(0)); + } + + // --------------------------------------------------------------- + // registerRootEpoch accepts exactly the encodings the KMS + // produces: a 91-byte uncompressed P-256 SubjectPublicKeyInfo and + // a 33-byte compressed secp256k1 key. + // --------------------------------------------------------------- + + function check_RegisterRootEpoch_AcceptsOnlyCanonicalEncodings( + bytes27 prefix, + bytes32 x, + bytes1 tag, + int8 caLength, + int8 k256Length + ) + external + { + bytes memory ca = _resize(abi.encodePacked(prefix, x, x), caLength); + bytes memory k256 = _resize(abi.encodePacked(tag, x), k256Length); + + vm.prank(OWNER); + (bool ok,) = address(kms).call(abi.encodeCall(DstackKms.registerRootEpoch, (ca, k256, "url"))); + + bool canonical = ca.length == 91 && prefix == P256_SPKI_PREFIX && k256.length == 33 + && (tag == bytes1(0x02) || tag == bytes1(0x03)); + assert(ok == canonical); + } + + // --------------------------------------------------------------- + // Authorization bounds: an operation of the wrong kind, an app + // root epoch above the current one, a KMS root epoch above the + // latest registered one, or an app epoch above the app's current + // one is never authorized. Every result reports the current root + // epoch and its chain hash. + // --------------------------------------------------------------- + + function check_Authorize_EnforcesEpochBounds( + bool isKms, + bytes32 operation, + bool rootSet, + uint256 root, + bool appSet, + uint256 appEpoch, + Identity calldata id + ) + external + view + { + AuthorizationRequest memory request = _request(_info(id), operation, rootSet, root, appSet, appEpoch); + AuthorizationResult memory result = isKms ? kms.authorizeKms(request) : kms.authorizeApp(request); + + (uint256 current, bytes32 currentHash) = kms.currentRootEpoch(); + assert(result.currentRootEpoch == current && result.currentRootEpochHash == currentHash); + + bool kindMatches = isKms ? AuthorizationOperation.isKms(operation) : AuthorizationOperation.isApp(operation); + bool issuesKeys = !isKms || operation == AuthorizationOperation.KMS_SERVE; + uint256 rootBound = issuesKeys ? current : kms.rootEpochCount() - 1; + uint256 appBound = isKms ? 0 : app.currentAppEpoch(); + if (!kindMatches || (rootSet && root > rootBound) || (appSet && appEpoch > appBound)) { + assert(!result.authorized); + } + } + + // --------------------------------------------------------------- + // Before any root epoch is registered, only KMS bootstrap can be + // authorized. + // --------------------------------------------------------------- + + function check_Authorize_OnlyBootstrapBeforeRegistration( + bool isKms, + bytes32 operation, + Identity calldata id + ) + external + { + DstackKms impl = new DstackKms(); + bytes memory initData = abi.encodeCall(DstackKms.initialize, (OWNER, address(new DstackApp()))); + DstackKms fresh = DstackKms(address(new ERC1967Proxy(address(impl), initData))); + vm.startPrank(OWNER); + fresh.addOsImageHashAtEpoch(0, id.osImageHash); + fresh.addKmsAggregatedMrAtEpoch(0, id.mrAggregated); + fresh.addKmsDeviceAtEpoch(0, id.deviceId); + vm.stopPrank(); + + AuthorizationRequest memory request = _request(_info(id), operation, false, 0, false, 0); + AuthorizationResult memory result = isKms ? fresh.authorizeKms(request) : fresh.authorizeApp(request); + if (result.authorized) assert(isKms && operation == AuthorizationOperation.KMS_BOOTSTRAP); + } + + // --------------------------------------------------------------- + // KMS authorization matches the policy of the selected root epoch + // (requested, or current when unset), after any one owner write. + // --------------------------------------------------------------- + + /// @custom:halmos --loop 4 + function check_AuthorizeKms_MatchesEpochPolicy( + uint256 which, + uint256 writeEpoch, + uint256 writeTo, + bytes32 word, + bytes32 operation, + bool rootSet, + uint256 root, + Identity calldata id + ) + external + { + if (which < KMS_POLICY_SETTERS) { + bytes memory data = _kmsPolicyCall(which, writeEpoch, writeTo, word); + vm.prank(OWNER); + (bool ok,) = address(kms).call(data); + ok; + } + + AuthorizationResult memory result = kms.authorizeKms(_request(_info(id), operation, rootSet, root, false, 0)); + + uint256 epoch = rootSet ? root : _current(); + uint256 bound = operation == AuthorizationOperation.KMS_SERVE ? _current() : kms.rootEpochCount() - 1; + bool expected = AuthorizationOperation.isKms(operation) && epoch <= bound && id.upToDate + && kms.allowedOsImagesAtEpoch(epoch, id.osImageHash) + && kms.kmsAllowedAggregatedMrsAtEpoch(epoch, id.mrAggregated) + && kms.kmsAllowedDeviceIdsAtEpoch(epoch, id.deviceId); + assert(result.authorized == expected); + } + + // --------------------------------------------------------------- + // App authorization admits exactly a registered app whose OS image + // the selected root epoch allows and whose own policy admits it at + // the selected app epoch. + // --------------------------------------------------------------- + + function check_AuthorizeApp_MatchesKmsAndAppPolicy( + bool getCert, + bool rootSet, + uint256 root, + bool appSet, + uint256 appEpoch, + Identity calldata id + ) + external + view + { + bytes32 operation = getCert ? AuthorizationOperation.APP_GET_CERT : AuthorizationOperation.APP_GET_KEY; + IAppAuth.AppInfo memory info = _info(id); + AuthorizationResult memory result = kms.authorizeApp(_request(info, operation, rootSet, root, appSet, appEpoch)); + + uint256 rootEpoch = rootSet ? root : _current(); + uint256 selectedAppEpoch = appSet ? appEpoch : app.currentAppEpoch(); + bool expected = rootEpoch <= _current() && selectedAppEpoch <= app.currentAppEpoch() + && kms.allowedOsImagesAtEpoch(rootEpoch, id.osImageHash); + if (expected) (expected,) = _appAuth(app, info, selectedAppEpoch, operation); + assert(result.authorized == expected); + } + + // --------------------------------------------------------------- + // Epoch isolation: a KMS policy write never changes a KMS + // authorization decision at a root epoch outside the rows it names. + // --------------------------------------------------------------- + + /// @custom:halmos --loop 4 + function check_KmsPolicyWrite_PreservesOtherEpochDecisions( + uint256 which, + uint256 writeEpoch, + uint256 writeTo, + bytes32 word, + bytes32 operation, + uint256 root, + Identity calldata id + ) + external + { + vm.assume(which < KMS_POLICY_SETTERS); + bytes memory data = _kmsPolicyCall(which, writeEpoch, writeTo, word); + vm.assume(which < 6 ? writeEpoch != root : root < writeEpoch || root > writeTo); + + AuthorizationRequest memory request = _request(_info(id), operation, true, root, false, 0); + AuthorizationResult memory before = kms.authorizeKms(request); + vm.prank(OWNER); + (bool ok,) = address(kms).call(data); + ok; + AuthorizationResult memory afterWrite = kms.authorizeKms(request); + + assert(before.authorized == afterWrite.authorized); + assert(keccak256(bytes(before.rejectionReason)) == keccak256(bytes(afterWrite.rejectionReason))); + } + + // --------------------------------------------------------------- + // Revocation: removing a material from every registered row, 0 + // through the latest, leaves no selectable root epoch at which an + // identity carrying it is authorized, for any KMS or app operation. + // --------------------------------------------------------------- + + /// @custom:halmos --loop 4 + function check_FullRangeRemoval_RevokesAtEveryEpoch( + uint256 kind, + bytes32 operation, + bool rootSet, + uint256 root, + bool appSet, + uint256 appEpoch, + Identity calldata id + ) + external + { + vm.assume(kind < 3); + uint256 latest = kms.rootEpochCount() - 1; + vm.startPrank(OWNER); + if (kind == 0) kms.removeOsImageHashAtEpochs(0, latest, id.osImageHash); + else if (kind == 1) kms.removeKmsAggregatedMrAtEpochs(0, latest, id.mrAggregated); + else kms.removeKmsDeviceAtEpochs(0, latest, id.deviceId); + vm.stopPrank(); + + AuthorizationRequest memory request = _request(_info(id), operation, rootSet, root, appSet, appEpoch); + assert(!kms.authorizeKms(request).authorized); + if (kind == 0) assert(!kms.authorizeApp(request).authorized); + } + + // --------------------------------------------------------------- + // Policy rows belong to a registration: popping the pending epoch + // and registering the number again, with the same keys or others, + // starts with empty rows. + // --------------------------------------------------------------- + + function check_PopThenRegister_StartsWithEmptyRows( + bool sameKeys, + bytes32 x, + bytes32 y, + bool even, + bytes32 word + ) + external + { + (bytes memory ca, bytes memory k256,, bytes32 hash) = kms.rootEpoch(2); + if (!sameKeys) { + ca = _ca(x, y); + k256 = _k256(even, x); + } + vm.startPrank(OWNER); + kms.popRootEpoch(2, hash); + kms.registerRootEpoch(ca, k256, "evidence"); + vm.stopPrank(); + + assert(!kms.allowedOsImagesAtEpoch(2, word)); + assert(!kms.kmsAllowedAggregatedMrsAtEpoch(2, word)); + assert(!kms.kmsAllowedDeviceIdsAtEpoch(2, word)); + } + + // --------------------------------------------------------------- + // Root epoch 0's rows are the v0.5.x mappings: after any one owner + // write, the legacy getters and the epoch 0 getters agree. + // --------------------------------------------------------------- + + /// @custom:halmos --loop 4 + function check_EpochZeroRows_AreTheLegacyMappings( + uint256 which, + uint256 writeEpoch, + uint256 writeTo, + bytes32 word, + bytes32 probe + ) + external + { + vm.assume(which < KMS_POLICY_SETTERS); + vm.prank(OWNER); + (bool ok,) = address(kms).call(_kmsPolicyCall(which, writeEpoch, writeTo, word)); + ok; + assert(kms.allowedOsImages(probe) == kms.allowedOsImagesAtEpoch(0, probe)); + assert(kms.kmsAllowedAggregatedMrs(probe) == kms.kmsAllowedAggregatedMrsAtEpoch(0, probe)); + assert(kms.kmsAllowedDeviceIds(probe) == kms.kmsAllowedDeviceIdsAtEpoch(0, probe)); + } + + // --------------------------------------------------------------- + // An app policy predating app epochs (no IAppEpochAuth) is decided by + // its isAppAllowed and only for app epoch 0: an explicit nonzero app + // epoch is refused, and otherwise the decision is the KMS checks at + // the selected root epoch and the policy's own answer. + // --------------------------------------------------------------- + + function check_AuthorizeApp_LegacyPolicyOnlyAtAppEpochZero( + bool getCert, + bool rootSet, + uint256 root, + bool appSet, + uint256 appEpoch, + Identity calldata id + ) + external + { + LegacyAppPolicy legacy = new LegacyAppPolicy(_id(0xc0, 0)); + kms.registerApp(address(legacy)); + IAppAuth.AppInfo memory info = _info(id); + info.appId = address(legacy); + bytes32 operation = getCert ? AuthorizationOperation.APP_GET_CERT : AuthorizationOperation.APP_GET_KEY; + AuthorizationResult memory result = kms.authorizeApp(_request(info, operation, rootSet, root, appSet, appEpoch)); + + uint256 rootEpoch = rootSet ? root : _current(); + bool expected = rootEpoch <= _current() && (!appSet || appEpoch == 0) + && kms.allowedOsImagesAtEpoch(rootEpoch, id.osImageHash) && id.composeHash == _id(0xc0, 0); + assert(result.authorized == expected); + assert(result.currentAppEpoch == 0); + assert(kms.currentAppEpoch(address(legacy)) == 0); + } + + // --------------------------------------------------------------- + // Legacy entry points serve KMS binaries that only hold root epoch 0: + // before any rotation isKmsAllowed decides as KMS_SERVE at epoch 0, + // and once a later epoch is current both refuse. isAppAllowed never + // authorizes an app whose policy has left app epoch 0. + // --------------------------------------------------------------- + + function check_LegacyEntryPoints_RefuseOnceRotated(Identity calldata id) external view { + IAppAuth.AppInfo memory info = _info(id); + bytes32 rotated = keccak256("Root epoch requires epoch-aware authorization"); + (bool kmsAllowed, string memory kmsReason) = kms.isKmsAllowed(info); + assert(!kmsAllowed && keccak256(bytes(kmsReason)) == rotated); + (bool appAllowed, string memory appReason) = kms.isAppAllowed(info); + assert(!appAllowed && keccak256(bytes(appReason)) == rotated); + } + + function check_LegacyKmsCheck_ReadsEpochZeroBeforeRotation(Identity calldata id) external { + bytes memory initData = abi.encodeCall(DstackKms.initialize, (OWNER, address(new DstackApp()))); + kms = DstackKms(address(new ERC1967Proxy(address(new DstackKms()), initData))); + vm.startPrank(OWNER); + for (uint256 epoch = 0; epoch < 2; epoch++) { + kms.registerRootEpoch(_ca(bytes32(epoch + 1), bytes32(0)), _k256(true, bytes32(epoch + 1)), "evidence"); + kms.addOsImageHashAtEpoch(epoch, _id(0x05, epoch)); + kms.addKmsAggregatedMrAtEpoch(epoch, _id(0x0a, epoch)); + kms.addKmsDeviceAtEpoch(epoch, _id(0x0d, epoch)); + } + vm.stopPrank(); + // Epoch 1 is registered and prepared but pending. + IAppAuth.AppInfo memory info = _info(id); + (bool kmsAllowed,) = kms.isKmsAllowed(info); + AuthorizationResult memory serve = + kms.authorizeKms(_request(info, AuthorizationOperation.KMS_SERVE, true, 0, false, 0)); + assert(kmsAllowed == serve.authorized); + } +} diff --git a/dstack/kms/auth-eth/test/EventAudit.t.sol b/dstack/kms/auth-eth/test/EventAudit.t.sol index 9631b355a..84bd2205f 100644 --- a/dstack/kms/auth-eth/test/EventAudit.t.sol +++ b/dstack/kms/auth-eth/test/EventAudit.t.sol @@ -54,12 +54,12 @@ contract EventAuditTest is Test { vm.recordLogs(); vm.startPrank(owner); - kms.addKmsAggregatedMr(mr); - kms.addKmsDevice(device); - kms.addOsImageHash(image); + kms.addKmsAggregatedMrAtEpoch(0, mr); + kms.addKmsDeviceAtEpoch(0, device); + kms.addOsImageHashAtEpoch(0, image); kms.setGatewayAppId(gateway); kms.registerApp(address(app)); - kms.removeKmsAggregatedMr(mr); + kms.removeKmsAggregatedMrAtEpoch(0, mr); vm.stopPrank(); Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -83,11 +83,11 @@ contract EventAuditTest is Test { vm.recordLogs(); vm.startPrank(owner); - app.addComposeHash(composeHash); - app.addDevice(device); - app.setAllowAnyDevice(true); + app.addComposeHashAtEpoch(0, composeHash); + app.addDeviceAtEpoch(0, device); + app.setAllowAnyDeviceAtEpoch(0, true); app.setRequireTcbUpToDate(true); - app.removeDevice(device); + app.removeDeviceAtEpoch(0, device); app.disableUpgrades(); vm.stopPrank(); Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -110,7 +110,7 @@ contract EventAuditTest is Test { vm.recordLogs(); vm.prank(outsider); vm.expectRevert(); - kms.addOsImageHash(image); + kms.addOsImageHashAtEpoch(0, image); Vm.Log[] memory logs = vm.getRecordedLogs(); assertEq(_auditCount(logs), 0); assertFalse(kms.allowedOsImages(image)); @@ -123,7 +123,7 @@ contract EventAuditTest is Test { vm.recordLogs(); vm.prank(owner); - app.addComposeHash(orphaned); + app.addComposeHashAtEpoch(0, orphaned); Vm.Log[] memory orphanLogs = vm.getRecordedLogs(); _assertAudit(orphanLogs, owner, "compose-hash", orphaned, true); assertTrue(app.allowedComposeHashes(orphaned)); @@ -133,7 +133,7 @@ contract EventAuditTest is Test { vm.recordLogs(); vm.prank(owner); - app.addComposeHash(canonical); + app.addComposeHashAtEpoch(0, canonical); Vm.Log[] memory canonicalLogs = vm.getRecordedLogs(); _assertAudit(canonicalLogs, owner, "compose-hash", canonical, true); assertEq(_auditCount(canonicalLogs), 1); diff --git a/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol b/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol new file mode 100644 index 000000000..193eba714 --- /dev/null +++ b/dstack/kms/auth-eth/test/LegacyAppCompat.t.sol @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "openzeppelin-foundry-upgrades/Upgrades.sol"; +import "../contracts/DstackKms.sol"; +import "../contracts/DstackApp.sol"; + +// The epoch-less setters of the released app policies. +interface ILegacyApp { + function addComposeHash(bytes32 composeHash) external; + function removeComposeHash(bytes32 composeHash) external; + function setAllowAnyDevice(bool allowed) external; +} + +/// An upgraded DstackKms with app policies still on the v0.5.9 and v0.6.0 +/// implementations, which app owners upgrade on their own schedule. +contract LegacyAppCompatTest is Test { + DstackKms kms; + address owner = makeAddr("owner"); + address appOwner = makeAddr("appOwner"); + + string[2] internal appReleases = ["DstackAppV059.sol:DstackApp", "DstackAppV060.sol:DstackApp"]; + + bytes32 constant COMPOSE = bytes32(uint256(0xc0)); + bytes32 constant DEVICE = bytes32(uint256(0xd0)); + bytes32 constant IMAGE = bytes32(uint256(0x10)); + + function setUp() public { + vm.startPrank(owner); + kms = DstackKms( + Upgrades.deployUUPSProxy("DstackKms.sol", abi.encodeCall(DstackKms.initialize, (owner, address(0)))) + ); + kms.registerRootEpoch(key(91, 0xa0), key(33, 0xb0), "ipfs://0"); + kms.addOsImageHashAtEpoch(0, IMAGE); + vm.stopPrank(); + } + + function key(uint256 length, uint8 fill) internal pure returns (bytes memory k) { + k = new bytes(length); + for (uint256 i; i < length; ++i) { + k[i] = bytes1(fill); + } + if (length == 91) { + bytes memory prefix = hex"3059301306072a8648ce3d020106082a8648ce3d03010703420004"; + for (uint256 i; i < prefix.length; ++i) { + k[i] = prefix[i]; + } + } else { + k[0] = 0x02; + } + } + + function deployLegacyApp(uint256 release) internal returns (address app) { + vm.prank(appOwner); + app = Upgrades.deployUUPSProxy( + appReleases[release], + abi.encodeWithSignature( + "initialize(address,bool,bool,bytes32,bytes32)", appOwner, false, false, DEVICE, COMPOSE + ) + ); + kms.registerApp(app); + } + + function request(address app, bool appSet, uint256 appEpoch) internal pure returns (AuthorizationRequest memory) { + IAppAuth.AppInfo memory info; + info.appId = app; + info.composeHash = COMPOSE; + info.deviceId = DEVICE; + info.osImageHash = IMAGE; + info.tcbStatus = "UpToDate"; + info.advisoryIds = new string[](0); + return AuthorizationRequest( + info, keccak256("app_get_key"), OptionalUint256(appSet, appEpoch), OptionalUint256(false, 0) + ); + } + + function rotateRoot() internal { + vm.startPrank(owner); + kms.registerRootEpoch(key(91, 0xa1), key(33, 0xb1), "ipfs://1"); + kms.addOsImageHashAtEpoch(1, IMAGE); + (,,, bytes32 hash) = kms.rootEpoch(1); + kms.activateRootEpoch(1, hash); + vm.stopPrank(); + } + + function test_LegacyAppsAreAuthorizedAtAppEpochZeroAcrossRootRotation() public { + for (uint256 release; release < appReleases.length; ++release) { + address app = deployLegacyApp(release); + assertEq(kms.currentAppEpoch(app), 0); + assertTrue(kms.authorizeApp(request(app, false, 0)).authorized); + assertTrue(kms.authorizeApp(request(app, true, 0)).authorized); + AuthorizationResult memory result = kms.authorizeApp(request(app, true, 1)); + assertFalse(result.authorized); + assertEq(result.rejectionReason, "Requested app epoch is not active"); + + // Its epoch-less setters still decide app epoch 0. + vm.prank(appOwner); + ILegacyApp(app).removeComposeHash(COMPOSE); + assertFalse(kms.authorizeApp(request(app, false, 0)).authorized); + vm.prank(appOwner); + ILegacyApp(app).addComposeHash(COMPOSE); + } + rotateRoot(); + for (uint256 release; release < appReleases.length; ++release) { + address app = deployLegacyApp(release); + assertEq(kms.currentAppEpoch(app), 0); + AuthorizationResult memory result = kms.authorizeApp(request(app, false, 0)); + assertTrue(result.authorized, result.rejectionReason); + assertEq(result.currentRootEpoch, 1); + assertEq(result.currentAppEpoch, 0); + assertFalse(kms.authorizeApp(request(app, true, 1)).authorized); + } + } + + function test_InterfaceIdsTellLegacyAndEpochAppsApart() public { + bytes4 basicManagement = 0x8fd37527; + for (uint256 release; release < appReleases.length; ++release) { + address app = deployLegacyApp(release); + assertTrue(IERC165(app).supportsInterface(basicManagement)); + assertFalse(IERC165(app).supportsInterface(type(IAppEpochManagement).interfaceId)); + assertFalse(IERC165(app).supportsInterface(type(IAppEpochAuth).interfaceId)); + } + DstackApp current = DstackApp( + Upgrades.deployUUPSProxy( + "DstackApp.sol", + abi.encodeWithSignature( + "initialize(address,bool,bool,bytes32,bytes32)", appOwner, false, false, DEVICE, COMPOSE + ) + ) + ); + assertFalse(current.supportsInterface(basicManagement)); + assertTrue(current.supportsInterface(type(IAppEpochManagement).interfaceId)); + assertTrue(current.supportsInterface(type(IAppEpochAuth).interfaceId)); + } + + function test_UpgradedV059AppKeepsItsPolicyAtAppEpochZero() public { + checkUpgradeKeepsPolicy(0, false); + } + + function test_UpgradedV059AnyDeviceAppKeepsItsPolicyAtAppEpochZero() public { + checkUpgradeKeepsPolicy(0, true); + } + + function test_UpgradedV060AppKeepsItsPolicyAtAppEpochZero() public { + checkUpgradeKeepsPolicy(1, false); + } + + function test_UpgradedV060AnyDeviceAppKeepsItsPolicyAtAppEpochZero() public { + checkUpgradeKeepsPolicy(1, true); + } + + function checkUpgradeKeepsPolicy(uint256 release, bool anyDevice) internal { + address app = deployLegacyApp(release); + if (anyDevice) { + vm.prank(appOwner); + ILegacyApp(app).setAllowAnyDevice(true); + } + Options memory opts; + opts.referenceContract = appReleases[release]; + vm.startPrank(appOwner); + Upgrades.upgradeProxy(app, "DstackApp.sol", "", opts); + vm.stopPrank(); + + DstackApp upgraded = DstackApp(app); + assertEq(upgraded.currentAppEpoch(), 0); + assertTrue(upgraded.allowedComposeHashesAtEpoch(0, COMPOSE)); + assertTrue(upgraded.allowedDeviceIdsAtEpoch(0, DEVICE)); + assertEq(upgraded.allowAnyDeviceAtEpoch(0), anyDevice); + assertFalse(upgraded.allowedComposeHashesAtEpoch(1, COMPOSE)); + assertTrue(kms.authorizeApp(request(app, true, 0)).authorized); + + // The epoch-less setters are gone; the rows are written by epoch. + vm.prank(appOwner); + vm.expectRevert(); + ILegacyApp(app).addComposeHash(bytes32(uint256(0xc1))); + vm.prank(appOwner); + upgraded.removeComposeHashAtEpochs(0, 1, COMPOSE); + assertFalse(kms.authorizeApp(request(app, true, 0)).authorized); + } +} diff --git a/dstack/kms/auth-eth/test/ManageScript.t.sol b/dstack/kms/auth-eth/test/ManageScript.t.sol new file mode 100644 index 000000000..503e3a878 --- /dev/null +++ b/dstack/kms/auth-eth/test/ManageScript.t.sol @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: © 2026 Phala Network + * + * SPDX-License-Identifier: Apache-2.0 + */ + +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "openzeppelin-foundry-upgrades/Upgrades.sol"; +import "../script/Manage.s.sol"; + +contract ManageScriptTest is Test { + function deployApp(string memory release, address owner) internal returns (address) { + return Upgrades.deployUUPSProxy( + release, + abi.encodeWithSignature( + "initialize(address,bool,bool,bytes32,bytes32)", owner, false, false, bytes32(0), bytes32(0) + ) + ); + } + + function addComposeHash(address app) internal returns (AddComposeHash script) { + vm.setEnv("APP_CONTRACT_ADDR", vm.toString(app)); + script = new AddComposeHash(); + script.setUp(); + } + + // Environment variables persist across tests, so the cases run in order. + function test_AppScriptsUseTheSettersTheAppHas() public { + (address owner, uint256 key) = makeAddrAndKey("owner"); + address legacy = deployApp("DstackAppV060.sol:DstackApp", owner); + address current = deployApp("DstackApp.sol", owner); + bytes32 composeHash = bytes32(uint256(0xc0)); + vm.setEnv("KMS_CONTRACT_ADDR", vm.toString(address(1))); + vm.setEnv("PRIVATE_KEY", vm.toString(key)); + vm.setEnv("COMPOSE_HASH", vm.toString(composeHash)); + + // A legacy app takes its epoch-less setter, which writes app epoch 0. + addComposeHash(legacy).run(); + assertTrue(DstackApp(legacy).allowedComposeHashes(composeHash)); + // An epoch-aware app needs its rows named. + AddComposeHash script = addComposeHash(current); + vm.expectRevert(bytes("set APP_EPOCH, or FROM_APP_EPOCH and TO_APP_EPOCH")); + script.run(); + + vm.setEnv("APP_EPOCH", "1"); + addComposeHash(current).run(); + assertFalse(DstackApp(current).allowedComposeHashesAtEpoch(0, composeHash)); + assertTrue(DstackApp(current).allowedComposeHashesAtEpoch(1, composeHash)); + script = addComposeHash(legacy); + vm.expectRevert(bytes("this app predates app epochs: only APP_EPOCH=0 applies")); + script.run(); + } +} diff --git a/dstack/kms/auth-eth/test/UpgradesWithPlugin.t.sol b/dstack/kms/auth-eth/test/UpgradesWithPlugin.t.sol index 0cb05013d..64334e594 100644 --- a/dstack/kms/auth-eth/test/UpgradesWithPlugin.t.sol +++ b/dstack/kms/auth-eth/test/UpgradesWithPlugin.t.sol @@ -24,6 +24,45 @@ contract UpgradesWithPluginTest is Test { Upgrades.upgradeProxy(proxy, "contracts/test-utils/DstackAppV2.sol:DstackAppV2", ""); } + /// Deployed releases; their sources are kept verbatim in contracts/test-utils/reference. + string[2] internal kmsReleases = ["DstackKmsV059.sol:DstackKms", "DstackKmsV060.sol:DstackKms"]; + string[2] internal appReleases = ["DstackAppV059.sol:DstackApp", "DstackAppV060.sol:DstackApp"]; + + function test_ReleasedStorageLayoutsUpgradeToTheCurrentContracts() public { + for (uint256 i = 0; i < kmsReleases.length; ++i) { + Options memory opts; + opts.referenceContract = kmsReleases[i]; + Upgrades.validateUpgrade("DstackKms.sol", opts); + opts.referenceContract = appReleases[i]; + Upgrades.validateUpgrade("DstackApp.sol", opts); + } + } + + function test_ReleasedAppProxyKeepsItsAllowlistAfterUpgrade() public { + bytes32 composeHash = bytes32(uint256(0xc0)); + bytes32 deviceId = bytes32(uint256(0xd0)); + vm.startPrank(owner); + address proxy = Upgrades.deployUUPSProxy( + "DstackAppV059.sol:DstackApp", + abi.encodeWithSignature( + "initialize(address,bool,bool,bool,bytes32,bytes32)", owner, false, true, false, deviceId, composeHash + ) + ); + Options memory opts; + opts.referenceContract = appReleases[0]; + Upgrades.upgradeProxy(proxy, "DstackApp.sol", "", opts); + vm.stopPrank(); + DstackApp app = DstackApp(proxy); + assertEq(app.owner(), owner); + assertTrue(app.requireTcbUpToDate()); + assertTrue(app.allowedComposeHashes(composeHash)); + assertTrue(app.allowedDeviceIds(deviceId)); + assertFalse(app.allowAnyDevice()); + assertEq(app.currentAppEpoch(), 0); + assertTrue(app.allowedComposeHashesAtEpoch(0, composeHash)); + assertTrue(app.allowedDeviceIdsAtEpoch(0, deviceId)); + } + function attemptKmsUpgrade(address proxy) external { Upgrades.upgradeProxy(proxy, "contracts/test-utils/DstackKmsV2.sol:DstackKmsV2", ""); } @@ -85,7 +124,7 @@ contract UpgradesWithPluginTest is Test { // Set some state to verify it's preserved kms.setGatewayAppId("original-gateway"); bytes32 mrAggregated = bytes32(uint256(123)); - kms.addKmsAggregatedMr(mrAggregated); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); // Upgrade to new implementation using plugin Upgrades.upgradeProxy(kmsProxy, "DstackKmsV2.sol", ""); @@ -114,9 +153,9 @@ contract UpgradesWithPluginTest is Test { // Set some state to verify it's preserved bytes32 deviceId = bytes32(uint256(456)); bytes32 composeHash = bytes32(uint256(789)); - app.addDevice(deviceId); - app.addComposeHash(composeHash); - app.setAllowAnyDevice(true); + app.addDeviceAtEpoch(0, deviceId); + app.addComposeHashAtEpoch(0, composeHash); + app.setAllowAnyDeviceAtEpoch(0, true); // Upgrade to new implementation using plugin Upgrades.upgradeProxy(appProxy, "DstackAppV2.sol", ""); @@ -266,8 +305,8 @@ contract UpgradesWithPluginTest is Test { bytes32 osImageHash = bytes32(uint256(0xA1)); bytes32 composeHash = bytes32(uint256(0xA2)); bytes32 mrAggregated = bytes32(uint256(0xA3)); - kms.addOsImageHash(osImageHash); - kms.addKmsAggregatedMr(mrAggregated); + kms.addOsImageHashAtEpoch(0, osImageHash); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); address appId = kms.deployAndRegisterApp( owner, @@ -284,7 +323,7 @@ contract UpgradesWithPluginTest is Test { assertTrue(app.allowAnyDevice()); assertTrue(app.allowedComposeHashes(composeHash)); - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: appId, composeHash: composeHash, instanceId: address(0), @@ -320,8 +359,8 @@ contract UpgradesWithPluginTest is Test { bytes32 osImageHash = bytes32(uint256(0xB1)); bytes32 composeHash = bytes32(uint256(0xB2)); bytes32 mrAggregated = bytes32(uint256(0xB3)); - kms.addOsImageHash(osImageHash); - kms.addKmsAggregatedMr(mrAggregated); + kms.addOsImageHashAtEpoch(0, osImageHash); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); address appId = kms.deployAndRegisterApp( owner, @@ -335,7 +374,7 @@ contract UpgradesWithPluginTest is Test { assertFalse(app.requireTcbUpToDate()); // Outdated TCB still allowed when flag is off - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: appId, composeHash: composeHash, instanceId: address(0), @@ -380,8 +419,8 @@ contract UpgradesWithPluginTest is Test { bytes32 osImageHash = bytes32(uint256(789)); bytes32 mrAggregated = bytes32(uint256(101_112)); - kms.addOsImageHash(osImageHash); - kms.addKmsAggregatedMr(mrAggregated); + kms.addOsImageHashAtEpoch(0, osImageHash); + kms.addKmsAggregatedMrAtEpoch(0, mrAggregated); kms.setGatewayAppId("complex-test-gateway"); // Upgrade both contracts @@ -399,7 +438,7 @@ contract UpgradesWithPluginTest is Test { assertEq(app.owner(), owner); // Test the full flow still works - IAppAuth.AppBootInfo memory bootInfo = IAppAuth.AppBootInfo({ + IAppAuth.AppInfo memory bootInfo = IAppAuth.AppInfo({ appId: appId, composeHash: composeHash, instanceId: address(0), diff --git a/dstack/kms/auth-eth/tsconfig.json b/dstack/kms/auth-eth/tsconfig.json deleted file mode 100644 index fc23863ce..000000000 --- a/dstack/kms/auth-eth/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": [ - "es2020" - ], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": "./src", - "resolveJsonModule": true, - "types": [ - "node", - "jest" - ], - "baseUrl": "." - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/dstack/kms/auth-simple/vitest.config.ts b/dstack/kms/auth-eth/vitest.config.ts similarity index 58% rename from dstack/kms/auth-simple/vitest.config.ts rename to dstack/kms/auth-eth/vitest.config.ts index 17597daca..3e17ccf51 100644 --- a/dstack/kms/auth-simple/vitest.config.ts +++ b/dstack/kms/auth-eth/vitest.config.ts @@ -8,5 +8,8 @@ export default defineConfig({ test: { globals: true, environment: 'node', + // The Foundry project shares this directory; keep vitest away from the + // JavaScript under lib/ (contract dependencies) and the build outputs. + include: ['*.test.ts'], }, }); diff --git a/dstack/kms/auth-mock/.oxlintrc.json b/dstack/kms/auth-mock/.oxlintrc.json deleted file mode 100644 index f7d7b8e35..000000000 --- a/dstack/kms/auth-mock/.oxlintrc.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/config/schema.json", - "rules": { - "typescript": "warn", - "unicorn": "warn", - "react": "off", - "jsx-a11y": "off", - "nextjs": "off" - }, - "env": { - "node": true, - "es2022": true - }, - "globals": { - "globalThis": "readonly", - "console": "readonly" - }, - "ignore_patterns": [ - "node_modules/**", - "dist/**", - "build/**", - "coverage/**", - "*.min.js", - "*.d.ts", - "*.test.ts" - ] -} diff --git a/dstack/kms/auth-mock/Dockerfile b/dstack/kms/auth-mock/Dockerfile deleted file mode 100644 index 75ecb5187..000000000 --- a/dstack/kms/auth-mock/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: © 2025 Phala Network -# -# SPDX-License-Identifier: Apache-2.0 - -FROM oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a -WORKDIR /app - -ARG DSTACK_REV -ARG DSTACK_BRANCH=next - -RUN apk add --no-cache git -RUN git clone --branch ${DSTACK_BRANCH} https://github.com/Dstack-TEE/dstack.git && \ - cd dstack && \ - git checkout ${DSTACK_REV} -WORKDIR /app/dstack/dstack/kms/auth-mock -RUN bun install --frozen-lockfile -CMD ["bun", "index.ts"] diff --git a/dstack/kms/auth-mock/README.md b/dstack/kms/auth-mock/README.md deleted file mode 100644 index c26ed0e19..000000000 --- a/dstack/kms/auth-mock/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# dstack KMS auth-mock - -a mock implementation of the dstack KMS backend using bun + hono + zod. **this backend always returns success for all authentication requests** and does not interact with any blockchain. - -## features - -- 🚀 fast and lightweight with bun runtime -- 🔧 modern web framework with hono.js -- ✅ type-safe validation with zod.js -- 📦 single file implementation -- 🎭 **mock behavior**: all authentications succeed -- 🚫 **no blockchain interaction**: no dependencies on ethereum/contracts - -## use cases - -- 🧪 **development & testing**: test applications without blockchain setup -- 🚀 **rapid prototyping**: quickly validate integration workflows -- 🔍 **debugging**: isolate issues by removing blockchain dependencies -- 📚 **demos**: showcase functionality without complex infrastructure - -## installation - -```bash -# install dependencies -bun install -``` - -## usage - -### development -```bash -# run with hot reload -bun run dev -``` - -### production -```bash -# run directly -bun run start - -# or build first -bun run build -``` - -### testing -```bash -# run tests (watch mode) -bun run test - -# run tests once -bun run test:run -``` - -### code quality -```bash -# run linter -bun run lint - -# run linter and fix issues -bun run lint:fix - -# format code -bun run format - -# run full check (lint + tests) -bun run check -``` - -## environment variables - -- `PORT` - server port (default: 3000) -- `MOCK_GATEWAY_APP_ID` - mock gateway app ID (default: 0xmockgateway1234567890123456789012345678) -- `MOCK_CHAIN_ID` - mock blockchain chain ID (default: 1337) -- `MOCK_APP_IMPLEMENTATION` - mock app implementation address (default: 0xmockapp9876543210987654321098765432109) - -## API endpoints - -### GET / -health check and mock system information - -**response includes a `note` field indicating this is a mock backend** - -### POST /bootAuth/app -application boot authentication - **always returns isAllowed=true** - -### POST /bootAuth/kms -KMS boot authentication - **always returns isAllowed=true** - -## request format - -identical to the real backend: - -```json -{ - "mrAggregated": "string", - "osImageHash": "string", - "appId": "string", - "composeHash": "string", - "instanceId": "string", - "deviceId": "string", - "tcbStatus": "string (optional)", - "advisoryIds": ["string (optional)"], - "mrSystem": "string (optional)" -} -``` - -## response format - -```json -{ - "isAllowed": true, - "reason": "mock app always allowed" | "mock KMS always allowed", - "gatewayAppId": "0xmockgateway1234567890123456789012345678" -} -``` - -## mock behavior - -### ✅ always succeeds -- all POST requests return `isAllowed: true` -- no validation against blockchain -- no actual security checks - -### 📝 logs requests -- logs incoming requests with key identifiers -- helps with debugging and monitoring - -### 🎯 consistent responses -- predictable mock values for testing -- same response format as real backend - -## API compatibility - -this implementation is fully API-compatible with the real backend: - -- **request/response schemas**: identical to production API -- **OpenAPI specification**: available in `openapi.json` -- **comprehensive testing**: vitest test suite validates behavior -- **backward compatibility**: supports both minimal and full BootInfo formats - -### differences from real backend - -| aspect | real backend | mock backend | -|--------|-------------|--------------| -| blockchain interaction | ✅ calls smart contracts | ❌ no blockchain | -| authentication logic | ✅ validates against chain | ❌ always allows | -| security | ✅ enforces policies | ❌ bypasses all checks | -| performance | 🐌 depends on network | 🚀 instant responses | -| setup complexity | 🔧 requires ethereum setup | ✅ zero config | - -## warnings - -⚠️ **never use in production** - this backend bypasses all security checks - -⚠️ **development only** - intended for testing and development environments - -⚠️ **no security** - all requests are approved regardless of validity - -## transitioning to real backend - -to switch from mock to real backend: - -1. replace `kms/auth-mock` with `kms/auth-eth-bun` -2. configure ethereum RPC endpoint -3. deploy smart contracts -4. update environment variables - -the API remains identical, so no client code changes are needed. diff --git a/dstack/kms/auth-mock/bun.lock b/dstack/kms/auth-mock/bun.lock deleted file mode 100644 index 9129aa451..000000000 --- a/dstack/kms/auth-mock/bun.lock +++ /dev/null @@ -1,190 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "auth-mock", - "dependencies": { - "@hono/zod-validator": "0.9.1", - "hono": "4.13.14", - "zod": "4.6.7", - }, - "devDependencies": { - "@types/bun": "1.2.18", - "@types/node": "22.10.8", - "openapi-types": "12.1.3", - "oxlint": "0.9.10", - "typescript": "5.8.3", - "vite": "8.3.5", - "vitest": "5.0.5", - }, - }, - }, - "packages": { - "@hono/zod-validator": ["@hono/zod-validator@0.9.1", "", { "peerDependencies": { "hono": ">=4.11.2", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@oxc-project/types": ["@oxc-project/types@0.155.0", "", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="], - - "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@0.9.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eOXKZYq5bnCSgDefgM5bzAg+4Fc//Rc4yjgKN8iDWUARweCaChiQXb6TXX8MfEfs6qayEMy6yVj0pqoFz0B1aw=="], - - "@oxlint/darwin-x64": ["@oxlint/darwin-x64@0.9.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UeYICDvLUaUOcY+0ugZUEmBMRLP+x8iTgL7TeY6BlpGw2ahbtUOTbyIIRWtr/0O++TnjZ+v8TzhJ9crw6Ij6dg=="], - - "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@0.9.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0Zn+vqHhrZyufFBfq9WOgiIool0gCR14BLsdS+0Dwd9o+kNxPGA5q7erQFkiC4rpkxtfBHeD3iIKMMt7d29Kyw=="], - - "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@0.9.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-tkQcWpYwF42bA/uRaV2iMFePHkBjTTgomOgeEaiw6XOSJX4nBEqGIIboqqLBWT4JnKCf/L+IG3y/e1MflhKByw=="], - - "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@0.9.10", "", { "os": "linux", "cpu": "x64" }, "sha512-JHbkMUnibqaSMBvLHyqTL5cWxcGW+jw+Ppt2baLISpvo34a6fBR+PI7v/A92sEDWe0W1rPhypzCwA8mKpkQ3DA=="], - - "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@0.9.10", "", { "os": "linux", "cpu": "x64" }, "sha512-aBBwN7bQzidwHwEXr7BAdVvMTLWstCy5gikerjLnGDeCSXX9r+o6+yUzTOqZvOo66E+XBgOJaVbY8rsL1MLE0g=="], - - "@oxlint/win32-arm64": ["@oxlint/win32-arm64@0.9.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-LXDnk7vKHT3IY6G1jq0O7+XMhtcHOYuxLGIx4KP+4xS6vKgBY+Bsq4xV3AtmtKlvnXkP5FxHpfLmcEtm5AWysA=="], - - "@oxlint/win32-x64": ["@oxlint/win32-x64@0.9.10", "", { "os": "win32", "cpu": "x64" }, "sha512-w5XRAV4bhgwenjjpGYZGglqzG9Wv/sI+cjQWJBQsvfDXsr2w4vOBXzt1j3/Z3EcSqf4KtkCa/IIuAhQyeShUbA=="], - - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], - - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/node": ["@types/node@22.10.8", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-rk+QvAEGsbX/ZPiiyel6hJHNUS9cnSbPWVaZLvE+Er3tLqQFzWMz9JOfWW7XUmKvRPfxJfbl3qYWve+RGXncFw=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "@vitest/mocker": ["@vitest/mocker@5.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg=="], - - "@vitest/spy": ["@vitest/spy@5.0.5", "", {}, "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw=="], - - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], - - "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], - - "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - - "magic-string": ["magic-string@1.4.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w=="], - - "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - - "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - - "oxlint": ["oxlint@0.9.10", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "0.9.10", "@oxlint/darwin-x64": "0.9.10", "@oxlint/linux-arm64-gnu": "0.9.10", "@oxlint/linux-arm64-musl": "0.9.10", "@oxlint/linux-x64-gnu": "0.9.10", "@oxlint/linux-x64-musl": "0.9.10", "@oxlint/win32-arm64": "0.9.10", "@oxlint/win32-x64": "0.9.10" }, "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-bKiiFN7Hnoaist/rditTRBXz+GXKYuLd53/NB7Q6zHB/bifELJarSoRLkAUGElIJKl4PSr3lTh1g6zehh+rX0g=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - - "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], - - "rolldown": ["rolldown@1.2.15", "", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="], - - "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], - - "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], - - "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], - - "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], - - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], - - "vitest": ["vitest@5.0.5", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.5", "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.5", "@vitest/coverage-v8": "5.0.5", "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg=="], - - "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], - - "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - - "bun-types/@types/node": ["@types/node@24.0.14", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw=="], - - "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - } -} diff --git a/dstack/kms/auth-mock/index.test.ts b/dstack/kms/auth-mock/index.test.ts deleted file mode 100644 index 493745613..000000000 --- a/dstack/kms/auth-mock/index.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; -import openApiSpec from './openapi.json'; - -// Dynamic import -let appFetch: any; - -beforeAll(async () => { - // Set environment variables for testing - process.env.KMS_CONTRACT_ADDR = '0xmockcontract1234567890123456789012345678'; - process.env.PORT = '3002'; - - // Import the app - const indexModule = await import('./index.ts'); - appFetch = indexModule.default.fetch; -}); - -beforeEach(() => { - // Reset console spy before each test - vi.clearAllMocks(); -}); - -describe('Mock Backend Tests', () => { - describe('GET /', () => { - it('should return mock system info', async () => { - const response = await appFetch(new Request('http://localhost:3002/')); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toMatchObject({ - status: 'ok', - kmsContractAddr: expect.any(String), - gatewayAppId: expect.any(String), - chainId: expect.any(Number), - appAuthImplementation: expect.any(String), - appImplementation: expect.any(String), - note: 'this is a mock backend - all authentications will succeed' - }); - - // Verify mock values (using same defaults as implementation) - expect(data.gatewayAppId).toBe(process.env.MOCK_GATEWAY_APP_ID || '0xmockgateway1234567890123456789012345678'); - expect(data.chainId).toBe(parseInt(process.env.MOCK_CHAIN_ID || '1337')); - expect(data.appImplementation).toBe(process.env.MOCK_APP_IMPLEMENTATION || '0xmockapp9876543210987654321098765432109'); - - // Verify response structure matches OpenAPI spec - const systemInfoSchema = openApiSpec.components.schemas.MockSystemInfo; - const requiredFields = systemInfoSchema.required; - - requiredFields.forEach(field => { - expect(data).toHaveProperty(field); - }); - }); - }); - - describe('POST /bootAuth/app', () => { - const validBootInfo = { - mrAggregated: '0x1234567890123456789012345678901234567890123456789012345678901234', - osImageHash: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - appId: '0x1111111111111111111111111111111111111111', - composeHash: '0x2222222222222222222222222222222222222222222222222222222222222222', - instanceId: '0x3333333333333333333333333333333333333333', - deviceId: '0x4444444444444444444444444444444444444444444444444444444444444444', - }; - - it('should always return success for app auth', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - const response = await appFetch(new Request('http://localhost:3002/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBootInfo), - })); - - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toMatchObject({ - isAllowed: true, - reason: 'mock app always allowed', - gatewayAppId: process.env.MOCK_GATEWAY_APP_ID || '0xmockgateway1234567890123456789012345678', - }); - - // Verify console log was called - expect(consoleSpy).toHaveBeenCalledWith('mock app boot auth request:', { - appId: validBootInfo.appId, - instanceId: validBootInfo.instanceId, - note: 'always returning success' - }); - - // Verify response matches OpenAPI spec - const bootResponseSchema = openApiSpec.components.schemas.BootResponse; - const requiredFields = bootResponseSchema.required; - - requiredFields.forEach(field => { - expect(data).toHaveProperty(field); - }); - - consoleSpy.mockRestore(); - }); - - it('should handle full BootInfo with optional fields', async () => { - const fullBootInfo = { - ...validBootInfo, - tcbStatus: 'OK', - advisoryIds: ['INTEL-SA-00123'], - mrSystem: '0x5555555555555555555555555555555555555555555555555555555555555555', - }; - - const response = await appFetch(new Request('http://localhost:3002/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fullBootInfo), - })); - - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.isAllowed).toBe(true); - expect(data.reason).toBe('mock app always allowed'); - }); - - it('should reject invalid request body', async () => { - const invalidBootInfo = { - mrAggregated: '0x1234', // missing required fields - }; - - const response = await appFetch(new Request('http://localhost:3002/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(invalidBootInfo), - })); - - expect(response.status).toBe(400); - }); - - it('should return success even with minimal data', async () => { - const minimalBootInfo = { - mrAggregated: 'minimal', - osImageHash: 'minimal', - appId: 'minimal', - composeHash: 'minimal', - instanceId: 'minimal', - deviceId: 'minimal', - }; - - const response = await appFetch(new Request('http://localhost:3002/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(minimalBootInfo), - })); - - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.isAllowed).toBe(true); - expect(data.reason).toBe('mock app always allowed'); - }); - }); - - describe('POST /bootAuth/kms', () => { - const validBootInfo = { - mrAggregated: '0x1234567890123456789012345678901234567890123456789012345678901234', - osImageHash: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', - appId: '0x1111111111111111111111111111111111111111', - composeHash: '0x2222222222222222222222222222222222222222222222222222222222222222', - instanceId: '0x3333333333333333333333333333333333333333', - deviceId: '0x4444444444444444444444444444444444444444444444444444444444444444', - }; - - it('should always return success for KMS auth', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - const response = await appFetch(new Request('http://localhost:3002/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBootInfo), - })); - - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toMatchObject({ - isAllowed: true, - reason: 'mock KMS always allowed', - gatewayAppId: process.env.MOCK_GATEWAY_APP_ID || '0xmockgateway1234567890123456789012345678', - }); - - // Verify console log was called - expect(consoleSpy).toHaveBeenCalledWith('mock KMS boot auth request:', { - appId: validBootInfo.appId, - instanceId: validBootInfo.instanceId, - note: 'always returning success' - }); - - consoleSpy.mockRestore(); - }); - - it('should handle different request patterns', async () => { - const randomBootInfo = { - mrAggregated: 'random-mr-value', - osImageHash: 'random-os-hash', - appId: 'random-app-id', - composeHash: 'random-compose-hash', - instanceId: 'random-instance-id', - deviceId: 'random-device-id', - tcbStatus: 'FAIL', // even with fail status - advisoryIds: ['CRITICAL-ADVISORY'], - }; - - const response = await appFetch(new Request('http://localhost:3002/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(randomBootInfo), - })); - - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.isAllowed).toBe(true); // Always true in mock - expect(data.reason).toBe('mock KMS always allowed'); - }); - - it('should handle "Test backend error" appropriately', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // This shouldn't actually throw an error in mock backend, but test the error handling path - const response = await appFetch(new Request('http://localhost:3002/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validBootInfo), - })); - - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.isAllowed).toBe(true); // Still success in mock - - // Verify console.error was not called (no errors in mock) - expect(consoleSpy).not.toHaveBeenCalled(); - - consoleSpy.mockRestore(); - }); - }); -}); - -describe('API Schema Compatibility', () => { - it('should match BootInfo schema requirements', () => { - const bootInfoSchema = openApiSpec.components.schemas.BootInfo; - - // Required fields should match original schema - expect(bootInfoSchema.required).toEqual([ - 'mrAggregated', - 'osImageHash', - 'appId', - 'composeHash', - 'instanceId', - 'deviceId' - ]); - - // Optional fields should be present for full compatibility - expect(bootInfoSchema.properties).toHaveProperty('tcbStatus'); - expect(bootInfoSchema.properties).toHaveProperty('advisoryIds'); - expect(bootInfoSchema.properties).toHaveProperty('mrSystem'); - }); - - it('should match BootResponse schema requirements', () => { - const bootResponseSchema = openApiSpec.components.schemas.BootResponse; - - expect(bootResponseSchema.required).toEqual([ - 'isAllowed', - 'reason', - 'gatewayAppId' - ]); - - // Verify mock-specific examples - expect(bootResponseSchema.properties.isAllowed.example).toBe(true); - expect(bootResponseSchema.properties.reason.example).toBe('mock app always allowed'); - }); - - it('should match MockSystemInfo schema requirements', () => { - const systemInfoSchema = openApiSpec.components.schemas.MockSystemInfo; - - expect(systemInfoSchema.required).toEqual([ - 'status', - 'kmsContractAddr', - 'gatewayAppId', - 'chainId', - 'appAuthImplementation', - 'appImplementation', - 'note' - ]); - - // Verify the note field is required (unique to mock) - expect(systemInfoSchema.properties.note.example).toBe('this is a mock backend - all authentications will succeed'); - }); -}); - -describe('Mock Behavior Verification', () => { - it('should always return success regardless of input validity', async () => { - // Test with completely bogus data - const bogusBootInfo = { - mrAggregated: 'invalid-data', - osImageHash: 'also-invalid', - appId: 'not-a-real-address', - composeHash: 'fake-hash', - instanceId: 'fake-instance', - deviceId: 'fake-device', - tcbStatus: 'COMPROMISED', // even compromised status - advisoryIds: ['CRITICAL-001', 'CRITICAL-002'], - }; - - const appResponse = await appFetch(new Request('http://localhost:3002/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(bogusBootInfo), - })); - - const kmsResponse = await appFetch(new Request('http://localhost:3002/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(bogusBootInfo), - })); - - const appData = await appResponse.json(); - const kmsData = await kmsResponse.json(); - - // Both should return success - expect(appData.isAllowed).toBe(true); - expect(kmsData.isAllowed).toBe(true); - expect(appData.reason).toBe('mock app always allowed'); - expect(kmsData.reason).toBe('mock KMS always allowed'); - }); - - it('denies both routes on an unknown MOCK_POLICY', async () => { - process.env.MOCK_POLICY = 'deny_all'; - try { - for (const path of ['app', 'kms']) { - const response = await appFetch(new Request(`http://localhost:3002/bootAuth/${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mrAggregated: '', osImageHash: '', appId: '', composeHash: '', instanceId: '', deviceId: '', - }), - })); - const data = await response.json(); - expect(data.isAllowed).toBe(false); - expect(data.reason).toBe('unknown MOCK_POLICY "deny_all"'); - } - } finally { - delete process.env.MOCK_POLICY; - } - }); -}); diff --git a/dstack/kms/auth-mock/index.ts b/dstack/kms/auth-mock/index.ts deleted file mode 100644 index 577cae583..000000000 --- a/dstack/kms/auth-mock/index.ts +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { Hono } from 'hono'; -import { zValidator } from '@hono/zod-validator'; -import { z } from 'zod'; - -if (process.env.NODE_ENV === 'production') { - throw new Error('auth-mock must not run with NODE_ENV=production'); -} - -// zod schemas for validation - compatible with original fastify implementation -const BootInfoSchema = z.object({ - // required fields (matching original fastify schema) - mrAggregated: z.string().describe('aggregated MR measurement'), - osImageHash: z.string().describe('OS Image hash'), - appId: z.string().describe('application ID'), - composeHash: z.string().describe('compose hash'), - instanceId: z.string().describe('instance ID'), - deviceId: z.string().describe('device ID'), - // optional fields (for full compatibility with BootInfo interface) - tcbStatus: z.string().optional().default(''), - advisoryIds: z.array(z.string()).optional().default([]), - mrSystem: z.string().optional().default('') -}); - -const BootResponseSchema = z.object({ - isAllowed: z.boolean(), - reason: z.string(), - gatewayAppId: z.string() -}); - -type BootInfo = z.infer; -type BootResponse = z.infer; - -// authorization policy - configurable via environment variables -// MOCK_POLICY: "allow-all" (default), "deny-kms", "deny-app", "deny-all", -// "allowlist-device", "allowlist-mr" -// MOCK_ALLOWED_DEVICE_IDS: comma-separated device IDs (for allowlist-device policy) -// MOCK_ALLOWED_MR_AGGREGATED: comma-separated MR aggregated values (for allowlist-mr policy) - -type MockPolicy = 'allow-all' | 'deny-kms' | 'deny-app' | 'deny-all' | 'allowlist-device' | 'allowlist-mr'; - -function getPolicy(): MockPolicy { - const policy = process.env.MOCK_POLICY || 'allow-all'; - const valid: MockPolicy[] = ['allow-all', 'deny-kms', 'deny-app', 'deny-all', 'allowlist-device', 'allowlist-mr']; - if (!valid.includes(policy as MockPolicy)) { - throw new Error(`unknown MOCK_POLICY "${policy}"`); - } - return policy as MockPolicy; -} - -function parseList(envVar: string): Set { - const raw = process.env[envVar] || ''; - return new Set(raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)); -} - -// mock backend class - no blockchain interaction -class MockBackend { - private mockGatewayAppId: string; - private mockChainId: number; - private mockAppImplementation: string; - - constructor() { - // mock values - configurable via environment variables - this.mockGatewayAppId = process.env.MOCK_GATEWAY_APP_ID || '0xmockgateway1234567890123456789012345678'; - this.mockChainId = parseInt(process.env.MOCK_CHAIN_ID || '1337'); - this.mockAppImplementation = process.env.MOCK_APP_IMPLEMENTATION || '0xmockapp9876543210987654321098765432109'; - } - - async checkBoot(bootInfo: BootInfo, isKms: boolean): Promise { - const policy = getPolicy(); - const deny = (reason: string): BootResponse => ({ - isAllowed: false, - reason, - gatewayAppId: '', - }); - const allow = (reason: string): BootResponse => ({ - isAllowed: true, - reason, - gatewayAppId: this.mockGatewayAppId, - }); - - switch (policy) { - case 'deny-all': - return deny(`mock policy: deny-all`); - case 'deny-kms': - if (isKms) return deny(`mock policy: deny-kms`); - return allow('mock app allowed (deny-kms policy)'); - case 'deny-app': - if (!isKms) return deny(`mock policy: deny-app`); - return allow('mock KMS allowed (deny-app policy)'); - case 'allowlist-device': { - const allowed = parseList('MOCK_ALLOWED_DEVICE_IDS'); - const deviceId = bootInfo.deviceId.toLowerCase().replace(/^0x/, ''); - if (allowed.size === 0) return deny('mock policy: allowlist-device with empty list'); - if (!allowed.has(deviceId)) return deny(`mock policy: device ${bootInfo.deviceId} not in allowlist`); - return allow(`mock policy: device ${bootInfo.deviceId} allowed`); - } - case 'allowlist-mr': { - const allowed = parseList('MOCK_ALLOWED_MR_AGGREGATED'); - const mr = bootInfo.mrAggregated.toLowerCase().replace(/^0x/, ''); - if (allowed.size === 0) return deny('mock policy: allowlist-mr with empty list'); - if (!allowed.has(mr)) return deny(`mock policy: mrAggregated ${bootInfo.mrAggregated} not in allowlist`); - return allow(`mock policy: mrAggregated ${bootInfo.mrAggregated} allowed`); - } - case 'allow-all': - default: - return allow(isKms ? 'mock KMS always allowed' : 'mock app always allowed'); - } - } - - async getGatewayAppId(): Promise { - return this.mockGatewayAppId; - } - - async getChainId(): Promise { - return this.mockChainId; - } - - async getAppImplementation(): Promise { - return this.mockAppImplementation; - } -} - -// initialize app -const app = new Hono(); - -// initialize mock backend -const mockBackend = new MockBackend(); - -// health check and info endpoint -app.get('/', async (c) => { - try { - const batch = await Promise.all([ - mockBackend.getGatewayAppId(), - mockBackend.getChainId(), - mockBackend.getAppImplementation(), - ]); - - return c.json({ - status: 'ok', - kmsContractAddr: process.env.KMS_CONTRACT_ADDR || '0xmockcontract1234567890123456789012345678', - ethRpcUrl: process.env.ETH_RPC_URL || '', - gatewayAppId: batch[0], - chainId: batch[1], - appAuthImplementation: batch[2], // NOTE: for backward compatibility - appImplementation: batch[2], - note: 'this is a mock backend - all authentications will succeed' - }); - } catch (error) { - console.error('error in health check:', error); - return c.json({ - status: 'error', - message: error instanceof Error ? error.message : String(error) - }, 500); - } -}); - -// app boot authentication -app.post('/bootAuth/app', - zValidator('json', BootInfoSchema), - async (c) => { - try { - const bootInfo = c.req.valid('json'); - console.log('mock app boot auth request:', { - appId: bootInfo.appId, - instanceId: bootInfo.instanceId, - note: 'always returning success' - }); - - const result = await mockBackend.checkBoot(bootInfo, false); - return c.json(result); - } catch (error) { - console.error('error in app boot auth:', error); - return c.json({ - isAllowed: false, - gatewayAppId: '', - reason: error instanceof Error ? error.message : String(error) - }); - } - } -); - -// KMS boot authentication -app.post('/bootAuth/kms', - zValidator('json', BootInfoSchema), - async (c) => { - try { - const bootInfo = c.req.valid('json'); - console.log('mock KMS boot auth request:', { - appId: bootInfo.appId, - instanceId: bootInfo.instanceId, - note: 'always returning success' - }); - - const result = await mockBackend.checkBoot(bootInfo, true); - return c.json(result); - } catch (error) { - // don't log test backend errors (keeping compatibility with original) - if (!(error instanceof Error && "Test backend error" === error.message)) { - console.error('error in KMS boot auth:', error); - } - return c.json({ - isAllowed: false, - gatewayAppId: '', - reason: error instanceof Error ? error.message : String(error) - }); - } - } -); - -// start server -const port = parseInt(process.env.PORT || '3000'); -const policy = getPolicy(); -console.log(`starting mock auth server on port ${port} (policy: ${policy})`); - -// Bun defaults idleTimeout to 10s and closes the socket while a handler is -// still awaiting work. Stay above the KMS webhook timeout (60s). Bun max: 255. -export default { - port, - idleTimeout: 255, - fetch: app.fetch, -}; diff --git a/dstack/kms/auth-mock/openapi.json b/dstack/kms/auth-mock/openapi.json deleted file mode 100644 index 1689308d0..000000000 --- a/dstack/kms/auth-mock/openapi.json +++ /dev/null @@ -1,250 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "dstack KMS Mock Backend API", - "description": "Mock API for dstack KMS backend - all authentications will succeed (no blockchain interaction)", - "version": "1.0.0" - }, - "servers": [ - { - "url": "http://localhost:3000", - "description": "Development server" - } - ], - "paths": { - "/": { - "get": { - "summary": "Health check and system information", - "description": "Returns mock system status and configuration information", - "operationId": "getSystemInfo", - "responses": { - "200": { - "description": "Mock system information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MockSystemInfo" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/bootAuth/app": { - "post": { - "summary": "Application boot authentication (mock - always succeeds)", - "description": "Mock validation that always allows application boot", - "operationId": "authenticateAppBoot", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootInfo" - } - } - } - }, - "responses": { - "200": { - "description": "Authentication result (always success in mock)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootResponse" - } - } - } - } - } - } - }, - "/bootAuth/kms": { - "post": { - "summary": "KMS boot authentication (mock - always succeeds)", - "description": "Mock validation that always allows KMS boot", - "operationId": "authenticateKmsBoot", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootInfo" - } - } - } - }, - "responses": { - "200": { - "description": "Authentication result (always success in mock)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootResponse" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "BootInfo": { - "type": "object", - "required": [ - "mrAggregated", - "osImageHash", - "appId", - "composeHash", - "instanceId", - "deviceId" - ], - "properties": { - "mrAggregated": { - "type": "string", - "description": "Aggregated MR measurement" - }, - "osImageHash": { - "type": "string", - "description": "OS Image hash" - }, - "appId": { - "type": "string", - "description": "Application ID" - }, - "composeHash": { - "type": "string", - "description": "Compose hash" - }, - "instanceId": { - "type": "string", - "description": "Instance ID" - }, - "deviceId": { - "type": "string", - "description": "Device ID" - }, - "tcbStatus": { - "type": "string", - "description": "TCB status (optional for compatibility)" - }, - "advisoryIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Advisory IDs (optional for compatibility)" - }, - "mrSystem": { - "type": "string", - "description": "MR System measurement (optional for compatibility)" - } - } - }, - "BootResponse": { - "type": "object", - "required": [ - "isAllowed", - "reason", - "gatewayAppId" - ], - "properties": { - "isAllowed": { - "type": "boolean", - "description": "Whether the boot is allowed (always true in mock)", - "example": true - }, - "reason": { - "type": "string", - "description": "Reason for the decision", - "example": "mock app always allowed" - }, - "gatewayAppId": { - "type": "string", - "description": "Mock gateway application ID", - "example": "0xmockgateway1234567890123456789012345678" - } - } - }, - "MockSystemInfo": { - "type": "object", - "required": [ - "status", - "kmsContractAddr", - "gatewayAppId", - "chainId", - "appAuthImplementation", - "appImplementation", - "note" - ], - "properties": { - "status": { - "type": "string", - "enum": ["ok"], - "description": "System status" - }, - "kmsContractAddr": { - "type": "string", - "description": "Mock KMS contract address", - "example": "0xmockcontract1234567890123456789012345678" - }, - "gatewayAppId": { - "type": "string", - "description": "Mock gateway application ID", - "example": "0xmockgateway1234567890123456789012345678" - }, - "chainId": { - "type": "number", - "description": "Mock blockchain chain ID", - "example": 1337 - }, - "appAuthImplementation": { - "type": "string", - "description": "Mock app auth implementation address (deprecated, use appImplementation)", - "example": "0xmockapp9876543210987654321098765432109" - }, - "appImplementation": { - "type": "string", - "description": "Mock app implementation address", - "example": "0xmockapp9876543210987654321098765432109" - }, - "note": { - "type": "string", - "description": "Note about mock backend behavior", - "example": "this is a mock backend - all authentications will succeed" - } - } - }, - "Error": { - "type": "object", - "required": [ - "status", - "message" - ], - "properties": { - "status": { - "type": "string", - "enum": ["error"] - }, - "message": { - "type": "string", - "description": "Error message" - } - } - } - } - } -} diff --git a/dstack/kms/auth-mock/package.json b/dstack/kms/auth-mock/package.json deleted file mode 100644 index cece2c985..000000000 --- a/dstack/kms/auth-mock/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "auth-mock", - "version": "1.0.0", - "description": "dstack KMS mock backend with bun + hono + zod", - "main": "index.ts", - "scripts": { - "dev": "bun run --watch index.ts", - "start": "bun run index.ts", - "build": "bun build index.ts --outdir ./dist --target bun", - "test": "vitest", - "test:run": "vitest run", - "lint": "oxlint .", - "lint:fix": "oxlint --fix .", - "fmt": "oxlint --fix .", - "check": "bun run lint && bun run test:run" - }, - "dependencies": { - "@hono/zod-validator": "0.9.1", - "hono": "4.13.14", - "zod": "4.6.7" - }, - "devDependencies": { - "@types/bun": "1.2.18", - "@types/node": "22.10.8", - "openapi-types": "12.1.3", - "oxlint": "0.9.10", - "typescript": "5.8.3", - "vite": "8.3.5", - "vitest": "5.0.5" - }, - "type": "module" -} diff --git a/dstack/kms/auth-mock/vitest.config.ts b/dstack/kms/auth-mock/vitest.config.ts deleted file mode 100644 index 17597daca..000000000 --- a/dstack/kms/auth-mock/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - }, -}); diff --git a/dstack/kms/auth-simple/.oxlintrc.json b/dstack/kms/auth-simple/.oxlintrc.json deleted file mode 100644 index f7d7b8e35..000000000 --- a/dstack/kms/auth-simple/.oxlintrc.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/config/schema.json", - "rules": { - "typescript": "warn", - "unicorn": "warn", - "react": "off", - "jsx-a11y": "off", - "nextjs": "off" - }, - "env": { - "node": true, - "es2022": true - }, - "globals": { - "globalThis": "readonly", - "console": "readonly" - }, - "ignore_patterns": [ - "node_modules/**", - "dist/**", - "build/**", - "coverage/**", - "*.min.js", - "*.d.ts", - "*.test.ts" - ] -} diff --git a/dstack/kms/auth-simple/README.md b/dstack/kms/auth-simple/README.md deleted file mode 100644 index efd2efcb5..000000000 --- a/dstack/kms/auth-simple/README.md +++ /dev/null @@ -1,252 +0,0 @@ -# dstack auth-simple - -A config-based auth server for dstack KMS webhook authorization. Validates boot requests against a JSON configuration file. - -## When to Use - -| Auth Server | Use Case | -|-------------|----------| -| **auth-simple** | Production deployments with config-file-based whitelisting | -| auth-eth | Production deployments with on-chain governance | -| auth-mock | Development and testing only | - -## Installation - -```bash -bun install -``` - -## Configuration - -Create `auth-config.json` (see `auth-config.example.json`). - -For KMS deployment, you must allowlist the OS image hash and at least one KMS -identity value via `kms.mrAggregated` (the early/boot-mr-done aggregate MR, -same pinning model as bare TDX). App compose hashes still use -`apps..composeHashes`. - -```json -{ - "osImages": ["0x0b327bcd642788b0517de3ff46d31ebd3847b6c64ea40bacde268bb9f1c8ec83"], - "kms": { - "mrAggregated": ["0x"], - "allowAnyDevice": true - }, - "apps": {} -} -``` - -Add more fields as you deploy Gateway and apps: - -```json -{ - "osImages": ["0x..."], - "allowedTcbStatuses": ["UpToDate"], - "allowedAdvisoryIds": [], - "gatewayAppId": "0x...", - "kms": { - "mrAggregated": ["0x..."], - "devices": [], - "allowAnyDevice": true - }, - "apps": { - "0xYourAppId": { - "composeHashes": ["0xabc...", "0xdef..."], - "devices": [], - "allowAnyDevice": true - } - } -} -``` - -### AWS NitroTPM Example - -AWS NitroTPM has no TDX/SNP-style TCB advisory surface, so the KMS normalizes a -verified NitroTPM attestation to `tcbStatus: "UpToDate"` before calling -auth-simple. Pin KMS via early `mrAggregated` (boot-mr-done snapshot), same as -bare TDX. Prefer a stable AMI/`os_image_hash` and empty TPM key-provider id so -that early MR is precomputable. - -```json -{ - "osImages": ["0x"], - "allowedAdvisoryIds": [], - "kms": { - "mrAggregated": ["0x"], - "devices": [], - "allowAnyDevice": true - }, - "apps": { - "0x": { - "composeHashes": ["0x"], - "devices": [], - "allowAnyDevice": true - } - } -} -``` - -The verifier that calls auth-simple must already have verified the NitroTPM -Attestation Document, AWS NitroTPM PKI chain, boot PCRs, PCR14 launch-event -replay, and OS image binding. PCR8 is an optional third-party shortcut, not a -required dstack check; see `docs/aws-ec2-production-verifier-runbook.md`. -auth-simple authorizes the resulting canonical `BootInfo`; it does not verify -raw NitroTPM evidence by itself. - -### Configuration Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `osImages` | Yes | Allowed OS image hashes (from `digest.txt`) | -| `gatewayAppId` | No | Gateway app ID (add after Gateway deployment) | -| `allowedTcbStatuses` | No | Allowed verifier-derived TCB status strings. Defaults to `["UpToDate"]`; non-up-to-date SNP/TDX statuses remain fail-closed unless explicitly allowlisted for testing. | -| `allowedAdvisoryIds` | No | Advisory IDs permitted in `advisoryIds`. Defaults to `[]`, which rejects any advisory. | -| `kms.mrAggregated` | Yes | Allowed KMS early aggregate MR values (boot-mr-done). | -| `kms.devices` | No | Allowed KMS device IDs. Empty denies all unless `kms.allowAnyDevice` is true. | -| `kms.allowAnyDevice` | No | If true, skip device ID check for KMS | -| `apps..composeHashes` | No | Allowed compose hashes for this app | -| `apps..devices` | No | Allowed device IDs for this app. Empty denies all unless `allowAnyDevice` is true. | -| `apps..allowAnyDevice` | No | If true, skip device ID check for this app | - -For experimental AMD SEV-SNP dry-run authorization, keep the default fail-closed TCB policy unless you intentionally want the auth webhook to accept non-up-to-date verifier-derived SNP `BootInfo`. To exercise the dry-run path without enabling key release, allowlist the recomputed SNP `mrAggregated`, `osImageHash`, app/compose identity, device/chip identity, and any non-default `allowedTcbStatuses`/`allowedAdvisoryIds` values explicitly. KMS still rejects SNP before returning app keys, KMS keys, or app certificates. - -### Getting Hash Values - -**OS Image Hash:** -```bash -# From meta-dstack build output -cat images/digest.txt -``` - -**Compose Hash:** -```bash -sha256sum .app-compose.json | awk '{print "0x"$1}' -``` - -## Usage - -### Development - -```bash -# Run with hot reload -bun run dev -``` - -### Production - -```bash -# Run directly -bun run start - -# Or build first -bun run build -``` - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | 3000 | Server port | -| `AUTH_CONFIG_PATH` | ./auth-config.json | Path to config file | - -## API Endpoints - -### GET / - -Health check and server info. - -**Response:** -```json -{ - "status": "ok", - "configPath": "./auth-config.json", - "gatewayAppId": "0x..." -} -``` - -### POST /bootAuth/app - -App boot authorization. - -**Request:** -```json -{ - "teeVariant": "dstack-tdx", - "mrAggregated": "0x...", - "osImageHash": "0x...", - "appId": "0x...", - "composeHash": "0x...", - "instanceId": "0x...", - "deviceId": "0x...", - "tcbStatus": "UpToDate", - "advisoryIds": [] -} -``` - -**Response:** -```json -{ - "isAllowed": true, - "reason": "", - "gatewayAppId": "0x..." -} -``` - -### POST /bootAuth/kms - -KMS boot authorization. - -**Request:** Same as `/bootAuth/app` - -**Response:** Same as `/bootAuth/app` - -## Validation Logic - -### KMS Boot Validation - -1. `tcbStatus` must be listed in `allowedTcbStatuses` (default: only `"UpToDate"`). AWS NitroTPM is normalized to `"UpToDate"` by the KMS before it reaches auth-simple. -2. Every `advisoryIds` entry must be listed in `allowedAdvisoryIds` (default: none allowed) -3. `osImageHash` must be in `osImages` array -4. `mrAggregated` must be in the `kms.mrAggregated` early/boot-mr-done allowlist -5. `deviceId` must be in `kms.devices` unless `allowAnyDevice` is true - -### App Boot Validation - -1. `tcbStatus` must be listed in `allowedTcbStatuses` (default: only `"UpToDate"`). AWS NitroTPM is normalized to `"UpToDate"` by the KMS before it reaches auth-simple. -2. Every `advisoryIds` entry must be listed in `allowedAdvisoryIds` (default: none allowed) -3. `osImageHash` must be in `osImages` array -4. `appId` must exist in `apps` object -5. `composeHash` must be in app's `composeHashes` array -6. `deviceId` must be in app's `devices` (unless `allowAnyDevice` is true) - -## Hot Reload - -The config file is re-read on every request. No restart required after config changes. - -## Integration with KMS - -Configure KMS to use webhook auth pointing to this server: - -```toml -[core.auth_api] -type = "webhook" - -[core.auth_api.webhook] -url = "http://localhost:3000" -``` - -## Testing - -```bash -# Run tests -bun run test - -# Run once -bun run test:run -``` - -## See Also - -- [auth-eth](../auth-eth/) - On-chain governance auth server -- [auth-mock](../auth-mock/) - Development/testing auth server (always allows) -- [Deployment Guide](../../../docs/deployment.md) - Full deployment instructions diff --git a/dstack/kms/auth-simple/auth-config.example.json b/dstack/kms/auth-simple/auth-config.example.json deleted file mode 100644 index b93e4e987..000000000 --- a/dstack/kms/auth-simple/auth-config.example.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "osImages": [ - "0x0b327bcd642788b0517de3ff46d31ebd3847b6c64ea40bacde268bb9f1c8ec83" - ], - "kms": { - "mrAggregated": [ - "0x" - ], - "devices": [], - "allowAnyDevice": true - }, - "apps": { - "0xYourAppId": { - "composeHashes": [ - "0xYourComposeHash" - ], - "devices": [], - "allowAnyDevice": true - } - } -} diff --git a/dstack/kms/auth-simple/bun.lock b/dstack/kms/auth-simple/bun.lock deleted file mode 100644 index daaf50f92..000000000 --- a/dstack/kms/auth-simple/bun.lock +++ /dev/null @@ -1,186 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "auth-simple", - "dependencies": { - "@hono/zod-validator": "0.9.1", - "hono": "4.13.14", - "zod": "4.6.7", - }, - "devDependencies": { - "@types/bun": "1.2.18", - "@types/node": "22.10.8", - "openapi-types": "12.1.3", - "oxlint": "0.9.10", - "typescript": "5.8.3", - "vite": "8.3.5", - "vitest": "5.0.5", - }, - }, - }, - "packages": { - "@hono/zod-validator": ["@hono/zod-validator@0.9.1", "", { "peerDependencies": { "hono": ">=4.11.2", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@oxc-project/types": ["@oxc-project/types@0.155.0", "", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="], - - "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@0.9.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eOXKZYq5bnCSgDefgM5bzAg+4Fc//Rc4yjgKN8iDWUARweCaChiQXb6TXX8MfEfs6qayEMy6yVj0pqoFz0B1aw=="], - - "@oxlint/darwin-x64": ["@oxlint/darwin-x64@0.9.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UeYICDvLUaUOcY+0ugZUEmBMRLP+x8iTgL7TeY6BlpGw2ahbtUOTbyIIRWtr/0O++TnjZ+v8TzhJ9crw6Ij6dg=="], - - "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@0.9.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0Zn+vqHhrZyufFBfq9WOgiIool0gCR14BLsdS+0Dwd9o+kNxPGA5q7erQFkiC4rpkxtfBHeD3iIKMMt7d29Kyw=="], - - "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@0.9.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-tkQcWpYwF42bA/uRaV2iMFePHkBjTTgomOgeEaiw6XOSJX4nBEqGIIboqqLBWT4JnKCf/L+IG3y/e1MflhKByw=="], - - "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@0.9.10", "", { "os": "linux", "cpu": "x64" }, "sha512-JHbkMUnibqaSMBvLHyqTL5cWxcGW+jw+Ppt2baLISpvo34a6fBR+PI7v/A92sEDWe0W1rPhypzCwA8mKpkQ3DA=="], - - "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@0.9.10", "", { "os": "linux", "cpu": "x64" }, "sha512-aBBwN7bQzidwHwEXr7BAdVvMTLWstCy5gikerjLnGDeCSXX9r+o6+yUzTOqZvOo66E+XBgOJaVbY8rsL1MLE0g=="], - - "@oxlint/win32-arm64": ["@oxlint/win32-arm64@0.9.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-LXDnk7vKHT3IY6G1jq0O7+XMhtcHOYuxLGIx4KP+4xS6vKgBY+Bsq4xV3AtmtKlvnXkP5FxHpfLmcEtm5AWysA=="], - - "@oxlint/win32-x64": ["@oxlint/win32-x64@0.9.10", "", { "os": "win32", "cpu": "x64" }, "sha512-w5XRAV4bhgwenjjpGYZGglqzG9Wv/sI+cjQWJBQsvfDXsr2w4vOBXzt1j3/Z3EcSqf4KtkCa/IIuAhQyeShUbA=="], - - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], - - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/node": ["@types/node@22.10.8", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-rk+QvAEGsbX/ZPiiyel6hJHNUS9cnSbPWVaZLvE+Er3tLqQFzWMz9JOfWW7XUmKvRPfxJfbl3qYWve+RGXncFw=="], - - "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], - - "@vitest/mocker": ["@vitest/mocker@5.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg=="], - - "@vitest/spy": ["@vitest/spy@5.0.5", "", {}, "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw=="], - - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], - - "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], - - "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - - "magic-string": ["magic-string@1.4.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w=="], - - "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - - "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - - "oxlint": ["oxlint@0.9.10", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "0.9.10", "@oxlint/darwin-x64": "0.9.10", "@oxlint/linux-arm64-gnu": "0.9.10", "@oxlint/linux-arm64-musl": "0.9.10", "@oxlint/linux-x64-gnu": "0.9.10", "@oxlint/linux-x64-musl": "0.9.10", "@oxlint/win32-arm64": "0.9.10", "@oxlint/win32-x64": "0.9.10" }, "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-bKiiFN7Hnoaist/rditTRBXz+GXKYuLd53/NB7Q6zHB/bifELJarSoRLkAUGElIJKl4PSr3lTh1g6zehh+rX0g=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - - "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], - - "rolldown": ["rolldown@1.2.15", "", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="], - - "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], - - "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], - - "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], - - "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], - - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], - - "vitest": ["vitest@5.0.5", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.5", "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.5", "@vitest/coverage-v8": "5.0.5", "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg=="], - - "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], - - "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - - "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - } -} diff --git a/dstack/kms/auth-simple/index.test.ts b/dstack/kms/auth-simple/index.test.ts deleted file mode 100644 index fee441712..000000000 --- a/dstack/kms/auth-simple/index.test.ts +++ /dev/null @@ -1,494 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { writeFileSync, unlinkSync, existsSync } from 'fs'; -import app from './index'; - -const TEST_CONFIG_PATH = './test-auth-config.json'; - -const baseBootInfo = { - teeVariant: 'dstack-tdx', - mrAggregated: '0xabc123', - osImageHash: '0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a', - appId: '0xapp123', - composeHash: '0xcompose456', - instanceId: '0xinstance789', - deviceId: '0xdevice999', - tcbStatus: 'UpToDate', - advisoryIds: [], - mrSystem: '' -}; - -function writeTestConfig(config: object) { - writeFileSync(TEST_CONFIG_PATH, JSON.stringify(config, null, 2)); -} - -describe('auth-simple', () => { - beforeAll(() => { - process.env.AUTH_CONFIG_PATH = TEST_CONFIG_PATH; - }); - - afterAll(() => { - if (existsSync(TEST_CONFIG_PATH)) { - unlinkSync(TEST_CONFIG_PATH); - } - }); - - describe('GET /', () => { - it('returns health check info', async () => { - writeTestConfig({ gatewayAppId: '0xgateway' }); - - const res = await app.fetch(new Request('http://localhost/', { method: 'GET' })); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.status).toBe('ok'); - expect(json.gatewayAppId).toBe('0xgateway'); - }); - }); - - describe('POST /bootAuth/kms', () => { - it('allows KMS boot with valid config', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xabc123'], - devices: ['0xdevice999'], - allowAnyDevice: false - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(true); - expect(json.reason).toBe(''); - }); - - it('rejects KMS boot with invalid TCB status', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xabc123'], - allowAnyDevice: true - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...baseBootInfo, tcbStatus: 'OutOfDate' }) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('TCB status'); - }); - - it('requires explicit opt-in for non-UpToDate SEV-SNP TCB status', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xabc123'], - devices: ['0xdevice999'], - allowAnyDevice: false - } - }); - - const sevSnpBootInfo = { - ...baseBootInfo, - teeVariant: 'dstack-amd-sev-snp', - tcbStatus: 'OutOfDate' - }; - const denied = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(sevSnpBootInfo) - })); - expect((await denied.json()).isAllowed).toBe(false); - - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - allowedTcbStatuses: ['OutOfDate'], - kms: { - mrAggregated: ['0xabc123'], - devices: ['0xdevice999'], - allowAnyDevice: false - } - }); - - const allowed = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(sevSnpBootInfo) - })); - const allowedJson = await allowed.json(); - - expect(allowedJson.isAllowed).toBe(true); - expect(allowedJson.reason).toBe(''); - }); - - it('rejects unallowlisted advisory IDs by default', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xabc123'], - allowAnyDevice: true - } - }); - - const denied = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...baseBootInfo, advisoryIds: ['INTEL-SA-TEST'] }) - })); - const deniedJson = await denied.json(); - - expect(deniedJson.isAllowed).toBe(false); - expect(deniedJson.reason).toContain('advisory'); - - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - allowedAdvisoryIds: ['INTEL-SA-TEST'], - kms: { - mrAggregated: ['0xabc123'], - allowAnyDevice: true - } - }); - - const allowed = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...baseBootInfo, advisoryIds: ['INTEL-SA-TEST'] }) - })); - const allowedJson = await allowed.json(); - - expect(allowedJson.isAllowed).toBe(true); - }); - - it('rejects KMS boot with invalid OS image', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0xdifferentimage'], - kms: { - mrAggregated: ['0xabc123'], - allowAnyDevice: true - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('OS image'); - }); - - it('rejects KMS boot with invalid mrAggregated', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xdifferentmr'], - allowAnyDevice: true - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('MR'); - }); - - it('rejects KMS boot when the identity allowlist is empty', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: [], - allowAnyDevice: true - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('MR'); - }); - it('rejects KMS boot with an empty device allowlist', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xabc123'], - devices: [], - allowAnyDevice: false - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('device'); - }); - - it('allows KMS boot with allowAnyDevice', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - kms: { - mrAggregated: ['0xabc123'], - devices: ['0xotherdevice'], - allowAnyDevice: true - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/kms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(true); - }); - }); - - describe('POST /bootAuth/app', () => { - it('allows app boot with valid config', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: { - '0xapp123': { - composeHashes: ['0xcompose456'], - devices: ['0xdevice999'], - allowAnyDevice: false - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(true); - expect(json.reason).toBe(''); - }); - - it('rejects app boot with unregistered app', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: {} - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('not registered'); - }); - - it('rejects app boot with invalid compose hash', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: { - '0xapp123': { - composeHashes: ['0xdifferenthash'], - allowAnyDevice: true - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('compose hash'); - }); - - it('rejects app boot with invalid device', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: { - '0xapp123': { - composeHashes: ['0xcompose456'], - devices: ['0xotherdevice'], - allowAnyDevice: false - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('device'); - }); - - it('rejects app boot with an empty device allowlist', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: { - '0xapp123': { - composeHashes: ['0xcompose456'], - devices: [], - allowAnyDevice: false - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(false); - expect(json.reason).toContain('device'); - }); - - it('allows app boot with allowAnyDevice', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: { - '0xapp123': { - composeHashes: ['0xcompose456'], - devices: [], - allowAnyDevice: true - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(true); - }); - - // Stricter than a legacy DstackApp by design; see DstackApp.t.sol - // test_IsAppAllowed_RejectsOutdatedTcbWhenRequired. - it('rejects app boot with out-of-date TCB or unallowlisted advisory ID', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: [baseBootInfo.osImageHash], - apps: { '0xapp123': { composeHashes: ['0xcompose456'], allowAnyDevice: true } } - }); - - for (const [patch, reason] of [ - [{ tcbStatus: 'OutOfDate' }, 'TCB status is not allowed'], - [{ advisoryIds: ['INTEL-SA-00614'] }, 'advisory ID is not allowed'], - ] as const) { - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...baseBootInfo, ...patch }) - })); - const json = await res.json(); - expect(json.isAllowed).toBe(false); - expect(json.reason).toBe(reason); - } - }); - }); - - describe('hex normalization', () => { - it('handles uppercase hex', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['0x1FBB0CF9CC6CFBF23D6B779776FABAD2C5403D643BADB9E5E238615E4960A78A'], - apps: { - '0xAPP123': { - composeHashes: ['0xCOMPOSE456'], - allowAnyDevice: true - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(true); - }); - - it('handles hex without 0x prefix', async () => { - writeTestConfig({ - gatewayAppId: '0xgateway', - osImages: ['1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a'], - apps: { - 'app123': { - composeHashes: ['compose456'], - allowAnyDevice: true - } - } - }); - - const res = await app.fetch(new Request('http://localhost/bootAuth/app', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(baseBootInfo) - })); - const json = await res.json(); - - expect(json.isAllowed).toBe(true); - }); - }); -}); diff --git a/dstack/kms/auth-simple/index.ts b/dstack/kms/auth-simple/index.ts deleted file mode 100644 index 9da149014..000000000 --- a/dstack/kms/auth-simple/index.ts +++ /dev/null @@ -1,325 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Phala Network -// -// SPDX-License-Identifier: Apache-2.0 - -import { Hono } from 'hono'; -import { zValidator } from '@hono/zod-validator'; -import { z } from 'zod'; -import { readFileSync, existsSync } from 'fs'; - -// zod schemas for validation - compatible with auth-eth implementation -const BootInfoSchema = z.object({ - teeVariant: z.string().optional().default(''), - mrAggregated: z.string().describe('aggregated MR measurement'), - osImageHash: z.string().describe('OS Image hash'), - appId: z.string().describe('application ID'), - composeHash: z.string().describe('compose hash'), - instanceId: z.string().describe('instance ID'), - deviceId: z.string().describe('device ID'), - tcbStatus: z.string().optional().default(''), - advisoryIds: z.array(z.string()).optional().default([]), - mrSystem: z.string().optional().default('') -}); - -const BootResponseSchema = z.object({ - isAllowed: z.boolean(), - reason: z.string(), - gatewayAppId: z.string() -}); - -// config schema -const AppConfigSchema = z.object({ - composeHashes: z.array(z.string()).default([]), - devices: z.array(z.string()).default([]), - allowAnyDevice: z.boolean().default(false) -}); - -const KmsConfigSchema = z.object({ - mrAggregated: z.array(z.string()).default([]), - devices: z.array(z.string()).default([]), - allowAnyDevice: z.boolean().default(false) -}); - -const AuthConfigSchema = z.object({ - gatewayAppId: z.string().default(''), - // KMS expects these fields in the health check response - kmsContractAddr: z.string().default('0x0000000000000000000000000000000000000000'), - chainId: z.number().default(0), - appImplementation: z.string().default('0x0000000000000000000000000000000000000000'), - osImages: z.array(z.string()).default([]), - // TDX and SEV-SNP production defaults remain strict: only UpToDate is - // accepted unless operators explicitly allow another verifier-derived status. - allowedTcbStatuses: z.array(z.string()).default(['UpToDate']), - allowedAdvisoryIds: z.array(z.string()).default([]), - kms: KmsConfigSchema.default({}), - apps: z.record(z.string(), AppConfigSchema).default({}) -}); - -type BootInfo = z.infer; -type BootResponse = z.infer; -type AuthConfig = z.infer; - -// normalize hex string to lowercase with 0x prefix -function normalizeHex(hex: string): string { - hex = hex.toLowerCase(); - if (!hex.startsWith('0x')) { - hex = '0x' + hex; - } - return hex; -} - -// config-based backend -class ConfigBackend { - private getConfigPath(): string { - return process.env.AUTH_CONFIG_PATH || './auth-config.json'; - } - - private loadConfig(): AuthConfig { - const configPath = this.getConfigPath(); - if (!existsSync(configPath)) { - console.error(`config file not found: ${configPath}`); - return AuthConfigSchema.parse({}); - } - - try { - const content = readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - return AuthConfigSchema.parse(parsed); - } catch (error) { - console.error(`failed to load config: ${error}`); - return AuthConfigSchema.parse({}); - } - } - - async checkBoot(bootInfo: BootInfo, isKms: boolean): Promise { - const config = this.loadConfig(); - const osImageHash = normalizeHex(bootInfo.osImageHash); - const deviceId = normalizeHex(bootInfo.deviceId); - - // check TCB status - const allowedTcbStatuses = config.allowedTcbStatuses; - if (!allowedTcbStatuses.includes(bootInfo.tcbStatus)) { - return { - isAllowed: false, - reason: 'TCB status is not allowed', - gatewayAppId: config.gatewayAppId - }; - } - - for (const advisoryId of bootInfo.advisoryIds) { - if (!config.allowedAdvisoryIds.includes(advisoryId)) { - return { - isAllowed: false, - reason: 'advisory ID is not allowed', - gatewayAppId: config.gatewayAppId - }; - } - } - - // check OS image - const allowedOsImages = config.osImages.map(normalizeHex); - if (!allowedOsImages.includes(osImageHash)) { - return { - isAllowed: false, - reason: 'OS image is not allowed', - gatewayAppId: config.gatewayAppId - }; - } - - if (isKms) { - return this.checkKmsBoot(bootInfo, config, deviceId); - } else { - return this.checkAppBoot(bootInfo, config, deviceId); - } - } - - private checkKmsBoot(bootInfo: BootInfo, config: AuthConfig, deviceId: string): BootResponse { - const mrAggregated = normalizeHex(bootInfo.mrAggregated); - - // check aggregated MR - const allowedMrs = config.kms.mrAggregated.map(normalizeHex); - if (!allowedMrs.includes(mrAggregated)) { - return { - isAllowed: false, - reason: 'aggregated MR not allowed', - gatewayAppId: config.gatewayAppId - }; - } - - // check device ID - if (!config.kms.allowAnyDevice) { - const allowedDevices = config.kms.devices.map(normalizeHex); - if (!allowedDevices.includes(deviceId)) { - return { - isAllowed: false, - reason: 'KMS is not allowed to boot on this device', - gatewayAppId: config.gatewayAppId - }; - } - } - - return { - isAllowed: true, - reason: '', - gatewayAppId: config.gatewayAppId - }; - } - - private checkAppBoot(bootInfo: BootInfo, config: AuthConfig, deviceId: string): BootResponse { - const appId = normalizeHex(bootInfo.appId); - const composeHash = normalizeHex(bootInfo.composeHash); - - // check app exists - const appConfig = Object.entries(config.apps).find( - ([id]) => normalizeHex(id) === appId - )?.[1]; - - if (!appConfig) { - return { - isAllowed: false, - reason: 'app not registered', - gatewayAppId: config.gatewayAppId - }; - } - - // check compose hash - const allowedHashes = appConfig.composeHashes.map(normalizeHex); - if (!allowedHashes.includes(composeHash)) { - return { - isAllowed: false, - reason: 'compose hash not allowed', - gatewayAppId: config.gatewayAppId - }; - } - - // check device ID - if (!appConfig.allowAnyDevice) { - const allowedDevices = appConfig.devices.map(normalizeHex); - if (!allowedDevices.includes(deviceId)) { - return { - isAllowed: false, - reason: 'app is not allowed to boot on this device', - gatewayAppId: config.gatewayAppId - }; - } - } - - return { - isAllowed: true, - reason: '', - gatewayAppId: config.gatewayAppId - }; - } - - async getGatewayAppId(): Promise { - return this.loadConfig().gatewayAppId; - } - - async getInfo(): Promise<{ - gatewayAppId: string; - kmsContractAddr: string; - chainId: number; - appImplementation: string; - }> { - const config = this.loadConfig(); - return { - gatewayAppId: config.gatewayAppId, - kmsContractAddr: config.kmsContractAddr, - chainId: config.chainId, - appImplementation: config.appImplementation, - }; - } -} - -// initialize app -const app = new Hono(); - -// initialize backend -const backend = new ConfigBackend(); - -// health check and info endpoint -app.get('/', async (c) => { - try { - const info = await backend.getInfo(); - return c.json({ - status: 'ok', - kmsContractAddr: info.kmsContractAddr, - gatewayAppId: info.gatewayAppId, - chainId: info.chainId, - appAuthImplementation: info.appImplementation, // backward compat - appImplementation: info.appImplementation, - }); - } catch (error) { - console.error('error in health check:', error); - return c.json({ - status: 'error', - message: error instanceof Error ? error.message : String(error) - }, 500); - } -}); - -// app boot authentication -app.post('/bootAuth/app', - zValidator('json', BootInfoSchema), - async (c) => { - try { - const bootInfo = c.req.valid('json'); - console.log('app boot auth request:', { - appId: bootInfo.appId, - composeHash: bootInfo.composeHash, - instanceId: bootInfo.instanceId, - }); - - const result = await backend.checkBoot(bootInfo, false); - console.log('app boot auth result:', result); - return c.json(result); - } catch (error) { - console.error('error in app boot auth:', error); - return c.json({ - isAllowed: false, - gatewayAppId: '', - reason: error instanceof Error ? error.message : String(error) - }); - } - } -); - -// KMS boot authentication -app.post('/bootAuth/kms', - zValidator('json', BootInfoSchema), - async (c) => { - try { - const bootInfo = c.req.valid('json'); - console.log('KMS boot auth request:', { - osImageHash: bootInfo.osImageHash, - mrAggregated: bootInfo.mrAggregated, - instanceId: bootInfo.instanceId, - }); - - const result = await backend.checkBoot(bootInfo, true); - console.log('KMS boot auth result:', result); - return c.json(result); - } catch (error) { - console.error('error in KMS boot auth:', error); - return c.json({ - isAllowed: false, - gatewayAppId: '', - reason: error instanceof Error ? error.message : String(error) - }); - } - } -); - -// start server -const port = parseInt(process.env.PORT || '3000'); -const defaultConfigPath = process.env.AUTH_CONFIG_PATH || './auth-config.json'; -console.log(`starting auth-simple server on port ${port}`); -console.log(`config path: ${defaultConfigPath}`); - -// Bun defaults idleTimeout to 10s and closes the socket while a handler is -// still awaiting work. Stay above the KMS webhook timeout (60s). Bun max: 255. -export default { - port, - idleTimeout: 255, - fetch: app.fetch, -}; diff --git a/dstack/kms/auth-simple/package.json b/dstack/kms/auth-simple/package.json deleted file mode 100644 index bb0bbb7e6..000000000 --- a/dstack/kms/auth-simple/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "auth-simple", - "version": "1.0.0", - "description": "dstack KMS config-based auth server with bun + hono + zod", - "main": "index.ts", - "scripts": { - "dev": "bun run --watch index.ts", - "start": "bun run index.ts", - "build": "bun build index.ts --outdir ./dist --target bun", - "test": "vitest", - "test:run": "vitest run", - "lint": "oxlint .", - "lint:fix": "oxlint --fix .", - "fmt": "oxlint --fix .", - "check": "bun run lint && bun run test:run" - }, - "dependencies": { - "@hono/zod-validator": "0.9.1", - "hono": "4.13.14", - "zod": "4.6.7" - }, - "devDependencies": { - "@types/bun": "1.2.18", - "@types/node": "22.10.8", - "openapi-types": "12.1.3", - "oxlint": "0.9.10", - "typescript": "5.8.3", - "vite": "8.3.5", - "vitest": "5.0.5" - }, - "type": "module" -} diff --git a/dstack/kms/dstack-app/compose-dev.yaml b/dstack/kms/dstack-app/compose-dev.yaml index 75c4b2d6e..955f92687 100644 --- a/dstack/kms/dstack-app/compose-dev.yaml +++ b/dstack/kms/dstack-app/compose-dev.yaml @@ -8,21 +8,25 @@ services: build: context: . dockerfile_inline: | - FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d + FROM oven/bun:1.4.4-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a WORKDIR /app - RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* + RUN apk add --no-cache git ca-certificates RUN git clone ${GIT_REPOSITORY} && \ cd dstack && \ git checkout ${GIT_REV} WORKDIR /app/dstack/dstack/kms/auth-eth - RUN npm ci - RUN npm run build - CMD node dist/main.js + RUN bun install --frozen-lockfile --production --ignore-scripts + ENV NODE_ENV=production + CMD ["bun", "index.ts"] environment: - - HOST=0.0.0.0 - PORT=8000 - ETH_RPC_URL=${ETH_RPC_URL} + # finalized on L1; an OP-stack chain read through Helios requires latest. + - ETH_BLOCK_TAG=${ETH_BLOCK_TAG} + - ETH_FINALITY_CONFIRMATIONS=${ETH_FINALITY_CONFIRMATIONS} + # Empty disables the chain ID check. + - ETH_CHAIN_ID=${ETH_CHAIN_ID} - KMS_CONTRACT_ADDR=${KMS_CONTRACT_ADDR} restart: unless-stopped ports: @@ -35,6 +39,7 @@ services: - /var/run/dstack.sock:/var/run/dstack.sock ports: - 8000:8000 + - 8002:8001 depends_on: - auth-api restart: unless-stopped @@ -71,6 +76,18 @@ configs: [core.auth_api.webhook] url = "http://auth-api:8000" + [core.admin] + enabled = true + # The onboarding KMS reaches it through the CVM port 8002. + address = "0.0.0.0" + port = 8001 + # SHA-256 of the admin token, which stays with the operator. + auth_token_hash = "${ADMIN_TOKEN_HASH}" + + [core.admin.tls] + key = "/kms/certs/rpc.key" + certs = "/kms/certs/rpc.crt" + [core.onboard] enabled = true auto_bootstrap_domain = "" diff --git a/dstack/kms/dstack-app/compose-simple.yaml b/dstack/kms/dstack-app/compose-simple.yaml index 366a2af67..3dbd6f2a5 100644 --- a/dstack/kms/dstack-app/compose-simple.yaml +++ b/dstack/kms/dstack-app/compose-simple.yaml @@ -2,8 +2,9 @@ # # SPDX-License-Identifier: Apache-2.0 -# KMS deployment with external auth-simple webhook -# auth-simple runs outside the CVM (operator infrastructure) +# KMS deployment with an external dstack-auth webhook. +# dstack-auth runs outside the CVM, on the operator host; see +# docs/dstack-auth-operations.md. services: kms: @@ -13,6 +14,7 @@ services: - /var/run/dstack.sock:/var/run/dstack.sock ports: - 8000:8000 + - 8002:8001 restart: unless-stopped configs: - source: kms_config @@ -47,6 +49,18 @@ configs: [core.auth_api.webhook] url = "${AUTH_WEBHOOK_URL}" + [core.admin] + enabled = true + # The onboarding KMS reaches it through the CVM port 8002. + address = "0.0.0.0" + port = 8001 + # SHA-256 of the admin token, which stays with the operator. + auth_token_hash = "${ADMIN_TOKEN_HASH}" + + [core.admin.tls] + key = "/kms/certs/rpc.key" + certs = "/kms/certs/rpc.crt" + [core.onboard] enabled = true auto_bootstrap_domain = "" diff --git a/dstack/kms/dstack-app/deploy-simple.sh b/dstack/kms/dstack-app/deploy-simple.sh index 06defbd45..3e36b17a3 100755 --- a/dstack/kms/dstack-app/deploy-simple.sh +++ b/dstack/kms/dstack-app/deploy-simple.sh @@ -4,8 +4,11 @@ # # SPDX-License-Identifier: Apache-2.0 -# Deploy KMS with external auth-simple webhook -# auth-simple runs outside the CVM (operator infrastructure) +# Deploy KMS with an external dstack-auth webhook. +# dstack-auth runs outside the CVM, on the operator host. Start it first: +# cargo build --release -p dstack-auth +# dstack-auth --config auth-config.json --port 3001 +# See docs/dstack-auth-operations.md. set -e @@ -20,18 +23,24 @@ else # Create a template .env.simple file echo "Creating template .env.simple file..." cat >.env.simple < ./kms.toml cache_dir = "./images" download_url = "${IMAGE_DOWNLOAD_URL}" download_timeout = "2m" + +[core.admin] +auth_token_hash = "${ADMIN_TOKEN_HASH}" EOF exec dstack-kms -c ./kms.toml diff --git a/dstack/kms/kms.toml b/dstack/kms/kms.toml index c24484d16..eb98cd9c6 100644 --- a/dstack/kms/kms.toml +++ b/dstack/kms/kms.toml @@ -82,11 +82,14 @@ cache_dir = "/usr/share/dstack/images" download_url = "http://localhost:8000/{OS_IMAGE_HASH}.tar.gz" download_timeout = "2m" -# Admin API: serves operator RPCs (e.g. ClearImageCache) on a dedicated -# listener behind the shared HTTP authenticator. Disabled by default; when -# enabled it fails closed unless auth_token or htpasswd_file is set. +# Admin API: serves operator RPCs (e.g. ClearImageCache) and the key handover +# to onboarding KMS nodes on a dedicated listener behind the shared HTTP +# authenticator. It fails closed: the KMS does not start until auth_token (or +# DSTACK_KMS_ADMIN_TOKEN), auth_token_hash or htpasswd_file is set. Unless +# core.onboard.public_key_handover = true, the KMS also requires this +# listener and its TLS below, so that other KMS nodes can onboard. [core.admin] -enabled = false +enabled = true # Bind address/port for the admin listener (keep it off the public network). address = "127.0.0.1" port = 8001 @@ -95,19 +98,24 @@ port = 8001 # `Authorization: Bearer ` or the `X-Admin-Token: ` header. # Required unless insecure_no_auth = true. Generate with: openssl rand -hex 32 auth_token = "" +# Or the token's hex SHA-256, for a config that is not secret (an app compose). +# The hash is public there, so the token must be high-entropy random, or it can +# be brute-forced offline: TOKEN=$(openssl rand -hex 32) +# printf '%s' "$TOKEN" | sha256sum +auth_token_hash = "" # Optional Apache bcrypt htpasswd file (create with `htpasswd -B -c file admin`). htpasswd_file = "" # Development/testing escape hatch only. Never enable this on an admin # interface that is reachable from the network. insecure_no_auth = false -# Optional TLS for the admin listener. When configured, clients may present an -# RA-TLS certificate. GetKmsKey requires it; other admin RPCs do not. To serve -# KMS onboarding, enable this and bind `address` to one the new KMS can reach. +# TLS for the admin listener, so that clients may present an RA-TLS +# certificate. GetKmsKey requires it; other admin RPCs do not. To serve KMS +# onboarding, also bind `address` to one the new KMS can reach. # No `[core.admin.tls.mutual]` is needed, as for `[rpc.tls]` above. -# [core.admin.tls] -# key = "/etc/kms/certs/rpc.key" -# certs = "/etc/kms/certs/rpc.crt" +[core.admin.tls] +key = "/etc/kms/certs/rpc.key" +certs = "/etc/kms/certs/rpc.crt" [core.metrics] # Expose unauthenticated Prometheus metrics on /metrics. @@ -129,8 +137,8 @@ gateway_app_id = "any" [core.onboard] enabled = true auto_bootstrap_domain = "" -# Keep KMS.GetKmsKey on the public RPC listener for older onboarding clients. -# New deployments should point onboarding at the admin listener instead. -public_key_handover = true +# Also serve KMS.GetKmsKey on the public RPC listener, for onboarding clients +# that cannot reach the admin listener (KMS 0.5.x, or no source admin token). +public_key_handover = false address = "0.0.0.0" port = 8000 diff --git a/dstack/kms/rpc/proto/kms_rpc.proto b/dstack/kms/rpc/proto/kms_rpc.proto index 6853df2be..8085b2892 100644 --- a/dstack/kms/rpc/proto/kms_rpc.proto +++ b/dstack/kms/rpc/proto/kms_rpc.proto @@ -11,12 +11,24 @@ package kms; message GetAppKeyRequest { uint32 api_version = 1; string vm_config = 2; + // Root key epoch to derive from; unset means the current epoch. Epoch 0 is + // the original root keys. Older epochs remain available so CVMs can migrate. + optional uint32 root_epoch = 3; + optional uint32 app_epoch = 4; } message AppId { bytes app_id = 1; } +message SealedEnvPubKeyResponse { + bytes public_key = 1; + uint32 root_epoch = 2; + uint64 timestamp = 3; + bytes signature = 4; + uint32 app_epoch = 5; +} + message PublicKeyResponse { bytes public_key = 1; // Legacy signature without timestamp (for backward compatibility). @@ -46,6 +58,11 @@ message AppKeyResponse { string gateway_app_id = 7; // OS Image hash bytes os_image_hash = 8; + // Root key epoch all epoch-dependent fields above were derived from. + uint32 root_epoch = 9; + // X25519 private key for the SealedEnv HPKE receiver, derived for this epoch. + bytes sealed_env_key = 10; + uint32 app_epoch = 11; } message GetMetaResponse { @@ -73,15 +90,12 @@ message KmsKeys { } message KmsKeyResponse { + // Retained for legacy KMS onboarding. This public CA authenticates no caller. string temp_ca_key = 1; + // Root keys of every epoch the KMS holds; the index is the epoch. repeated KmsKeys keys = 2; } -// See the note on `GetTempCaCert`. -// -// `temp_ca_key` is a CA private key handed to any caller, so it authenticates -// nobody. It exists because the KMS pinned this CA for mutual TLS, which left a -// self-issued RA-TLS certificate with nothing to chain to. message GetTempCaCertResponse { string temp_ca_cert = 1; string temp_ca_key = 2; @@ -107,21 +121,10 @@ service KMS { rpc GetKmsKey(GetKmsKeyRequest) returns (KmsKeyResponse); // Request the app environment encryption public key given the app id rpc GetAppEnvEncryptPubKey(AppId) returns (PublicKeyResponse); + rpc GetSealedEnvPubKey(AppId) returns (SealedEnvPubKeyResponse); // Request the KMS instance metadata for use as a probe and health check. rpc GetMeta(google.protobuf.Empty) returns (GetMetaResponse); - // Request the temporary CA certificate and key. - // - // Both current callers - guests at boot, and KMS-to-KMS onboarding - fetch this CA - // and mint their client certificate from it, because the KMS used to pin the CA for - // mutual TLS and a self-issued certificate had nothing to chain to. - // - // That pin is gone: the KMS now verifies client certificates by the attestation - // they carry and ignores the issuer, so a self-issued certificate would be accepted - // on the same terms. Neither caller has been migrated yet, so this RPC still has to - // work; it is scheduled for removal once both are. - // - // Do not build new callers on it. A caller that only needs the KMS root CA can read - // `ca_cert` from `GetMeta` instead. + // Compatibility for existing OS images and tokenless legacy onboarding. rpc GetTempCaCert(google.protobuf.Empty) returns (GetTempCaCertResponse); // Sign a certificate rpc SignCert(SignCertRequest) returns (SignCertResponse); @@ -158,11 +161,18 @@ message OnboardRequest { string domain = 2; // Optional bearer token for the source KMS admin listener. string source_token = 3; + // 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; } message OnboardResponse { - // k256 public key (secp256k1) inherited from source KMS + // k256 public key (secp256k1) of the current epoch bytes k256_pubkey = 1; + // Set only by a rotation: the generated CA public key and the attestation + // binding the new epoch's public keys. + bytes ca_pubkey = 2; + bytes attestation = 3; } // Attestation info needed for on-chain KMS authorization. diff --git a/dstack/kms/src/admin_auth.rs b/dstack/kms/src/admin_auth.rs index 22fe33bd6..540b50aa1 100644 --- a/dstack/kms/src/admin_auth.rs +++ b/dstack/kms/src/admin_auth.rs @@ -8,11 +8,13 @@ //! attestation), the admin API is reached by operators/tooling, so it uses the //! same shared-token/htpasswd HTTP mechanism as the VMM and gateway. The token //! is configured via `core.admin.auth_token`, or the `DSTACK_KMS_ADMIN_TOKEN` / -//! `ADMIN_API_TOKEN` environment variables. +//! `ADMIN_API_TOKEN` environment variables, or as its SHA-256 in +//! `core.admin.auth_token_hash`. -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use dstack_api_auth::{Authenticator, HttpAuthConfig, HttpAuthFairing}; use rocket::Route; +use sha2::{Digest, Sha256}; use crate::config::AdminConfig; @@ -29,28 +31,47 @@ impl AdminAuthFairing { http_config(), ))); } - let token = if !config.auth_token.is_empty() { - config.auth_token.trim().to_owned() - } else { - std::env::var(ENV_ADMIN_TOKEN) - .or_else(|_| std::env::var(ENV_ADMIN_TOKEN_COMPAT)) - .unwrap_or_default() - .trim() - .to_owned() - }; - if token.is_empty() && config.htpasswd_file.as_os_str().is_empty() { - bail!( - "admin API is enabled but neither auth_token nor htpasswd_file is configured; \ - set core.admin.auth_token, {ENV_ADMIN_TOKEN}, {ENV_ADMIN_TOKEN_COMPAT}, \ - core.admin.htpasswd_file, or insecure_no_auth = true (testing only)" - ); - } - let mut auth = Authenticator::from_tokens([token]); - if !config.htpasswd_file.as_os_str().is_empty() { - auth = auth.with_htpasswd_file(&config.htpasswd_file)?; - } - Ok(Self(HttpAuthFairing::new(auth, http_config()))) + Ok(Self(HttpAuthFairing::new( + authenticator(config)?, + http_config(), + ))) + } +} + +fn authenticator(config: &AdminConfig) -> Result { + let token = if !config.auth_token.is_empty() { + config.auth_token.trim().to_owned() + } else { + std::env::var(ENV_ADMIN_TOKEN) + .or_else(|_| std::env::var(ENV_ADMIN_TOKEN_COMPAT)) + .unwrap_or_default() + .trim() + .to_owned() + }; + let mut hashes = Vec::new(); + if !token.is_empty() { + hashes.push(Sha256::digest(token.as_bytes()).into()); + } + let hash = config.auth_token_hash.trim(); + if !hash.is_empty() { + let hash = hex::decode(hash) + .ok() + .and_then(|hash| <[u8; 32]>::try_from(hash).ok()) + .context("core.admin.auth_token_hash must be a hex SHA-256 digest")?; + hashes.push(hash); + } + if hashes.is_empty() && config.htpasswd_file.as_os_str().is_empty() { + bail!( + "admin API is enabled but no credential is configured; set core.admin.auth_token, \ + {ENV_ADMIN_TOKEN}, {ENV_ADMIN_TOKEN_COMPAT}, core.admin.auth_token_hash, \ + core.admin.htpasswd_file, or insecure_no_auth = true (testing only)" + ); + } + let mut auth = Authenticator::from_token_hashes(hashes); + if !config.htpasswd_file.as_os_str().is_empty() { + auth = auth.with_htpasswd_file(&config.htpasswd_file)?; } + Ok(auth) } fn http_config() -> HttpAuthConfig { @@ -102,6 +123,23 @@ mod tests { assert!(AdminAuthFairing::from_config(&cfg).is_ok()); } + #[test] + fn a_token_hash_admits_its_token() { + let cfg = AdminConfig { + enabled: true, + auth_token_hash: hex::encode(Sha256::digest(b"secret")), + ..Default::default() + }; + let auth = authenticator(&cfg).unwrap(); + assert!(auth.verify_token("secret")); + assert!(!auth.verify_token("other")); + let bad = AdminConfig { + auth_token_hash: "00".into(), + ..cfg + }; + assert!(authenticator(&bad).is_err()); + } + #[test] fn configured_token_builds() { let cfg = AdminConfig { diff --git a/dstack/kms/src/admin_service.rs b/dstack/kms/src/admin_service.rs index 181ff4dea..a8f9ce30c 100644 --- a/dstack/kms/src/admin_service.rs +++ b/dstack/kms/src/admin_service.rs @@ -14,7 +14,7 @@ use dstack_kms_rpc::{ }; use ra_rpc::{CallContext, RpcCall}; -use crate::main_service::{KmsState, RpcHandler}; +use crate::main_service::{KeyRelease, KmsState, RpcHandler}; pub struct AdminRpcHandler { state: KmsState, @@ -28,7 +28,9 @@ impl AdminRpc for AdminRpcHandler { } async fn get_kms_key(self, request: GetKmsKeyRequest) -> Result { - self.rpc.handover_keys(request).await + self.rpc + .handover_keys(KeyRelease::AdminHandover, request) + .await } } diff --git a/dstack/kms/src/config.rs b/dstack/kms/src/config.rs index fd2200fb8..7e95e8c45 100644 --- a/dstack/kms/src/config.rs +++ b/dstack/kms/src/config.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use anyhow::{bail, Result}; use dstack_attest::attestation::AttestationVerifierConfig; use load_config::load_config; use rocket::figment::Figment; @@ -21,6 +22,8 @@ const RPC_CERT: &str = "rpc.crt"; const RPC_KEY: &str = "rpc.key"; const RPC_DOMAIN: &str = "rpc-domain"; const K256_KEY: &str = "root-k256.key"; +const ROOT_EPOCH: &str = "root-epoch"; +const EPOCHS_DIR: &str = "epochs"; const BOOTSTRAP_INFO: &str = "bootstrap-info.json"; #[derive(Debug, Clone, Deserialize)] @@ -108,6 +111,10 @@ pub(crate) struct AdminConfig { /// `insecure_no_auth = true`. #[serde(default)] pub auth_token: String, + /// Hex SHA-256 of the admin token, for configs that are not secret, such + /// as an app compose, so the token itself stays out of them. + #[serde(default)] + pub auth_token_hash: String, /// Optional Apache bcrypt htpasswd file, accepted in addition to the token. #[serde(default)] pub htpasswd_file: PathBuf, @@ -121,6 +128,37 @@ fn default_true() -> bool { true } +/// Without the public handover, other KMS nodes can get the keys only from the +/// admin listener over TLS. A KMS without either would strand them, so it +/// refuses to start, in any mode. +pub(crate) fn check_key_handover(config: &KmsConfig, figment: &Figment) -> Result<()> { + if config.onboard.public_key_handover { + return Ok(()); + } + let set = |key: &str| { + figment + .find_value(key) + .ok() + .and_then(|value| value.into_string()) + .is_some_and(|value| !value.is_empty()) + }; + let missing = if !config.admin.enabled { + "[core.admin] enabled = true" + } else if !set("core.admin.tls.key") { + "[core.admin.tls] key" + } else if !set("core.admin.tls.certs") { + "[core.admin.tls] certs" + } else { + return Ok(()); + }; + bail!( + "no key handover path for onboarding other KMS nodes: {missing} is not set. \ + Configure the admin listener with TLS ([core.admin] enabled = true, an auth_token \ + or DSTACK_KMS_ADMIN_TOKEN, and [core.admin.tls] key and certs), or set \ + core.onboard.public_key_handover = true" + ) +} + impl KmsConfig { /// The root keys are the last files a bootstrap writes, so both existing /// marks a completed one. Anything short of that is left over from an @@ -130,9 +168,7 @@ impl KmsConfig { } pub fn keys_exists(&self) -> bool { - self.tmp_ca_cert().exists() - && self.tmp_ca_key().exists() - && self.root_ca_cert().exists() + self.root_ca_cert().exists() && self.root_ca_key().exists() && self.rpc_cert().exists() && self.rpc_key().exists() @@ -171,6 +207,28 @@ impl KmsConfig { self.cert_dir.join(K256_KEY) } + /// Records the newest epoch this KMS holds, whose roots are the top-level + /// root files. Absent means 0. + pub fn root_epoch(&self) -> PathBuf { + self.cert_dir.join(ROOT_EPOCH) + } + + /// Root key paths of `epoch` in a keyring whose newest epoch is `latest`: + /// the newest roots are the top-level root files, so paths that predate + /// epochs are unchanged. Older epochs live in `epochs//`. + pub fn root_key_paths(&self, epoch: u32, latest: u32) -> RootKeyPaths { + let dir = if epoch == latest { + self.cert_dir.clone() + } else { + self.cert_dir.join(EPOCHS_DIR).join(epoch.to_string()) + }; + RootKeyPaths { + ca_cert: dir.join(ROOT_CA_CERT), + ca_key: dir.join(ROOT_CA_KEY), + k256_key: dir.join(K256_KEY), + } + } + pub fn bootstrap_info(&self) -> PathBuf { self.cert_dir.join(BOOTSTRAP_INFO) } @@ -209,12 +267,18 @@ pub(crate) struct Dev { pub gateway_app_id: String, } +pub(crate) struct RootKeyPaths { + pub ca_cert: PathBuf, + pub ca_key: PathBuf, + pub k256_key: PathBuf, +} + #[derive(Debug, Clone, Deserialize)] pub(crate) struct OnboardConfig { pub enabled: bool, pub auto_bootstrap_domain: String, - /// Keep GetKmsKey available on the public RPC listener for compatibility. - #[serde(default = "default_true")] + /// Also serve GetKmsKey on the public RPC listener, for legacy onboarding clients. + #[serde(default)] pub public_key_handover: bool, } @@ -224,18 +288,54 @@ mod tests { use rocket::figment::providers::{Format, Toml}; #[test] - fn default_config_parses_with_admin_disabled_and_no_hash() { + fn default_config_serves_the_handover_on_the_admin_listener() { let figment = load_config_figment(None); let config: KmsConfig = figment .focus("core") .extract() .expect("kms.toml must parse into KmsConfig"); - assert!(!config.admin.enabled, "admin must be off by default"); + assert!(config.admin.enabled); + check_key_handover(&config, &figment).expect("the default config has a handover path"); assert!( config.admin.auth_token.is_empty(), "default admin token must be empty (fail-closed)" ); assert!(!config.admin.insecure_no_auth); + assert!( + !config.onboard.public_key_handover, + "public key handover must be off by default" + ); + } + + fn handover_check(toml: &str) -> Result<()> { + let figment = load_config_figment(None).merge(Toml::string(toml)); + let config: KmsConfig = figment.focus("core").extract().unwrap(); + check_key_handover(&config, &figment) + } + + #[test] + fn a_serving_kms_needs_a_key_handover_path() { + let public = "[core.onboard]\npublic_key_handover = true\n"; + let no_admin = "[core.admin]\nenabled = false\n"; + let no_key = "[core.admin.tls]\nkey = \"\"\n"; + let no_certs = "[core.admin.tls]\ncerts = \"\"\n"; + assert!(handover_check("").is_ok()); + // onboarding and bootstrap modes are checked alike. + let onboarding = "[core.onboard]\nenabled = true\nauto_bootstrap_domain = \"kms.test\"\n"; + assert!(handover_check(onboarding).is_ok()); + assert!(handover_check(&format!("{onboarding}{no_admin}")).is_err()); + for missing in [no_admin, no_key, no_certs] { + assert!(handover_check(&format!("{public}{missing}")).is_ok()); + } + for (toml, setting) in [ + (no_admin, "[core.admin] enabled = true"), + (no_key, "[core.admin.tls] key"), + (no_certs, "[core.admin.tls] certs"), + ] { + let err = handover_check(toml).unwrap_err().to_string(); + assert!(err.contains(setting), "{err}"); + assert!(err.contains("public_key_handover = true"), "{err}"); + } } #[test] diff --git a/dstack/kms/src/crypto.rs b/dstack/kms/src/crypto.rs index faa8e01f8..93a5c5054 100644 --- a/dstack/kms/src/crypto.rs +++ b/dstack/kms/src/crypto.rs @@ -8,11 +8,21 @@ use k256::ecdsa::SigningKey; use ra_tls::api_v1::sign_recoverable_keccak256; use ra_tls::kdf; +pub(crate) fn app_epoch_context(epoch: u32) -> Vec { + if epoch == 0 { + Vec::new() + } else { + [b"app-epoch-v1:".as_slice(), &epoch.to_be_bytes()].concat() + } +} + pub(crate) fn derive_k256_key( parent_key: &SigningKey, app_id: &[u8], + app_epoch: u32, ) -> Result<(SigningKey, Vec)> { - let context_data = [app_id, b"app-key"]; + let epoch_context = app_epoch_context(app_epoch); + let context_data = [app_id, b"app-key", epoch_context.as_slice()]; let derived_key_bytes: [u8; 32] = kdf::derive_key(&parent_key.to_bytes(), &context_data, 32)? .try_into() .ok() @@ -20,12 +30,21 @@ pub(crate) fn derive_k256_key( let derived_signing_key = SigningKey::from_bytes(&derived_key_bytes.into())?; let pubkey = derived_signing_key.verifying_key(); - let signature = sign_message( - parent_key, - b"dstack-kms-issued", - app_id, - &pubkey.to_sec1_bytes(), - )?; + let signature = if app_epoch == 0 { + sign_message( + parent_key, + b"dstack-kms-issued", + app_id, + &pubkey.to_sec1_bytes(), + )? + } else { + sign_message( + parent_key, + b"dstack-kms-issued-v2", + app_id, + &[app_epoch.to_be_bytes().as_slice(), &pubkey.to_sec1_bytes()].concat(), + )? + }; Ok((derived_signing_key, signature)) } @@ -141,7 +160,7 @@ mod tests { #[test] fn issued_app_key_signatures_match_their_golden_vectors() { let root = SigningKey::from_slice(&[7_u8; 32]).unwrap(); - let (derived, signature) = derive_k256_key(&root, b"app-a").unwrap(); + let (derived, signature) = derive_k256_key(&root, b"app-a", 0).unwrap(); assert_eq!( hex::encode(derived.to_bytes()), diff --git a/dstack/kms/src/main.rs b/dstack/kms/src/main.rs index f4bf428a8..ce730abfc 100644 --- a/dstack/kms/src/main.rs +++ b/dstack/kms/src/main.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::{anyhow, bail, Context, Result}; -use clap::Parser; +use clap::{Parser, Subcommand}; use config::KmsConfig; use main_service::{KmsState, RpcHandler}; use ra_rpc::ratls_client_verifier::RaTlsClientAuth; @@ -35,6 +35,32 @@ struct Args { /// Path to the configuration file #[arg(short, long)] config: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Print, as hex, the attestation `registerRootEpoch` takes for epoch 0, + /// converted from the legacy `kmsInfo` quote and event log + LegacyAttestation { + /// TDX quote, hex + #[arg(long)] + quote: String, + /// Event log JSON, hex-encoded as `kmsInfo()` returns it + #[arg(long)] + eventlog: String, + }, +} + +fn legacy_attestation(quote: &str, eventlog: &str) -> Result { + let decode = |hex_str: &str| hex::decode(hex_str.trim().trim_start_matches("0x")); + let quote = decode(quote).context("invalid quote hex")?; + let eventlog = decode(eventlog).context("invalid event log hex")?; + let attestation = dstack_attest::attestation::Attestation::from_tdx_quote(quote, &eventlog)? + .into_versioned() + .to_bytes()?; + Ok(format!("0x{}", hex::encode(attestation))) } async fn run_onboard_service(kms_config: KmsConfig, figment: Figment) -> Result<()> { @@ -123,6 +149,10 @@ async fn main() -> Result<()> { fmt().with_env_filter(filter).with_ansi(false).init(); } let args = Args::parse(); + if let Some(Command::LegacyAttestation { quote, eventlog }) = args.command { + println!("{}", legacy_attestation("e, &eventlog)?); + return Ok(()); + } let figment = config::load_config_figment(args.config.as_deref()); let config: KmsConfig = figment.focus("core").extract()?; @@ -135,6 +165,23 @@ async fn main() -> Result<()> { ); } + // before onboarding or bootstrap, so a misconfigured node fails before it + // gets or generates root keys: the handover path, and the admin listener + // serving it (enabled with no credential fails here). + config::check_key_handover(&config, &figment)?; + let admin_config = config.admin.clone(); + let admin_setup = if admin_config.enabled { + let admin_value = figment + .find_value("core.admin") + .context("core.admin section not found")?; + let admin_figment = + Figment::from(rocket::Config::default()).merge(Serialized::defaults(admin_value)); + let admin_fairing = admin_auth::AdminAuthFairing::from_config(&admin_config)?; + Some((admin_figment, admin_fairing)) + } else { + None + }; + if config.onboard.enabled && !config.keys_exists() { info!("Onboarding"); run_onboard_service(config.clone(), figment.clone()).await?; @@ -143,8 +190,12 @@ async fn main() -> Result<()> { } } + let keyring = main_service::Keyring::load(&config).context("Failed to load the root keys")?; + let rpc_epoch = main_service::wait_for_servable_epoch(&config, &keyring).await; + info!("Issuing RPC certificate under root epoch {rpc_epoch}"); + info!("Updating certs"); - if let Err(err) = onboard_service::update_certs(&config).await { + if let Err(err) = onboard_service::update_certs(&config, rpc_epoch, keyring.latest()).await { if config.attest_rpc_cert { return Err(err).context( "Failed to reissue the attested KMS RPC certificate; refusing to start with a \ @@ -161,22 +212,12 @@ async fn main() -> Result<()> { } let metrics_enabled = config.metrics.enabled; - let admin_config = config.admin.clone(); - // build the admin listener figment from `[core.admin]` before `config` is - // moved into the state; the fairing is built now so a misconfigured admin - // (enabled but no credential) fails fast before we start serving. - let admin_setup = if admin_config.enabled { - let admin_value = figment - .find_value("core.admin") - .context("core.admin section not found")?; - let admin_figment = - Figment::from(rocket::Config::default()).merge(Serialized::defaults(admin_value)); - let admin_fairing = admin_auth::AdminAuthFairing::from_config(&admin_config)?; - Some((admin_figment, admin_fairing)) - } else { - None - }; - let state = main_service::KmsState::new(config).context("Failed to initialize KMS state")?; + // Reload certificates after renewal before exposing them through RPCs. + let rpc_ca_cert = + fs_err::read_to_string(config.root_key_paths(rpc_epoch, keyring.latest()).ca_cert)?; + let keyring = main_service::Keyring::load(&config).context("Failed to reload the root keys")?; + let state = main_service::KmsState::new(config, keyring, rpc_ca_cert) + .context("Failed to initialize KMS state")?; let quote_verifier = QuoteVerifier::new(state.attestation_verifier()); let admin_quote_verifier = QuoteVerifier::new(state.attestation_verifier()); let figment = figment @@ -213,6 +254,11 @@ async fn main() -> Result<()> { rocket = rocket.manage(quote_verifier); let main_srv = rocket.launch(); + // Refresh the TLS listener when the authorized root CA changes. + let restart = { + let state = state.clone(); + async move { state.restart_requested().await } + }; match admin_setup { Some((admin_figment, admin_fairing)) => { if admin_config.insecure_no_auth { @@ -233,14 +279,46 @@ async fn main() -> Result<()> { .manage(state) .manage(admin_quote_verifier) .launch(); - tokio::try_join!( - async { main_srv.await.map_err(|err| anyhow!(err.to_string())) }, - async { admin_srv.await.map_err(|err| anyhow!(err.to_string())) }, - )?; - } - None => { - main_srv.await.map_err(|err| anyhow!(err.to_string()))?; + let servers = async { + tokio::try_join!( + async { main_srv.await.map_err(|err| anyhow!(err.to_string())) }, + async { admin_srv.await.map_err(|err| anyhow!(err.to_string())) }, + ) + .map(drop) + }; + tokio::select! { + result = servers => result?, + () = restart => bail!("the RPC root CA changed; restarting to refresh the TLS certificate"), + } } + None => tokio::select! { + result = main_srv => result.map(drop).map_err(|err| anyhow!(err.to_string()))?, + () = restart => bail!("the RPC root CA changed; restarting to refresh the TLS certificate"), + }, } Ok(()) } + +#[cfg(test)] +mod tests { + use dstack_attest::attestation::VersionedAttestation; + + #[test] + fn legacy_attestation_converts_a_get_quote_response() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../verifier/fixtures/tdx-lite-getquote.json" + )) + .unwrap(); + let quote = fixture["quote"].as_str().unwrap(); + let eventlog = hex::encode(fixture["event_log"].as_str().unwrap()); + + let attestation = super::legacy_attestation(quote, &format!("0x{eventlog}")).unwrap(); + let attestation = + VersionedAttestation::from_bytes(&hex::decode(&attestation[2..]).unwrap()).unwrap(); + let attestation = attestation.into_v1(); + assert_eq!( + hex::encode(attestation.report_data().unwrap()), + fixture["report_data"].as_str().unwrap() + ); + } +} diff --git a/dstack/kms/src/main_service.rs b/dstack/kms/src/main_service.rs index d845a82c6..e8744e70a 100644 --- a/dstack/kms/src/main_service.rs +++ b/dstack/kms/src/main_service.rs @@ -14,28 +14,31 @@ use anyhow::{anyhow, bail, Context, Result}; use dstack_kms_rpc::{ kms_server::{KmsRpc, KmsServer}, AppId, AppKeyResponse, GetAppKeyRequest, GetKmsKeyRequest, GetMetaResponse, - GetTempCaCertResponse, KmsKeyResponse, KmsKeys, PublicKeyResponse, SignCertRequest, - SignCertResponse, + GetTempCaCertResponse, KmsKeyResponse, KmsKeys, PublicKeyResponse, SealedEnvPubKeyResponse, + SignCertRequest, SignCertResponse, }; use dstack_verifier::{CvmVerifier, VerificationDetails}; use fs_err as fs; use k256::ecdsa::SigningKey; use moka::future::Cache; -use ra_rpc::{CallContext, RpcCall}; +use p256::pkcs8::DecodePrivateKey; +use ra_rpc::{CallContext, RemoteEndpoint, RpcCall}; use ra_tls::{ attestation::{resolve_vm_config, AttestationVerifier, TeeVariant, VerifiedAttestation}, cert::{CaCert, CertRequest, CertSigningRequestV1, CertSigningRequestV2, Csr}, kdf, + rcgen::KeyPair, }; use scale::Decode; -use tracing::warn; +use tracing::{info, warn}; use upgrade_authority::{ - build_boot_info, ensure_app_id_len, local_kms_boot_info, BootInfo, GetInfoResponse, + build_boot_info, ensure_app_id_len, local_kms_boot_info, AuthorizationResult, BootInfo, + GetInfoResponse, Operation, RootEpochRegistry, }; use crate::{ - config::KmsConfig, - crypto::{derive_k256_key, sign_message, sign_message_with_timestamp}, + config::{KmsConfig, RootKeyPaths}, + crypto::{app_epoch_context, derive_k256_key, sign_message, sign_message_with_timestamp}, }; pub(crate) mod amd_attest; @@ -43,15 +46,21 @@ pub(crate) mod upgrade_authority; /// How long the auth API's info and this KMS's own authorization are reused. /// -/// `GetMeta`, `GetAppEnvEncryptPubKey` and `GetTempCaCert` need no client certificate, and each +/// `GetMeta` and `GetAppEnvEncryptPubKey` need no client certificate, and each /// would otherwise cost the auth API a round of chain RPC calls. Concurrent callers share one /// in-flight request. App authorization is never cached. const AUTH_API_CACHE_TTL: Duration = Duration::from_secs(1); +/// Apps whose epoch is cached at once. Floods of random app IDs still reach the +/// auth API; limit them per source in a proxy in front of the KMS. +const APP_EPOCH_CACHE_CAPACITY: u64 = 10_000; + /// How long this KMS's own verified boot info is reused. /// /// Its measurements do not change while the KMS runs, but the TCB status and advisories do when -/// the platform's collateral is updated, and the auth API judges them on every self check. +/// the platform's collateral is updated. Self checks judge the cached copy, so a change to this +/// KMS's TCB status takes effect up to this long later. Callers' attestations are verified on +/// every request and are not affected. const SELF_BOOT_INFO_TTL: Duration = Duration::from_secs(60 * 60); #[derive(Clone)] @@ -67,17 +76,113 @@ impl std::ops::Deref for KmsState { } } +/// The root key pair of one epoch. +struct RootKeys { + ca: CaCert, + k256: SigningKey, +} + +impl RootKeys { + fn load(paths: &RootKeyPaths) -> Result { + let ca = CaCert::load(&paths.ca_cert, &paths.ca_key) + .context("failed to load root CA certificate")?; + let key_bytes = fs::read(&paths.k256_key).context("failed to read ECDSA root key")?; + let k256 = SigningKey::from_slice(&key_bytes).context("failed to load ECDSA root key")?; + Ok(Self { ca, k256 }) + } +} + +/// The root keys of epochs 0..=latest this KMS holds, and their chain hashes. +pub(crate) struct Keyring { + roots: Vec, + chain_hashes: Vec<[u8; 32]>, +} + +impl Keyring { + pub(crate) fn load(config: &KmsConfig) -> Result { + let latest = read_root_epoch(config)?; + let roots = (0..=latest) + .map(|epoch| { + RootKeys::load(&config.root_key_paths(epoch, latest)) + .with_context(|| format!("failed to load root keys of epoch {epoch}")) + }) + .collect::>>()?; + let chain_hashes = root_chain_hashes(roots.iter().map(|root| (&root.ca.key, &root.k256))); + Ok(Self { + roots, + chain_hashes, + }) + } + + pub(crate) fn latest(&self) -> u32 { + self.roots.len() as u32 - 1 + } + + pub(crate) fn servable_epoch(&self, registry: RootEpochRegistry) -> Result { + servable_epoch(&self.chain_hashes, registry) + } +} + +/// Chain hashes of the root keys of epochs 0, 1, ... +pub(crate) fn root_chain_hashes<'a>( + roots: impl Iterator, +) -> Vec<[u8; 32]> { + let pubkeys: Vec<_> = roots + .map(|(ca, k256)| { + ( + ca.public_key_der(), + k256.verifying_key().to_sec1_bytes().to_vec(), + ) + }) + .collect(); + dstack_types::root_epoch::chain_hashes(pubkeys.iter().map(|(ca, k256)| (&ca[..], &k256[..]))) +} + +/// The epoch a keyring with `chain_hashes` serves under `registry`: the +/// current epoch, which the keyring must hold with a matching chain hash. A +/// keyring nobody registered is servable only at epoch 0, as before root +/// epochs existed. +pub(crate) fn servable_epoch( + chain_hashes: &[[u8; 32]], + registry: RootEpochRegistry, +) -> Result { + let latest = chain_hashes.len() as u32 - 1; + let current = match registry { + RootEpochRegistry::Unchecked => return Ok(latest), + RootEpochRegistry::Empty if latest == 0 => return Ok(0), + RootEpochRegistry::Empty => { + bail!("the keyring holds root epoch {latest}, but no root epoch is registered") + } + RootEpochRegistry::Current(current) => current, + }; + match chain_hashes.get(current.epoch as usize) { + Some(hash) if *hash == current.chain_hash => Ok(current.epoch), + Some(_) => bail!( + "the root keys up to epoch {} are not the registered ones", + current.epoch + ), + None => bail!( + "root epoch {} is current, but the keyring holds only up to {latest}", + current.epoch + ), + } +} + pub struct KmsStateInner { config: KmsConfig, - root_ca: CaCert, - k256_key: SigningKey, - temp_ca_cert: String, - temp_ca_key: String, + keyring: Keyring, + temp_ca: CaCert, + /// The CA of the running TLS listener, independent of key selection. + rpc_ca_pubkey: Vec, + restart: tokio::sync::Notify, verifier: CvmVerifier, attestation_verifier: Arc, self_boot_info: Cache<(), BootInfo>, - self_allowed: Cache<(), ()>, + self_allowed: Cache, auth_api_info: Cache<(), GetInfoResponse>, + /// App epochs read for the unauthenticated env public key RPCs, so repeated + /// requests for one app do not each reach the auth API. + app_epochs: Cache, u32>, metrics: KmsMetrics, } @@ -85,6 +190,35 @@ pub struct KmsStateInner { pub(crate) struct KmsMetrics { attestation_requests_total: AtomicU64, attestation_failures_total: AtomicU64, + key_releases_total: [AtomicU64; KeyRelease::ALL.len()], +} + +/// A listener and operation that hands out key material. +#[derive(Debug, Clone, Copy)] +pub(crate) enum KeyRelease { + RpcHandover, + AdminHandover, + AppKey, + AppCert, +} + +impl KeyRelease { + const ALL: [Self; 4] = [ + Self::RpcHandover, + Self::AdminHandover, + Self::AppKey, + Self::AppCert, + ]; + + /// The listener and operation labels. + fn labels(self) -> (&'static str, &'static str) { + match self { + Self::RpcHandover => ("rpc", "kms_export_keys"), + Self::AdminHandover => ("admin", "kms_export_keys"), + Self::AppKey => ("rpc", "app_get_key"), + Self::AppCert => ("rpc", "app_get_cert"), + } + } } impl KmsMetrics { @@ -97,9 +231,24 @@ impl KmsMetrics { } } + fn record_key_release(&self, release: KeyRelease) { + self.key_releases_total[release as usize].fetch_add(1, Ordering::Relaxed); + } + pub(crate) fn render_prometheus(&self) -> String { let attestation_requests_total = self.attestation_requests_total.load(Ordering::Relaxed); let attestation_failures_total = self.attestation_failures_total.load(Ordering::Relaxed); + let mut key_releases = String::from( + "# HELP dstack_kms_key_releases_total Total number of key material releases.\n\ + # TYPE dstack_kms_key_releases_total counter\n", + ); + for release in KeyRelease::ALL { + let count = self.key_releases_total[release as usize].load(Ordering::Relaxed); + let (listener, operation) = release.labels(); + key_releases.push_str(&format!( + "dstack_kms_key_releases_total{{listener=\"{listener}\",operation=\"{operation}\"}} {count}\n" + )); + } format!( "# HELP dstack_kms_attestation_requests_total Total number of KMS attestation requests.\n\ @@ -107,14 +256,19 @@ impl KmsMetrics { dstack_kms_attestation_requests_total {attestation_requests_total}\n\ # HELP dstack_kms_attestation_failures_total Total number of failed KMS attestation requests.\n\ # TYPE dstack_kms_attestation_failures_total counter\n\ - dstack_kms_attestation_failures_total {attestation_failures_total}\n" + dstack_kms_attestation_failures_total {attestation_failures_total}\n\ + {key_releases}" ) } } -fn short_lived_cache() -> Cache<(), V> { +fn short_lived_cache(capacity: u64) -> Cache +where + K: Eq + std::hash::Hash + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ Cache::builder() - .max_capacity(1) + .max_capacity(capacity) .time_to_live(AUTH_API_CACHE_TTL) .build() } @@ -127,16 +281,15 @@ impl KmsState { self.verifier.clear_cache(image_hash, config_hash) } - pub fn new(config: KmsConfig) -> Result { - let root_ca = CaCert::load(config.root_ca_cert(), config.root_ca_key()) - .context("Failed to load root CA certificate")?; - let key_bytes = fs::read(config.k256_key()).context("Failed to read ECDSA root key")?; - let k256_key = - SigningKey::from_slice(&key_bytes).context("Failed to load ECDSA root key")?; - let temp_ca_key = - fs::read_to_string(config.tmp_ca_key()).context("Faeild to read temp ca key")?; - let temp_ca_cert = - fs::read_to_string(config.tmp_ca_cert()).context("Faeild to read temp ca cert")?; + pub fn new(config: KmsConfig, keyring: Keyring, rpc_ca_cert: String) -> Result { + let (_, pem) = x509_parser::pem::parse_x509_pem(rpc_ca_cert.as_bytes()) + .context("invalid RPC CA certificate")?; + let rpc_ca_pubkey = pem + .parse_x509() + .context("invalid RPC CA certificate")? + .public_key() + .raw + .to_vec(); let attestation_verifier = Arc::new( AttestationVerifier::load(&config.attestation) .context("failed to load attestation verifier")?, @@ -154,24 +307,34 @@ impl KmsState { } Ok(Self { inner: Arc::new(KmsStateInner { + temp_ca: crate::onboard_service::ensure_temp_ca(&config)?, config, - root_ca, - k256_key, - temp_ca_cert, - temp_ca_key, + keyring, + rpc_ca_pubkey, + restart: tokio::sync::Notify::new(), verifier, attestation_verifier, self_boot_info: Cache::builder() .max_capacity(1) .time_to_live(SELF_BOOT_INFO_TTL) .build(), - self_allowed: short_lived_cache(), - auth_api_info: short_lived_cache(), + self_allowed: short_lived_cache(4), + auth_api_info: short_lived_cache(1), + app_epochs: short_lived_cache(APP_EPOCH_CACHE_CAPACITY), metrics: KmsMetrics::default(), }), }) } + /// The current epoch of an app, cached for [`AUTH_API_CACHE_TTL`]. Failures + /// are not cached. + async fn app_epoch(&self, app_id: &[u8]) -> Result { + self.app_epochs + .try_get_with(app_id.to_vec(), self.config.auth_api.app_epoch(app_id)) + .await + .map_err(|err| anyhow!("{err:#}")) + } + pub(crate) fn metrics(&self) -> &KmsMetrics { &self.inner.metrics } @@ -179,11 +342,155 @@ impl KmsState { pub(crate) fn attestation_verifier(&self) -> Arc { self.inner.attestation_verifier.clone() } + + fn epoch_root(&self, epoch: u32) -> Result<&RootKeys> { + self.keyring + .roots + .get(epoch as usize) + .with_context(|| format!("root epoch {epoch} is not held by this KMS")) + } + + pub async fn restart_requested(&self) { + self.restart.notified().await + } + + async fn current_epoch_from_info(&self) -> Result { + let info = self + .auth_api_info + .try_get_with((), self.config.auth_api.get_info()) + .await + .map_err(|err| anyhow!("{err:#}"))?; + self.keyring.servable_epoch(info.root_epochs) + } + + /// Resolve this request from its authorization result; no serving epoch is cached. + fn authorized_epoch( + &self, + result: &AuthorizationResult, + requested: Option, + ) -> Result { + if !result.authorized { + bail!("Authorization denied: {}", result.rejection_reason); + } + let current = if self.config.auth_api.is_dev() { + self.keyring.latest() + } else { + let current = result + .current_root()? + .context("Root epoch not registered")?; + self.keyring + .servable_epoch(RootEpochRegistry::Current(current))? + }; + let epoch = requested.unwrap_or(current); + if epoch > current { + bail!("root epoch {epoch} is not active; the current one is {current}"); + } + self.epoch_root(epoch)?; + Ok(epoch) + } + + /// Keep the running TLS issuer consistent with default key responses until reload. + fn ensure_rpc_ca(&self, current_epoch: u32) -> Result<()> { + if self.epoch_root(current_epoch)?.ca.key.public_key_der() != self.rpc_ca_pubkey { + self.restart.notify_one(); + bail!("the RPC root CA changed; retry after the TLS certificate is refreshed"); + } + Ok(()) + } +} + +/// Wait for the registered current roots to issue the RPC certificate at startup. +/// An epoch-0 keyring may start while the info endpoint is unreachable, but +/// key release still requires an authorization result with registered roots. +pub(crate) async fn wait_for_servable_epoch(config: &KmsConfig, keyring: &Keyring) -> u32 { + const RETRY: Duration = Duration::from_secs(10); + loop { + let result = match config.auth_api.get_info().await { + Ok(info) => keyring.servable_epoch(info.root_epochs), + Err(err) if keyring.latest() == 0 => { + warn!("skipping the root epoch check: {err:#}"); + return 0; + } + Err(err) => Err(err.context("failed to read the current root epoch")), + }; + match result { + Ok(epoch) => return epoch, + Err(err) => { + warn!("not serving yet: {err:#}; retrying in {}s", RETRY.as_secs()); + tokio::time::sleep(RETRY).await; + } + } + } +} + +struct AppKeys { + disk_crypt_key: Vec, + env_crypt_key: Vec, + k256_key: Vec, + k256_signature: Vec, + sealed_env_key: Vec, +} + +fn derive_sealed_env_key(root: &RootKeys, app_id: &[u8], app_epoch: u32) -> Result<[u8; 32]> { + ensure_app_id_len(app_id)?; + let ca_secret = p256::SecretKey::from_pkcs8_der(root.ca.key.serialized_der())?; + kdf::derive_key_with_salt( + b"dstack-sealed-env-v1", + &ca_secret.to_bytes(), + &[app_id, &app_epoch_context(app_epoch)], + 32, + ) + .map_err(|_| anyhow!("failed to derive sealed env key"))? + .try_into() + .map_err(|_| anyhow!("invalid sealed env key length")) +} + +fn derive_app_keys( + root: &RootKeys, + app_id: &[u8], + instance_id: &[u8], + app_epoch: u32, +) -> Result { + let disk_crypt_key = kdf::derive_dh_secret( + &root.ca.key, + &[ + app_id, + instance_id, + b"app-disk-crypt-key", + &app_epoch_context(app_epoch), + ], + ) + .context("Failed to derive app disk key")?; + let env_crypt_key = kdf::derive_dh_secret( + &root.ca.key, + &[app_id, b"env-encrypt-key", &app_epoch_context(app_epoch)], + ) + .context("Failed to derive env encrypt key")?; + let env_crypt_key = x25519_dalek::StaticSecret::from(env_crypt_key).to_bytes(); + let (k256_key, k256_signature) = + derive_k256_key(&root.k256, app_id, app_epoch).context("Failed to derive app ecdsa key")?; + let sealed_env_key = derive_sealed_env_key(root, app_id, app_epoch)?.to_vec(); + Ok(AppKeys { + disk_crypt_key: disk_crypt_key.to_vec(), + env_crypt_key: env_crypt_key.to_vec(), + k256_key: k256_key.to_bytes().to_vec(), + k256_signature, + sealed_env_key, + }) +} + +pub(crate) fn read_root_epoch(config: &KmsConfig) -> Result { + match fs::read_to_string(config.root_epoch()) { + Ok(epoch) => epoch.trim().parse().context("invalid root epoch file"), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(err) => Err(err).context("failed to read root epoch file"), + } } pub struct RpcHandler { state: KmsState, attestation: Option, + remote_endpoint: Option, } impl RpcHandler { @@ -191,28 +498,85 @@ impl RpcHandler { Self { state: context.state.clone(), attestation: context.attestation, + remote_endpoint: context.remote_endpoint, } } - pub(crate) async fn handover_keys(self, request: GetKmsKeyRequest) -> Result { - self.ensure_self_allowed() + /// Count a key release and log it, regardless of the metrics setting. + fn audit_key_release( + &self, + release: KeyRelease, + boot_info: &BootInfo, + requested: &str, + returned: &str, + cached: bool, + ) { + self.state.metrics().record_key_release(release); + let instance_id = match &boot_info.instance_id[..] { + [] => "UNKNOWN".to_string(), + id => hex::encode(id), + }; + let (listener, operation) = release.labels(); + info!( + target: "dstack_kms::audit", + listener, + operation, + app_id = %hex::encode(&boot_info.app_id), + instance_id = %instance_id, + remote = ?self.remote_endpoint, + requested_epoch = requested, + returned_epoch = returned, + cached, + "key release" + ); + } + + fn self_allowed_cached(&self, operation: Operation) -> bool { + self.state.self_allowed.contains_key(&operation) + } + + pub(crate) async fn handover_keys( + self, + release: KeyRelease, + request: GetKmsKeyRequest, + ) -> Result { + // reject anonymous callers before the self check reaches the backend + self.ensure_attested()?; + let cached = self.self_allowed_cached(Operation::KmsExportKeys); + self.ensure_self_allowed(Operation::KmsExportKeys) .await .context("KMS self authorization failed")?; let info = self.ensure_kms_allowed(&request.vm_config).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); Ok(KmsKeyResponse { - temp_ca_key: self.state.inner.temp_ca_key.clone(), - keys: vec![KmsKeys { - ca_key: self.state.inner.root_ca.key.serialize_pem(), - k256_key: self.state.inner.k256_key.to_bytes().to_vec(), - }], + temp_ca_key: self.state.temp_ca.key.serialize_pem(), + keys: self + .state + .keyring + .roots + .iter() + .map(|root| KmsKeys { + ca_key: root.ca.key.serialize_pem(), + k256_key: root.k256.to_bytes().to_vec(), + }) + .collect(), }) } } +#[derive(Default)] +struct RequestedAppEpochs { + root: Option, + app: Option, +} + struct BootConfig { boot_info: BootInfo, gateway_app_id: String, + root_epoch: u32, + app_epoch: u32, } pub(crate) fn build_boot_info_for_attestation( @@ -268,20 +632,12 @@ fn ensure_key_release_allowed(boot_info: &BootInfo, policy: KeyReleasePolicy) -> } } -fn ensure_self_key_release_allowed( - self_boot_info: Option<&BootInfo>, - policy: KeyReleasePolicy, -) -> Result<()> { - if let Some(boot_info) = self_boot_info { - ensure_key_release_allowed(boot_info, policy)?; - } - Ok(()) -} - impl RpcHandler { - async fn ensure_self_allowed(&self) -> Result> { + async fn ensure_self_allowed(&self, operation: Operation) -> Result { if !self.state.config.enforce_self_authorization { - return Ok(None); + let epoch = self.state.current_epoch_from_info().await?; + self.state.ensure_rpc_ca(epoch)?; + return Ok(epoch); } let boot_info = self .state @@ -290,24 +646,25 @@ impl RpcHandler { .await .map_err(|err| anyhow!("{err:#}")) .context("Failed to load cached self boot info")?; - self.state + let response = self + .state .self_allowed - .try_get_with((), async { + .try_get_with(operation, async { let response = self .state .config .auth_api - .is_app_allowed(&boot_info, true) + .authorize_kms(&boot_info, operation, None) .await .context("Failed to call self KMS auth check")?; - if !response.is_allowed { - bail!("KMS is not allowed: {}", response.reason); - } - Ok(()) + let epoch = self.state.authorized_epoch(&response, None)?; + self.state.ensure_rpc_ca(epoch)?; + Ok::<_, anyhow::Error>(response) }) .await .map_err(|err| anyhow!("{err:#}"))?; - Ok(Some(boot_info)) + let epoch = self.state.authorized_epoch(&response, None)?; + Ok(epoch) } fn ensure_attested(&self) -> Result<&VerifiedAttestation> { @@ -319,57 +676,94 @@ impl RpcHandler { async fn ensure_kms_allowed(&self, vm_config: &str) -> Result { let att = self.ensure_attested()?; - self.ensure_app_attestation_allowed(att, true, false, vm_config) - .await - .map(|c| c.boot_info) + 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, + Some(self.state.keyring.latest()), + ) + .await?; + self.state.authorized_epoch(&response, None)?; + self.ensure_attestation_policy(att, vm_config, &boot_info, &response) + .await?; + Ok(boot_info) } - async fn ensure_app_boot_allowed(&self, vm_config: &str) -> Result { + async fn ensure_app_boot_allowed( + &self, + vm_config: &str, + epochs: RequestedAppEpochs, + ) -> Result { let att = self.ensure_attested()?; - self.ensure_app_attestation_allowed(att, false, false, vm_config) + 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 } async fn ensure_app_attestation_allowed( &self, att: &VerifiedAttestation, - is_kms: bool, - use_boottime_mr: bool, - vm_config_str: &str, + boot_info: BootInfo, + vm_config: &str, + operation: Operation, + epochs: RequestedAppEpochs, ) -> Result { - let boot_info = build_boot_info_for_attestation(att, use_boottime_mr, vm_config_str)?; let response = self .state .config .auth_api - .is_app_allowed(&boot_info, is_kms) + .authorize_app(&boot_info, operation, epochs.root, epochs.app) .await?; - if !response.is_allowed { - bail!("Boot denied: {}", response.reason); + let root_epoch = self.state.authorized_epoch(&response, epochs.root)?; + let app_epoch = epochs.app.unwrap_or(response.current_app_epoch); + if app_epoch > response.current_app_epoch { + bail!("requested app epoch is not active"); } + self.ensure_attestation_policy(att, vm_config, &boot_info, &response) + .await?; + Ok(BootConfig { + boot_info, + gateway_app_id: response.gateway_app_id, + root_epoch, + app_epoch, + }) + } + + async fn ensure_attestation_policy( + &self, + att: &VerifiedAttestation, + vm_config: &str, + boot_info: &BootInfo, + response: &AuthorizationResult, + ) -> Result<()> { + let current_epoch = if self.state.config.auth_api.is_dev() { + self.state.keyring.latest() + } else { + response.current_root_epoch + }; + self.state.ensure_rpc_ca(current_epoch)?; // SNP rootfs/app/config binding is handled by the SNP launch-measurement // helper above. The legacy OS-image verifier is TDX-oriented and still // rejects SNP quotes; keep SNP on the explicit fail-closed helper path. if boot_info.tee_variant != TeeVariant::DstackAmdSevSnp { self.state .verifier - .verify_os_image_hash( - vm_config_str.into(), - att, - &mut VerificationDetails::default(), - ) + .verify_os_image_hash(vm_config.into(), att, &mut VerificationDetails::default()) .await .context("Failed to verify os image hash")?; } - Ok(BootConfig { - boot_info, - gateway_app_id: response.gateway_app_id, - }) + Ok(()) } - fn derive_app_ca(&self, app_id: &[u8]) -> Result { - let context_data = vec![app_id, b"app-ca"]; - let app_key = kdf::derive_p256_key_pair(&self.state.root_ca.key, &context_data) + fn derive_app_ca(&self, app_id: &[u8], root: &RootKeys, app_epoch: u32) -> Result { + let epoch_context = app_epoch_context(app_epoch); + let context_data = vec![app_id, b"app-ca", epoch_context.as_slice()]; + let app_key = kdf::derive_p256_key_pair(&root.ca.key, &context_data) .context("Failed to derive app disk key")?; let req = CertRequest::builder() .key(&app_key) @@ -379,11 +773,7 @@ impl RpcHandler { .app_id(app_id) .special_usage("app:ca") .build(); - let app_ca = self - .state - .root_ca - .sign(req) - .context("Failed to sign App CA")?; + let app_ca = root.ca.sign(req).context("Failed to sign App CA")?; Ok(CaCert::from_parts(app_key, app_ca)) } } @@ -393,60 +783,72 @@ impl KmsRpc for RpcHandler { if request.api_version > 1 { bail!("Unsupported API version: {}", request.api_version); } - self.ensure_self_allowed() + // reject anonymous callers before the self check reaches the backend + self.ensure_attested()?; + let cached = self.self_allowed_cached(Operation::KmsServe); + self.ensure_self_allowed(Operation::KmsServe) .await .context("KMS self authorization failed")?; let BootConfig { boot_info, gateway_app_id, + root_epoch, + app_epoch, } = self - .ensure_app_boot_allowed(&request.vm_config) + .ensure_app_boot_allowed( + &request.vm_config, + RequestedAppEpochs { + root: request.root_epoch, + app: request.app_epoch, + }, + ) .await .context("App not allowed")?; ensure_key_release_allowed(&boot_info, (&self.state.config).into())?; - let app_id = boot_info.app_id; - let instance_id = boot_info.instance_id; - let os_image_hash = boot_info.os_image_hash; - - let context_data = vec![&app_id[..], &instance_id[..], b"app-disk-crypt-key"]; - let app_disk_key = kdf::derive_dh_secret(&self.state.root_ca.key, &context_data) - .context("Failed to derive app disk key")?; - let env_crypt_key = { - let secret = - kdf::derive_dh_secret(&self.state.root_ca.key, &[&app_id[..], b"env-encrypt-key"]) - .context("Failed to derive env encrypt key")?; - let secret = x25519_dalek::StaticSecret::from(secret); - secret.to_bytes() - }; - - let (k256_key, k256_signature) = { - let (k256_app_key, signature) = derive_k256_key(&self.state.k256_key, &app_id) - .context("Failed to derive app ecdsa key")?; - (k256_app_key.to_bytes().to_vec(), signature) - }; + let root = self.state.epoch_root(root_epoch)?; + let keys = derive_app_keys(root, &boot_info.app_id, &boot_info.instance_id, app_epoch)?; + let epoch = |epoch: Option| epoch.map_or("current".into(), |e| e.to_string()); + self.audit_key_release( + KeyRelease::AppKey, + &boot_info, + &format!( + "root {} app {}", + epoch(request.root_epoch), + epoch(request.app_epoch) + ), + &format!("root {root_epoch} app {app_epoch}"), + cached, + ); Ok(AppKeyResponse { - ca_cert: self.state.root_ca.pem_cert.clone(), - disk_crypt_key: app_disk_key.to_vec(), - env_crypt_key: env_crypt_key.to_vec(), - k256_key, - k256_signature, + ca_cert: root.ca.pem_cert.clone(), + disk_crypt_key: keys.disk_crypt_key, + env_crypt_key: keys.env_crypt_key, + k256_key: keys.k256_key, + k256_signature: keys.k256_signature, tproxy_app_id: gateway_app_id.clone(), gateway_app_id, - os_image_hash, + os_image_hash: boot_info.os_image_hash, + root_epoch, + sealed_env_key: keys.sealed_env_key, + app_epoch, }) } async fn get_app_env_encrypt_pub_key(self, request: AppId) -> Result { - self.ensure_self_allowed() + let epoch = self + .ensure_self_allowed(Operation::KmsServe) .await .context("KMS self authorization failed")?; + let root = self.state.epoch_root(epoch)?; ensure_app_id_len(&request.app_id)?; - let secret = kdf::derive_dh_secret( - &self.state.root_ca.key, - &[&request.app_id[..], "env-encrypt-key".as_bytes()], - ) - .context("Failed to derive env encrypt key")?; + let app_epoch = self.state.app_epoch(&request.app_id).await?; + if app_epoch != 0 { + bail!("epoch-aware applications must use GetSealedEnvPubKey"); + } + let secret = + kdf::derive_dh_secret(&root.ca.key, &[&request.app_id[..], b"env-encrypt-key"]) + .context("Failed to derive env encrypt key")?; let secret = x25519_dalek::StaticSecret::from(secret); let pubkey = x25519_dalek::PublicKey::from(&secret); @@ -458,7 +860,7 @@ impl KmsRpc for RpcHandler { // Legacy signature (without timestamp) for backward compatibility let signature = sign_message( - &self.state.k256_key, + &root.k256, b"dstack-env-encrypt-pubkey", &request.app_id, &public_key, @@ -467,7 +869,7 @@ impl KmsRpc for RpcHandler { // New signature with timestamp to prevent replay attacks let signature_v1 = sign_message_with_timestamp( - &self.state.k256_key, + &root.k256, b"dstack-env-encrypt-pubkey", &request.app_id, timestamp, @@ -483,6 +885,38 @@ impl KmsRpc for RpcHandler { }) } + async fn get_sealed_env_pub_key(self, request: AppId) -> Result { + let epoch = self.ensure_self_allowed(Operation::KmsServe).await?; + let root = self.state.epoch_root(epoch)?; + ensure_app_id_len(&request.app_id)?; + let app_epoch = self.state.app_epoch(&request.app_id).await?; + let secret = derive_sealed_env_key(root, &request.app_id, app_epoch)?; + let public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(secret)).to_bytes(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system time before UNIX epoch")? + .as_secs(); + let mut message = Vec::with_capacity(8 + 32 + 8); + message.extend_from_slice(&epoch.to_be_bytes()); + message.extend_from_slice(&app_epoch.to_be_bytes()); + message.extend_from_slice(&public_key); + message.extend_from_slice(×tamp.to_be_bytes()); + let signature = sign_message( + &root.k256, + b"dstack-sealed-env-pubkey-v1", + &request.app_id, + &message, + )?; + Ok(SealedEnvPubKeyResponse { + public_key: public_key.to_vec(), + root_epoch: epoch, + app_epoch, + timestamp, + signature, + }) + } + async fn get_meta(self) -> Result { let bootstrap_info = fs::read_to_string(self.state.config.bootstrap_info()) .ok() @@ -493,16 +927,12 @@ impl KmsRpc for RpcHandler { .try_get_with((), self.state.config.auth_api.get_info()) .await .map_err(|err| anyhow!("{err:#}"))?; + let epoch = self.state.keyring.servable_epoch(info.root_epochs)?; + let root = self.state.epoch_root(epoch)?; Ok(GetMetaResponse { - ca_cert: self.state.inner.root_ca.pem_cert.clone(), + ca_cert: root.ca.pem_cert.clone(), allow_any_upgrade: self.state.inner.config.auth_api.is_dev(), - k256_pubkey: self - .state - .inner - .k256_key - .verifying_key() - .to_sec1_bytes() - .to_vec(), + k256_pubkey: root.k256.verifying_key().to_sec1_bytes().to_vec(), bootstrap_info, is_dev: self.state.config.auth_api.is_dev(), kms_contract_address: info.kms_contract_address, @@ -515,31 +945,35 @@ impl KmsRpc for RpcHandler { async fn get_kms_key(self, request: GetKmsKeyRequest) -> Result { if !self.state.config.onboard.public_key_handover { - bail!("public KMS key handover is disabled; use the admin listener"); + bail!( + "public KMS key handover is disabled; onboard through the admin listener with \ + the source admin token, or set core.onboard.public_key_handover = true" + ); } - self.handover_keys(request).await + self.handover_keys(KeyRelease::RpcHandover, request).await } - /// Serve the temp CA certificate and key. - /// - /// Retained for older guest images and tokenless KMS onboarding. Current - /// guests use self-issued RA-TLS certificates and fetch the root CA via GetMeta. - /// The returned key authenticates nobody; client identity comes from attestation. async fn get_temp_ca_cert(self) -> Result { - let self_boot_info = self - .ensure_self_allowed() - .await - .context("KMS self authorization failed")?; - ensure_self_key_release_allowed(self_boot_info.as_ref(), (&self.state.config).into())?; + let epoch = self.ensure_self_allowed(Operation::KmsServe).await?; + if self.state.config.enforce_self_authorization { + let boot_info = self + .state + .self_boot_info + .get(&()) + .await + .context("missing authorized KMS boot info")?; + ensure_key_release_allowed(&boot_info, (&self.state.config).into())?; + } Ok(GetTempCaCertResponse { - temp_ca_cert: self.state.inner.temp_ca_cert.clone(), - temp_ca_key: self.state.inner.temp_ca_key.clone(), - ca_cert: self.state.inner.root_ca.pem_cert.clone(), + temp_ca_cert: self.state.temp_ca.pem_cert.clone(), + temp_ca_key: self.state.temp_ca.key.serialize_pem(), + ca_cert: self.state.epoch_root(epoch)?.ca.pem_cert.clone(), }) } async fn sign_cert(self, request: SignCertRequest) -> Result { - self.ensure_self_allowed() + let cached = self.self_allowed_cached(Operation::KmsServe); + self.ensure_self_allowed(Operation::KmsServe) .await .context("KMS self authorization failed")?; let csr = match request.api_version { @@ -566,11 +1000,19 @@ impl KmsRpc for RpcHandler { .verify_with_ra_pubkey(&csr.pubkey, &self.state.attestation_verifier) .await .context("Quote verification failed")?; + let boot_info = build_boot_info_for_attestation(&attestation, true, &request.vm_config)?; let app_info = self - .ensure_app_attestation_allowed(&attestation, false, true, &request.vm_config) + .ensure_app_attestation_allowed( + &attestation, + boot_info, + &request.vm_config, + Operation::AppGetCert, + RequestedAppEpochs::default(), + ) .await?; ensure_key_release_allowed(&app_info.boot_info, (&self.state.config).into())?; - let app_ca = self.derive_app_ca(&app_info.boot_info.app_id)?; + let root = self.state.epoch_root(app_info.root_epoch)?; + let app_ca = self.derive_app_ca(&app_info.boot_info.app_id, root, app_info.app_epoch)?; // Stamp the identity verified above, not the requester's own claims. // Runtime measurements (`false`) are what certificates have always carried. let cert_app_info = attestation @@ -579,11 +1021,18 @@ impl KmsRpc for RpcHandler { let cert = app_ca .sign_csr(&csr, Some(&cert_app_info), "app:custom") .context("Failed to sign certificate")?; + self.audit_key_release( + KeyRelease::AppCert, + &app_info.boot_info, + "current", + &format!("root {} app {}", app_info.root_epoch, app_info.app_epoch), + cached, + ); Ok(SignCertResponse { certificate_chain: vec![ cert.pem(), app_ca.pem_cert.clone(), - self.state.root_ca.pem_cert.clone(), + root.ca.pem_cert.clone(), ], }) } @@ -1171,16 +1620,6 @@ mod tests { .expect("TCB/advisory policy should be decided by the auth API, not this local gate"); } - #[test] - fn snp_self_boot_info_uses_same_release_policy_for_temp_ca() { - let boot_info = snp_boot_info(); - - ensure_self_key_release_allowed(Some(&boot_info), KeyReleasePolicy::default()) - .expect_err("disabled SNP self boot info must not receive temp CA key material"); - ensure_self_key_release_allowed(Some(&boot_info), SNP_ENABLED) - .expect("enabled clean SNP self boot info should pass the temp CA release gate"); - } - #[test] fn nitro_enclave_key_release_requires_explicit_enablement() { let mut boot_info = snp_boot_info(); @@ -1198,13 +1637,6 @@ mod tests { // explicitly enabled permits it ensure_key_release_allowed(&boot_info, NITRO_ENCLAVE_ENABLED) .expect("explicitly enabled nitro enclave key release should allow the boot info"); - - // the temp CA path uses the same gate - ensure_self_key_release_allowed(Some(&boot_info), KeyReleasePolicy::default()).expect_err( - "disabled nitro enclave self boot info must not receive temp CA key material", - ); - ensure_self_key_release_allowed(Some(&boot_info), NITRO_ENCLAVE_ENABLED) - .expect("enabled nitro enclave self boot info should pass the temp CA release gate"); } #[test] @@ -1280,19 +1712,8 @@ mod tests { .build() .self_signed() .unwrap(); - let tmp_ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); - let tmp_ca_cert = CertRequest::builder() - .org_name("Dstack") - .subject("Dstack Client Temp CA") - .ca_level(0) - .key(&tmp_ca_key) - .build() - .self_signed() - .unwrap(); fs::write(cert_dir.join("root-ca.key"), ca_key.serialize_pem()).unwrap(); fs::write(cert_dir.join("root-ca.crt"), ca_cert.pem()).unwrap(); - fs::write(cert_dir.join("tmp-ca.key"), tmp_ca_key.serialize_pem()).unwrap(); - fs::write(cert_dir.join("tmp-ca.crt"), tmp_ca_cert.pem()).unwrap(); fs::write( cert_dir.join("root-k256.key"), SigningKey::random(&mut rand::rngs::OsRng).to_bytes(), @@ -1324,11 +1745,794 @@ mod tests { .focus("core") .extract() .unwrap(); - KmsState::new(config).unwrap() + let keyring = Keyring::load(&config).unwrap(); + KmsState::new(config, keyring, ca_cert.pem()).unwrap() + } + + #[test] + fn sealed_env_derivation_is_separate_and_shared_with_app_keys() { + let dir = tempfile::tempdir().unwrap(); + let state = kms_state(dir.path(), "http://127.0.0.1:1"); + let root = state.epoch_root(0).unwrap(); + let app = [1; 20]; + let sealed = derive_sealed_env_key(root, &app, 0).unwrap(); + let app_keys = derive_app_keys(root, &app, &[2; 20], 0).unwrap(); + assert_eq!(sealed.as_slice(), app_keys.sealed_env_key); + assert_ne!(sealed.as_slice(), app_keys.env_crypt_key); + assert_ne!(sealed.as_slice(), app_keys.disk_crypt_key); + assert_ne!(sealed, derive_sealed_env_key(root, &[3; 20], 0).unwrap()); + assert!(derive_sealed_env_key(root, &[1; 19], 0).is_err()); + } + + #[test] + fn app_epoch_derivation_is_separate_and_epoch_zero_is_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let state = kms_state(dir.path(), "http://127.0.0.1:1"); + let root = state.epoch_root(0).unwrap(); + let app = [1; 20]; + let instance = [2; 20]; + let zero = derive_app_keys(root, &app, &instance, 0).unwrap(); + assert_eq!( + zero.disk_crypt_key, + kdf::derive_dh_secret(&root.ca.key, &[&app, &instance, b"app-disk-crypt-key"]).unwrap() + ); + assert_eq!( + zero.env_crypt_key, + x25519_dalek::StaticSecret::from( + kdf::derive_dh_secret(&root.ca.key, &[&app, b"env-encrypt-key"]).unwrap() + ) + .to_bytes() + ); + for epoch in [1, 2, u32::MAX] { + let rotated = derive_app_keys(root, &app, &instance, epoch).unwrap(); + let again = derive_app_keys(root, &app, &instance, epoch).unwrap(); + assert_eq!(rotated.disk_crypt_key, again.disk_crypt_key); + assert_ne!(zero.disk_crypt_key, rotated.disk_crypt_key); + assert_ne!(zero.env_crypt_key, rotated.env_crypt_key); + assert_ne!(zero.sealed_env_key, rotated.sealed_env_key); + assert_ne!(zero.k256_key, rotated.k256_key); + } + assert_ne!( + derive_app_keys(root, &app, &instance, 1).unwrap().k256_key, + derive_app_keys(root, &app, &instance, 2).unwrap().k256_key + ); + } + + /// The keyring in `config.cert_dir` with a second root epoch appended. + fn rotated_keyring(config: &KmsConfig) -> Keyring { + let mut keyring = Keyring::load(config).unwrap(); + let key = KeyPair::generate_for(&ra_tls::rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let cert = CertRequest::builder() + .key(&key) + .subject("Root epoch 1") + .ca_level(1) + .build() + .self_signed() + .unwrap(); + keyring.roots.push(RootKeys { + ca: CaCert::from_parts(key, cert), + k256: SigningKey::random(&mut rand::rngs::OsRng), + }); + keyring.chain_hashes = + root_chain_hashes(keyring.roots.iter().map(|root| (&root.ca.key, &root.k256))); + keyring + } + + #[rocket::async_test] + async fn authorization_selects_epochs_without_a_serving_epoch_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let initial = kms_state(dir.path(), "http://127.0.0.1:1"); + let keyring = rotated_keyring(&initial.config); + let state = KmsState::new( + initial.config.clone(), + keyring, + initial.keyring.roots[0].ca.pem_cert.clone(), + ) + .unwrap(); + let result = |epoch: u32| AuthorizationResult { + authorized: true, + rejection_reason: String::new(), + gateway_app_id: String::new(), + current_root_epoch: epoch, + current_app_epoch: 0, + current_root_epoch_hash: hex::encode(state.keyring.chain_hashes[epoch as usize]), + latest_root_epoch: epoch, + latest_root_epoch_hash: hex::encode(state.keyring.chain_hashes[epoch as usize]), + }; + assert_eq!(state.authorized_epoch(&result(0), None).unwrap(), 0); + assert!(state.authorized_epoch(&result(0), Some(1)).is_err()); + assert_eq!(state.authorized_epoch(&result(1), None).unwrap(), 1); + assert_eq!(state.authorized_epoch(&result(1), Some(0)).unwrap(), 0); + assert_eq!(state.authorized_epoch(&result(1), Some(1)).unwrap(), 1); + assert!(state.authorized_epoch(&result(1), Some(u32::MAX)).is_err()); + // Key selection already follows epoch 1; only the TLS listener needs refreshing. + assert!(state.ensure_rpc_ca(1).is_err()); + assert!( + tokio::time::timeout(Duration::from_millis(10), state.restart_requested()) + .await + .is_ok() + ); + assert_eq!(state.rpc_ca_pubkey, initial.rpc_ca_pubkey); + + let mut rejected = result(1); + rejected.authorized = false; + rejected.rejection_reason = "Policy denied".into(); + assert!(state.authorized_epoch(&rejected, Some(0)).is_err()); + rejected.authorized = true; + for hash in [ + "invalid".into(), + hex::encode([0; 32]), + hex::encode([0x55; 32]), + ] { + rejected.current_root_epoch_hash = hash; + assert!(state.authorized_epoch(&rejected, None).is_err()); + } + // A rejection is reported even when the epoch fields do not match. + rejected.authorized = false; + let err = state.authorized_epoch(&rejected, None).unwrap_err(); + assert!(err.to_string().contains("Policy denied"), "{err}"); + let mut missing = result(1); + missing.current_root_epoch = u32::MAX; + assert!(state.authorized_epoch(&missing, Some(0)).is_err()); + assert!(state.epoch_root(u32::MAX).is_err()); } const AUTH_API_INFO: &str = r#"{"status":"ok","kmsContractAddr":"0xkms","ethRpcUrl":"https://rpc.example","gatewayAppId":"0xgateway","chainId":1,"appImplementation":"0ximpl"}"#; + #[rocket::async_test] + async fn self_authorization_cache_cannot_authorize_a_different_operation() { + use upgrade_authority::tests::{boot_info, serve}; + let dir = tempfile::tempdir().unwrap(); + let initial = kms_state(dir.path(), "http://127.0.0.1:1"); + let response = |allowed| { + serde_json::json!({ + "authorized": allowed, "rejectionReason": "export denied", "gatewayAppId": "", + "currentRootEpoch": 0, "currentAppEpoch": 0, + "currentRootEpochHash": hex::encode(initial.keyring.chain_hashes[0]), + "latestRootEpoch": 0, + "latestRootEpochHash": hex::encode(initial.keyring.chain_hashes[0]), + }) + .to_string() + }; + let (url, server) = serve(vec![response(true), response(false)]); + let mut config = initial.config.clone(); + config.enforce_self_authorization = true; + config.auth_api = upgrade_authority::tests::webhook(url); + let keyring = Keyring::load(&config).unwrap(); + let state = KmsState::new( + config, + keyring, + initial.epoch_root(0).unwrap().ca.pem_cert.clone(), + ) + .unwrap(); + state.self_boot_info.insert((), boot_info(1)).await; + let handler = RpcHandler { + state, + attestation: None, + remote_endpoint: None, + }; + assert_eq!( + handler + .ensure_self_allowed(Operation::KmsServe) + .await + .unwrap(), + 0 + ); + assert!(handler + .ensure_self_allowed(Operation::KmsExportKeys) + .await + .is_err()); + assert_eq!( + handler + .ensure_self_allowed(Operation::KmsServe) + .await + .unwrap(), + 0 + ); + let requests = server.join().unwrap(); + assert_eq!(requests[0].1["operation"], "kms_serve"); + assert_eq!(requests[1].1["operation"], "kms_export_keys"); + } + + /// An SNP caller, which skips the OS image download in the attestation policy. + fn snp_caller() -> (VerifiedAttestation, String) { + let input = valid_snp_measurement_input(); + let measurement = compute_expected_measurement(&input).unwrap(); + let mr_config = valid_snp_mr_config(); + let vm_config = snp_vm_config(&input, &mr_config); + let attestation = verified_snp_attestation_with_config( + measurement, + [0xab; 64], + vm_config.clone(), + &mr_config, + ); + (attestation, vm_config) + } + + fn auth_answer(state: &KmsState, allowed: bool) -> String { + epoch_answer(&state.keyring, allowed, 0, 0) + } + + fn epoch_answer(keyring: &Keyring, allowed: bool, root_epoch: u32, app_epoch: u32) -> String { + let hash = hex::encode(keyring.chain_hashes[root_epoch as usize]); + serde_json::json!({ + "authorized": allowed, "rejectionReason": "denied", "gatewayAppId": "", + "currentRootEpoch": root_epoch, "currentAppEpoch": app_epoch, + "currentRootEpochHash": hash, + "latestRootEpoch": root_epoch, "latestRootEpochHash": hash, + }) + .to_string() + } + + /// Wait for the auth API requests; a missing request fails instead of hanging. + fn auth_requests( + server: std::thread::JoinHandle>, + ) -> Vec<(String, serde_json::Value)> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(server.join().unwrap())); + rx.recv_timeout(Duration::from_secs(5)) + .expect("an expected auth API request was not made") + } + + #[derive(Clone, Copy)] + struct HandoverCase { + snp_key_release: bool, + public_key_handover: bool, + admin: bool, + } + + const ALLOWED_HANDOVER: HandoverCase = HandoverCase { + snp_key_release: true, + public_key_handover: true, + admin: false, + }; + + /// Run one handover with the auth API answering `answers` (self check, then + /// caller check), and return its + /// result with the requests the auth API received. + async fn handover( + case: HandoverCase, + answers: &[bool], + ) -> ( + Result, + Vec<(String, serde_json::Value)>, + String, + ) { + use upgrade_authority::tests::{boot_info, serve, webhook}; + let dir = tempfile::tempdir().unwrap(); + let initial = kms_state(dir.path(), "http://127.0.0.1:1"); + let answers: Vec<_> = answers + .iter() + .map(|allowed| auth_answer(&initial, *allowed)) + .collect(); + let (url, server) = serve(answers); + let mut config = initial.config.clone(); + config.enforce_self_authorization = true; + config.auth_api = webhook(url); + config.sev_snp_key_release = case.snp_key_release; + config.onboard.public_key_handover = case.public_key_handover; + let keyring = Keyring::load(&config).unwrap(); + let state = KmsState::new( + config, + keyring, + initial.epoch_root(0).unwrap().ca.pem_cert.clone(), + ) + .unwrap(); + state.self_boot_info.insert((), boot_info(1)).await; + let (attestation, vm_config) = snp_caller(); + let handler = RpcHandler { + state: state.clone(), + attestation: Some(attestation), + remote_endpoint: None, + }; + let request = GetKmsKeyRequest { vm_config }; + let result = if case.admin { + handler + .handover_keys(KeyRelease::AdminHandover, request) + .await + } else { + handler.get_kms_key(request).await + }; + ( + result, + auth_requests(server), + state.metrics().render_prometheus(), + ) + } + + #[rocket::async_test] + async fn handover_runs_every_authorization_check() { + let (result, requests, metrics) = handover(ALLOWED_HANDOVER, &[true, true]).await; + assert_eq!(result.unwrap().keys.len(), 1); + let [(self_route, self_check), (caller_route, caller_check)] = &requests[..] else { + panic!("unexpected auth API requests: {requests:?}"); + }; + assert_eq!(self_route, "/authorizeKms"); + assert_eq!(self_check["operation"], "kms_export_keys"); + assert!(self_check.get("requestedRootEpoch").is_none()); + assert_eq!(self_check["appId"], hex::encode([4; 20])); + assert_eq!(caller_route, "/authorizeKms"); + assert_eq!(caller_check["operation"], "kms_import_keys"); + assert_eq!(caller_check["requestedRootEpoch"], 0); + assert_eq!(caller_check["appId"], hex::encode([0x11; 20])); + assert!(metrics.contains(&key_releases("rpc", 1)), "{metrics}"); + assert!(metrics.contains(&key_releases("admin", 0)), "{metrics}"); + + let admin = HandoverCase { + admin: true, + public_key_handover: false, + ..ALLOWED_HANDOVER + }; + let (result, requests, metrics) = handover(admin, &[true, true]).await; + assert!(result.is_ok()); + assert_eq!(requests.len(), 2); + assert!(metrics.contains(&key_releases("admin", 1)), "{metrics}"); + assert!(metrics.contains(&key_releases("rpc", 0)), "{metrics}"); + } + + fn key_releases(listener: &str, count: u64) -> String { + format!( + "dstack_kms_key_releases_total{{listener=\"{listener}\",operation=\"kms_export_keys\"}} {count}\n" + ) + } + + #[rocket::async_test] + async fn handover_is_refused_when_any_check_denies() { + let (result, requests, metrics) = handover(ALLOWED_HANDOVER, &[false]).await; + assert!(format!("{:#}", result.unwrap_err()).contains("KMS self authorization failed")); + assert_eq!(requests[0].1["operation"], "kms_export_keys"); + // A refused handover releases nothing and is not counted. + assert!(metrics.contains(&key_releases("rpc", 0)), "{metrics}"); + + for admin in [false, true] { + let case = HandoverCase { + admin, + ..ALLOWED_HANDOVER + }; + let (result, requests, metrics) = handover(case, &[true, false]).await; + assert!(result.is_err()); + assert_eq!(requests[1].1["operation"], "kms_import_keys"); + let listener = if admin { "admin" } else { "rpc" }; + assert!(metrics.contains(&key_releases(listener, 0)), "{metrics}"); + + let case = HandoverCase { + snp_key_release: false, + admin, + ..ALLOWED_HANDOVER + }; + let (result, _, _) = handover(case, &[true, true]).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("sev-snp key release is not enabled"), "{err}"); + } + + let public_disabled = HandoverCase { + public_key_handover: false, + ..ALLOWED_HANDOVER + }; + let (result, requests, _) = handover(public_disabled, &[]).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("public KMS key handover is disabled"), "{err}"); + assert!(requests.is_empty()); + } + + #[rocket::async_test] + async fn handover_recipient_requires_the_source_to_be_allowed_to_export() { + use upgrade_authority::tests::{serve, webhook}; + let dir = tempfile::tempdir().unwrap(); + let state = kms_state(dir.path(), "http://127.0.0.1:1"); + let (attestation, _) = snp_caller(); + for allowed in [true, false] { + let (url, server) = serve(vec![auth_answer(&state, allowed)]); + let mut config = state.config.clone(); + config.auth_api = webhook(url); + let result = upgrade_authority::ensure_kms_allowed(&config, &attestation, 2).await; + assert_eq!(result.is_ok(), allowed); + let requests = auth_requests(server); + let [(route, check)] = &requests[..] else { + panic!("unexpected auth API requests: {requests:?}"); + }; + assert_eq!(route, "/authorizeKms"); + assert_eq!(check["operation"], "kms_export_keys"); + assert_eq!(check["requestedRootEpoch"], 2); + } + } + + type AuthRequests = std::thread::JoinHandle>; + + /// A KMS holding root epochs 0 and 1 and serving 1, with self authorization + /// enforced. Its auth API reports root epoch 1 and app epoch 1 as current + /// and answers `allowed` to each request in turn (self check first). + async fn rotated_kms( + dir: &Path, + allowed: &[bool], + configure: impl FnOnce(&mut KmsConfig), + ) -> (KmsState, AuthRequests) { + use upgrade_authority::tests::{boot_info, serve, webhook}; + let initial = kms_state(dir, "http://127.0.0.1:1"); + let keyring = rotated_keyring(&initial.config); + let answers = allowed + .iter() + .map(|allowed| epoch_answer(&keyring, *allowed, 1, 1)) + .collect(); + let (url, server) = serve(answers); + let mut config = initial.config.clone(); + config.enforce_self_authorization = true; + config.auth_api = webhook(url); + configure(&mut config); + let rpc_ca = keyring.roots[1].ca.pem_cert.clone(); + let state = KmsState::new(config, keyring, rpc_ca).unwrap(); + state.self_boot_info.insert((), boot_info(1)).await; + (state, server) + } + + fn released(metrics: &str, operation: &str, count: u64) -> bool { + metrics.contains(&format!( + "dstack_kms_key_releases_total{{listener=\"rpc\",operation=\"{operation}\"}} {count}\n" + )) + } + + /// Request an app key from a [`rotated_kms`] as an SNP caller, or anonymously. + async fn rotated_app_key( + allowed: &[bool], + attested: bool, + root_epoch: Option, + app_epoch: Option, + ) -> ( + Result, + Vec<(String, serde_json::Value)>, + KmsState, + BootInfo, + ) { + let dir = tempfile::tempdir().unwrap(); + let (state, server) = rotated_kms(dir.path(), allowed, |config| { + config.sev_snp_key_release = true + }) + .await; + let (attestation, vm_config) = snp_caller(); + let boot_info = build_boot_info_for_attestation(&attestation, false, &vm_config).unwrap(); + let handler = RpcHandler { + state: state.clone(), + attestation: attested.then_some(attestation), + remote_endpoint: None, + }; + let result = handler + .get_app_key(GetAppKeyRequest { + api_version: 1, + vm_config, + root_epoch, + app_epoch, + }) + .await; + (result, auth_requests(server), state, boot_info) + } + + #[rocket::async_test] + async fn get_app_key_derives_from_the_requested_epochs_after_rotation() { + for (requested_root, requested_app, root_epoch, app_epoch) in [ + (None, None, 1, 1), + (Some(0), Some(0), 0, 0), + (Some(0), Some(1), 0, 1), + (Some(1), Some(0), 1, 0), + ] { + let (result, requests, state, caller) = + rotated_app_key(&[true, true], true, requested_root, requested_app).await; + let response = result.unwrap(); + assert_eq!( + (response.root_epoch, response.app_epoch), + (root_epoch, app_epoch) + ); + let root = state.epoch_root(root_epoch).unwrap(); + assert_eq!(response.ca_cert, root.ca.pem_cert); + let expected = + derive_app_keys(root, &caller.app_id, &caller.instance_id, app_epoch).unwrap(); + assert_eq!(response.disk_crypt_key, expected.disk_crypt_key); + assert_eq!(response.sealed_env_key, expected.sealed_env_key); + assert_eq!(response.k256_key, expected.k256_key); + + let [(_, self_check), (route, check)] = &requests[..] else { + panic!("unexpected auth API requests: {requests:?}"); + }; + assert_eq!(self_check["operation"], "kms_serve"); + assert_eq!(route, "/authorizeApp"); + assert_eq!(check["operation"], "app_get_key"); + assert_eq!( + check.get("requestedRootEpoch"), + requested_root.map(Into::into).as_ref() + ); + assert_eq!( + check.get("requestedAppEpoch"), + requested_app.map(Into::into).as_ref() + ); + let metrics = state.metrics().render_prometheus(); + assert!(released(&metrics, "app_get_key", 1), "{metrics}"); + } + } + + #[rocket::async_test] + async fn get_app_key_fails_unless_the_request_is_authorized() { + let (result, requests, state, _) = rotated_app_key(&[true, false], true, None, None).await; + let err = format!("{:#}", result.unwrap_err()); + assert!( + err.contains("App not allowed") && err.contains("denied"), + "{err}" + ); + assert_eq!(requests.len(), 2); + let metrics = state.metrics().render_prometheus(); + assert!(released(&metrics, "app_get_key", 0), "{metrics}"); + + // A backend that authorizes an epoch it reports as inactive is not trusted. + let (result, _, _, _) = rotated_app_key(&[true, true], true, None, Some(2)).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("requested app epoch is not active"), "{err}"); + let (result, _, _, _) = rotated_app_key(&[true, true], true, Some(2), None).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("root epoch 2 is not active"), "{err}"); + + // An anonymous caller is refused before the KMS reaches its backend. + let (result, requests, _, _) = rotated_app_key(&[], false, None, None).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("No attestation provided"), "{err}"); + assert!(requests.is_empty()); + } + + fn pem_public_key(pem: &str) -> Vec { + let (_, pem) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).unwrap(); + pem.parse_x509().unwrap().public_key().raw.to_vec() + } + + /// Sign a certificate on a [`rotated_kms`] for a mock nitro enclave app. + async fn rotated_sign_cert( + allowed: &[bool], + ) -> ( + Result, + Vec<(String, serde_json::Value)>, + KmsState, + ) { + use ra_tls::{ + attestation::{ + Attestation, AttestationQuote, AttestationVerifierConfig, DstackNitroQuote, + QuoteContentType, RootCaPaths, + }, + cert::{CertConfigV2, Csr}, + }; + let dir = tempfile::tempdir().unwrap(); + let attester = mock_attestation::nsm::NsmGenerator::new().unwrap(); + let attestation_root = dir.path().join("nitro-root.pem"); + fs::write(&attestation_root, attester.root_ca_pem()).unwrap(); + let (state, server) = rotated_kms(dir.path(), allowed, |config| { + config.nitro_enclave_key_release = true; + config.attestation = AttestationVerifierConfig { + insecure_allow_external_trust_anchors: true, + root_ca: RootCaPaths { + aws_nitro_enclave: Some(attestation_root), + ..Default::default() + }, + ..Default::default() + }; + }) + .await; + + let key = KeyPair::generate_for(&ra_tls::rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let pubkey = key.public_key_der(); + let report_data = QuoteContentType::RaTlsCert.to_report_data(&pubkey); + let pcrs: BTreeMap> = [0, 1, 2, 4] + .into_iter() + .map(|i| (i, vec![i as u8 + 1; 48])) + .collect(); + let os_image_hash = sha256_chunks(&[&pcrs[&0], &pcrs[&1], &pcrs[&2]]); + let attestation = Attestation { + quote: AttestationQuote::DstackNitroEnclave(DstackNitroQuote { + nsm_quote: attester.attest_with_pcrs(&report_data, pcrs).unwrap(), + }), + runtime_events: vec![runtime_event("app-id", vec![0x22; 20])], + report_data, + config: String::new(), + report: (), + } + .into_versioned(); + let csr = CertSigningRequestV2 { + confirm: "please sign cert:".into(), + pubkey, + config: CertConfigV2 { + org_name: None, + subject: "app.test".into(), + subject_alt_names: vec![], + usage_server_auth: true, + usage_client_auth: false, + ext_quote: false, + ext_app_info: false, + not_before: None, + not_after: None, + }, + attestation, + }; + let request = SignCertRequest { + api_version: 2, + signature: csr.signed_by(&key).unwrap(), + csr: csr.to_vec(), + vm_config: serde_json::json!({ "os_image_hash": hex::encode(os_image_hash) }) + .to_string(), + }; + let result = RpcHandler { + state: state.clone(), + attestation: None, + remote_endpoint: None, + } + .sign_cert(request) + .await; + (result, auth_requests(server), state) + } + + #[rocket::async_test] + async fn sign_cert_issues_from_the_current_epochs_after_rotation() { + let (result, requests, state) = rotated_sign_cert(&[true, true]).await; + let chain = result.unwrap().certificate_chain; + let root = state.epoch_root(1).unwrap(); + assert_eq!(chain[2], root.ca.pem_cert); + let app_ca_key = kdf::derive_p256_key_pair( + &root.ca.key, + &[&[0x22; 20][..], b"app-ca", &app_epoch_context(1)], + ) + .unwrap(); + assert_eq!(pem_public_key(&chain[1]), app_ca_key.public_key_der()); + + let [(_, self_check), (route, check)] = &requests[..] else { + panic!("unexpected auth API requests: {requests:?}"); + }; + assert_eq!(self_check["operation"], "kms_serve"); + assert_eq!(route, "/authorizeApp"); + assert_eq!(check["operation"], "app_get_cert"); + assert!(check.get("requestedRootEpoch").is_none()); + assert!(check.get("requestedAppEpoch").is_none()); + let metrics = state.metrics().render_prometheus(); + assert!(released(&metrics, "app_get_cert", 1), "{metrics}"); + } + + #[rocket::async_test] + async fn sign_cert_fails_when_the_backend_rejects_the_app() { + let (result, requests, state) = rotated_sign_cert(&[true, false]).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("Authorization denied: denied"), "{err}"); + assert_eq!(requests.len(), 2); + let metrics = state.metrics().render_prometheus(); + assert!(released(&metrics, "app_get_cert", 0), "{metrics}"); + + let (result, requests, _) = rotated_sign_cert(&[false]).await; + let err = format!("{:#}", result.unwrap_err()); + assert!(err.contains("KMS self authorization failed"), "{err}"); + assert_eq!(requests.len(), 1); + } + + /// Fetch an env public key while the auth API answers the app epoch query + /// with `status` and `body`. + async fn env_pub_key(sealed: bool, status: u16, body: &'static str) -> Result<()> { + let (url, server) = + upgrade_authority::tests::serve_with_status(vec![(200, AUTH_API_INFO), (status, body)]); + let dir = tempfile::tempdir().unwrap(); + let handler = RpcHandler { + state: kms_state(dir.path(), &url), + attestation: None, + remote_endpoint: None, + }; + let request = AppId { + app_id: vec![0x11; 20], + }; + let result = if sealed { + handler.get_sealed_env_pub_key(request).await.map(drop) + } else { + handler.get_app_env_encrypt_pub_key(request).await.map(drop) + }; + let requests = auth_requests(server); + assert_eq!(requests[1].0, format!("/app/0x{}", "11".repeat(20))); + result + } + + #[rocket::async_test] + async fn env_public_keys_require_the_backend_to_report_the_app_epoch() { + for sealed in [false, true] { + for (status, body) in [ + (404, r#"{"error":"unknown app"}"#), + (503, r#"{"error":"authorization backend unavailable"}"#), + (500, r#"{"status":"error"}"#), + ] { + let err = env_pub_key(sealed, status, body).await.unwrap_err(); + assert!(format!("{err:#}").contains(&status.to_string()), "{err:#}"); + } + // A legacy app at epoch zero still gets its key. + env_pub_key(sealed, 200, r#"{"currentAppEpoch":0}"#) + .await + .unwrap(); + } + } + + #[rocket::async_test] + async fn env_public_keys_at_a_rotated_app_epoch() { + let err = env_pub_key(false, 200, r#"{"currentAppEpoch":1}"#) + .await + .unwrap_err(); + assert!( + format!("{err:#}").contains("must use GetSealedEnvPubKey"), + "{err:#}" + ); + + let (url, server) = + upgrade_authority::tests::serve(vec![AUTH_API_INFO, r#"{"currentAppEpoch":1}"#]); + let dir = tempfile::tempdir().unwrap(); + let state = kms_state(dir.path(), &url); + let app_id = [0x11; 20]; + let response = RpcHandler { + state: state.clone(), + attestation: None, + remote_endpoint: None, + } + .get_sealed_env_pub_key(AppId { + app_id: app_id.to_vec(), + }) + .await + .unwrap(); + auth_requests(server); + assert_eq!((response.root_epoch, response.app_epoch), (0, 1)); + let root = state.epoch_root(0).unwrap(); + let secret = derive_sealed_env_key(root, &app_id, 1).unwrap(); + assert_ne!(secret, derive_sealed_env_key(root, &app_id, 0).unwrap()); + let public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(secret)).to_bytes(); + assert_eq!(response.public_key, public_key); + let message = [ + &0u32.to_be_bytes()[..], + &1u32.to_be_bytes(), + &public_key, + &response.timestamp.to_be_bytes(), + ] + .concat(); + let signature = sign_message( + &root.k256, + b"dstack-sealed-env-pubkey-v1", + &app_id, + &message, + ) + .unwrap(); + // The recoverable signature is deterministic (RFC 6979). + assert_eq!(response.signature, signature); + } + + #[rocket::async_test] + async fn legacy_temp_ca_rpc_returns_a_separate_ca_and_the_served_root() { + assert!(dstack_kms_rpc::kms_server::KMSMethod::from_str("GetTempCaCert").is_some()); + let dir = tempfile::tempdir().unwrap(); + let (url, _) = serve_concurrent_auth_api(AUTH_API_INFO); + let state = kms_state(dir.path(), &url); + let response = RpcHandler { + state: state.clone(), + attestation: None, + remote_endpoint: None, + } + .get_temp_ca_cert() + .await + .unwrap(); + let temp_ca = CaCert::new(response.temp_ca_cert, response.temp_ca_key).unwrap(); + assert_ne!(temp_ca.key.public_key_der(), state.rpc_ca_pubkey); + assert_eq!(response.ca_cert, state.epoch_root(0).unwrap().ca.pem_cert); + } + + #[rocket::async_test] + async fn app_epoch_lookups_are_cached_per_app_for_one_second() { + use std::sync::atomic::Ordering; + let dir = tempfile::tempdir().unwrap(); + let (url, requests) = serve_concurrent_auth_api(r#"{"currentAppEpoch":2}"#); + let state = kms_state(dir.path(), &url); + for _ in 0..3 { + assert_eq!(state.app_epoch(&[0x11; 20]).await.unwrap(), 2); + } + assert_eq!(requests.load(Ordering::SeqCst), 1); + // Another app is looked up separately. + state.app_epoch(&[0x22; 20]).await.unwrap(); + assert_eq!(requests.load(Ordering::SeqCst), 2); + tokio::time::sleep(AUTH_API_CACHE_TTL + Duration::from_millis(100)).await; + state.app_epoch(&[0x11; 20]).await.unwrap(); + assert_eq!(requests.load(Ordering::SeqCst), 3); + } + #[rocket::async_test] async fn concurrent_get_meta_calls_share_one_auth_api_request() { let dir = tempfile::tempdir().unwrap(); @@ -1342,6 +2546,7 @@ mod tests { RpcHandler { state, attestation: None, + remote_endpoint: None, } .get_meta() .await diff --git a/dstack/kms/src/main_service/upgrade_authority.rs b/dstack/kms/src/main_service/upgrade_authority.rs index 79f4a7c23..ac640e820 100644 --- a/dstack/kms/src/main_service/upgrade_authority.rs +++ b/dstack/kms/src/main_service/upgrade_authority.rs @@ -8,13 +8,13 @@ use anyhow::{bail, Context, Result}; use dstack_guest_agent_rpc::v0::{ dstack_guest_client::DstackGuestClient, AttestResponse, RawQuoteArgs, }; +pub(crate) use dstack_types::authorization::AuthorizationOperation as Operation; use http_client::prpc::PrpcClient; use ra_tls::attestation::{AttestationVerifier, VerifiedAttestation, VersionedAttestation}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::time::Duration; -use tracing::warn; /// The KMS `bootAuth` payload. This is the verifier's `PolicyBootInfo` — the one /// canonical struct shared by the producer (KMS) and the policy input the @@ -78,6 +78,7 @@ pub(crate) async fn local_kms_boot_info(verifier: &AttestationVerifier) -> Resul build_boot_info_for_attestation(&verified, false, "") } +#[cfg(test)] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct BootResponse { @@ -86,6 +87,109 @@ pub(crate) struct BootResponse { pub reason: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AuthorizationResult { + pub authorized: bool, + pub rejection_reason: String, + pub gateway_app_id: String, + pub current_root_epoch: u32, + pub current_app_epoch: u32, + pub current_root_epoch_hash: String, + pub latest_root_epoch: u32, + pub latest_root_epoch_hash: String, +} + +impl AuthorizationResult { + pub fn current_root(&self) -> Result> { + root_epoch(self.current_root_epoch, &self.current_root_epoch_hash) + } + + /// The latest registered root epoch, which may be pending activation. + pub fn latest_root(&self) -> Result> { + root_epoch(self.latest_root_epoch, &self.latest_root_epoch_hash) + } +} + +/// A zero chain hash means no epoch is registered. +fn root_epoch(epoch: u32, hash: &str) -> Result> { + let chain_hash: [u8; 32] = hex::decode(hash.trim_start_matches("0x")) + .ok() + .and_then(|hash| hash.try_into().ok()) + .context("invalid authorization root epoch chain hash")?; + if chain_hash == [0; 32] { + return Ok(None); + } + Ok(Some(CurrentRootEpoch { epoch, chain_hash })) +} + +/// The root epoch fields: the KMS sends the epoch it serves in boot checks, +/// and auth backends report the current one in info responses. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RootEpochFields { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_epoch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_epoch_hash: Option, +} + +impl From for RootEpochFields { + fn from(current: CurrentRootEpoch) -> Self { + Self { + root_epoch: Some(current.epoch), + root_epoch_hash: Some(format!("0x{}", hex::encode(current.chain_hash))), + } + } +} + +impl RootEpochFields { + pub fn registry(&self) -> Result { + match (self.root_epoch, &self.root_epoch_hash) { + (None, None) => Ok(RootEpochRegistry::Empty), + (Some(epoch), Some(hash)) => { + let chain_hash = hex::decode(hash.trim_start_matches("0x")) + .ok() + .and_then(|hash| hash.try_into().ok()) + .context("invalid root epoch chain hash")?; + Ok(RootEpochRegistry::Current(CurrentRootEpoch { + epoch, + chain_hash, + })) + } + _ => bail!("the auth API reports a root epoch without its chain hash"), + } + } +} + +/// A root epoch governance registered, with the chain hash committing to the +/// root public keys of epochs 0..=epoch. Usually the current epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CurrentRootEpoch { + pub epoch: u32, + pub chain_hash: [u8; 32], +} + +/// What governance says about root epochs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum RootEpochRegistry { + /// The dev backend: there is nothing to check against. + Unchecked, + /// No epoch is registered, or the backend predates root epochs. + Empty, + Current(CurrentRootEpoch), +} + +/// What governance says about root epochs at a key handover: the current epoch +/// and the latest registered one. Chain hashes are cumulative, so the latest +/// binds every registered epoch, including those pending activation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RootEpochRegistration { + pub registry: RootEpochRegistry, + /// Absent for the dev backend and when nothing is registered. + pub latest: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AuthApiInfoResponse { @@ -96,6 +200,8 @@ pub(crate) struct AuthApiInfoResponse { pub gateway_app_id: String, pub chain_id: u64, pub app_implementation: String, + #[serde(flatten)] + pub root_epoch: RootEpochFields, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -107,6 +213,7 @@ pub(crate) struct GetInfoResponse { pub eth_rpc_url: Option, pub chain_id: Option, pub app_implementation: Option, + pub root_epochs: RootEpochRegistry, } const AUTH_API_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); @@ -167,7 +274,125 @@ async fn send_request(req: reqwest::RequestBuilder, url: &s } impl AuthApi { - pub async fn is_app_allowed(&self, boot_info: &BootInfo, is_kms: bool) -> Result { + pub async fn authorize_kms( + &self, + app_info: &BootInfo, + operation: Operation, + requested_root_epoch: Option, + ) -> Result { + if !operation.is_kms() { + bail!("invalid KMS authorization operation"); + } + self.check_authorization( + app_info, + "authorizeKms", + operation, + requested_root_epoch, + None, + ) + .await + } + + pub async fn authorize_app( + &self, + app_info: &BootInfo, + operation: Operation, + requested_root_epoch: Option, + requested_app_epoch: Option, + ) -> Result { + if operation.is_kms() { + bail!("invalid app authorization operation"); + } + self.check_authorization( + app_info, + "authorizeApp", + operation, + requested_root_epoch, + requested_app_epoch, + ) + .await + } + + async fn check_authorization( + &self, + app_info: &BootInfo, + method: &str, + operation: Operation, + requested_root_epoch: Option, + requested_app_epoch: Option, + ) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct AuthorizationRequest<'a> { + #[serde(flatten)] + app_info: &'a BootInfo, + operation: Operation, + #[serde(skip_serializing_if = "Option::is_none")] + requested_app_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_root_epoch: Option, + } + ensure_identity_widths(app_info)?; + match self { + AuthApi::Dev { dev } => Ok(AuthorizationResult { + authorized: true, + rejection_reason: String::new(), + gateway_app_id: dev.gateway_app_id.clone(), + current_root_epoch: 0, + current_app_epoch: 0, + current_root_epoch_hash: hex::encode([0; 32]), + latest_root_epoch: 0, + latest_root_epoch_hash: hex::encode([0; 32]), + }), + AuthApi::Webhook { webhook } => { + let url = url_join(&webhook.url, method); + http_post( + &url, + &AuthorizationRequest { + app_info, + operation, + requested_app_epoch, + requested_root_epoch, + }, + webhook.timeout, + ) + .await + } + } + } + + pub async fn app_epoch(&self, app_id: &[u8]) -> Result { + ensure_app_id_len(app_id)?; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct AppEpoch { + current_app_epoch: u32, + } + match self { + AuthApi::Dev { .. } => Ok(0), + AuthApi::Webhook { webhook } => { + let url = url_join(&webhook.url, &format!("app/0x{}", hex::encode(app_id))); + let response: AppEpoch = http_get(&url, webhook.timeout).await?; + Ok(response.current_app_epoch) + } + } + } + + #[cfg(test)] + pub async fn is_app_allowed( + &self, + boot_info: &BootInfo, + is_kms: bool, + root_epoch: Option, + ) -> Result { + #[derive(Serialize)] + struct BootAuthRequest<'a> { + #[serde(flatten)] + boot_info: &'a BootInfo, + #[serde(flatten)] + root_epoch: RootEpochFields, + } + ensure_identity_widths(boot_info)?; match self { AuthApi::Dev { dev } => Ok(BootResponse { @@ -182,7 +407,11 @@ impl AuthApi { "bootAuth/app" }; let url = url_join(&webhook.url, path); - http_post(&url, &boot_info, webhook.timeout).await + let request = BootAuthRequest { + boot_info, + root_epoch: root_epoch.map(Into::into).unwrap_or_default(), + }; + http_post(&url, &request, webhook.timeout).await } } } @@ -196,6 +425,7 @@ impl AuthApi { gateway_app_id: Some(dev.gateway_app_id.clone()), chain_id: None, app_implementation: None, + root_epochs: RootEpochRegistry::Unchecked, }), AuthApi::Webhook { webhook } => { let info: AuthApiInfoResponse = http_get(&webhook.url, webhook.timeout).await?; @@ -211,6 +441,7 @@ impl AuthApi { chain_id: Some(info.chain_id), gateway_app_id: Some(info.gateway_app_id.clone()), app_implementation: Some(info.app_implementation.clone()), + root_epochs: info.root_epoch.registry()?, }) } } @@ -255,11 +486,21 @@ pub(crate) async fn ensure_self_kms_allowed( .context("failed to build local KMS boot info")?; let response = cfg .auth_api - .is_app_allowed(&boot_info, true) + .authorize_kms(&boot_info, Operation::KmsBootstrap, None) .await - .context("failed to call KMS auth check")?; - if !response.is_allowed { - bail!("boot denied: {}", response.reason); + .context("failed to call KMS bootstrap authorization")?; + if !response.authorized { + bail!("boot denied: {}", response.rejection_reason); + } + Ok(()) +} + +/// A source must present its OS image hash. KMS releases before 0.6 issue +/// certificates without a vm_config, and so without one; they cannot be joined +/// directly. Upgrade such a cluster to 0.6 first. +fn ensure_source_os_image(boot_info: &BootInfo) -> Result<()> { + if boot_info.os_image_hash.is_empty() { + bail!("the source KMS certificate does not carry its OS image hash; onboard from a v0.6+ source"); } Ok(()) } @@ -267,35 +508,40 @@ pub(crate) async fn ensure_self_kms_allowed( pub(crate) async fn ensure_kms_allowed( cfg: &KmsConfig, attestation: &VerifiedAttestation, - verifier: &AttestationVerifier, -) -> Result<()> { - let mut boot_info = build_boot_info_for_attestation(attestation, false, "") + root_epoch: u32, +) -> Result { + let boot_info = build_boot_info_for_attestation(attestation, false, "") .context("failed to build KMS boot info from attestation")?; - // Workaround: old source KMS instances use the legacy cert format (separate TDX_QUOTE + - // EVENT_LOG OIDs) which lacks vm_config, resulting in an empty os_image_hash. - // Fill it from the local KMS's own value, so only mrAggregated gates the source image here. - // A source that presents a config but no os_image_hash is not legacy and gets no fallback. - // TODO: remove once all source KMS instances use the unified PHALA_RATLS_ATTESTATION format. - if boot_info.os_image_hash.is_empty() && attestation.config.is_empty() { - warn!("source KMS uses the legacy cert format, falling back to the local os_image_hash"); - let local_info = local_kms_boot_info(verifier) - .await - .context("failed to get local KMS boot info for os_image_hash fallback")?; - boot_info.os_image_hash = local_info.os_image_hash; - } + ensure_source_os_image(&boot_info)?; let response = cfg .auth_api - .is_app_allowed(&boot_info, true) + .authorize_kms(&boot_info, Operation::KmsExportKeys, Some(root_epoch)) .await - .context("failed to call KMS auth check")?; - if !response.is_allowed { - bail!("boot denied: {}", response.reason); + .context("failed to authorize source KMS key export")?; + if !response.authorized { + bail!("boot denied: {}", response.rejection_reason); } - Ok(()) + if cfg.auth_api.is_dev() { + return Ok(RootEpochRegistration { + registry: RootEpochRegistry::Unchecked, + latest: None, + }); + } + let current = response + .current_root()? + .context("root epoch not registered")?; + let latest = response + .latest_root()? + .filter(|latest| latest.epoch >= current.epoch) + .context("the auth API reports no latest root epoch at or after the current one")?; + Ok(RootEpochRegistration { + registry: RootEpochRegistry::Current(current), + latest: Some(latest), + }) } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::config::Webhook; use ra_tls::attestation::TeeVariant; @@ -304,7 +550,7 @@ mod tests { use std::net::TcpListener; use std::thread; - fn boot_info(identity: u8) -> BootInfo { + pub(crate) fn boot_info(identity: u8) -> BootInfo { BootInfo { tee_variant: TeeVariant::DstackTdx, mr_aggregated: vec![identity; 32], @@ -320,15 +566,25 @@ mod tests { } } - fn serve>( + pub(crate) fn serve>( responses: Vec, ) -> (String, thread::JoinHandle>) { - let responses: Vec = responses.into_iter().map(Into::into).collect(); + serve_with_status(responses.into_iter().map(|body| (200, body)).collect()) + } + + /// Like [`serve`], answering each request with its own HTTP status. + pub(crate) fn serve_with_status>( + responses: Vec<(u16, S)>, + ) -> (String, thread::JoinHandle>) { + let responses: Vec<(u16, String)> = responses + .into_iter() + .map(|(status, body)| (status, body.into())) + .collect(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let handle = thread::spawn(move || { let mut requests = Vec::new(); - for response in responses { + for (status, response) in responses { let (mut stream, _) = listener.accept().unwrap(); let mut request = Vec::new(); let mut chunk = [0u8; 4096]; @@ -364,13 +620,16 @@ mod tests { .nth(1) .unwrap() .to_string(); - let body = - serde_json::from_slice(&request[header_end..header_end + content_length]) - .unwrap(); + let body = &request[header_end..header_end + content_length]; + let body = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(body).unwrap() + }; requests.push((path, body)); write!( stream, - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response ) @@ -381,7 +640,22 @@ mod tests { (format!("http://{address}"), handle) } - fn webhook(url: String) -> AuthApi { + /// Joins and rotations share this check, so both refuse such a source. + #[test] + fn a_source_certificate_without_an_os_image_hash_is_refused() { + let legacy = BootInfo { + os_image_hash: vec![], + ..boot_info(1) + }; + let err = ensure_source_os_image(&legacy).unwrap_err(); + assert!( + err.to_string().contains("onboard from a v0.6+ source"), + "{err:#}" + ); + ensure_source_os_image(&boot_info(1)).unwrap(); + } + + pub(crate) fn webhook(url: String) -> AuthApi { AuthApi::Webhook { webhook: Webhook { url, @@ -436,7 +710,7 @@ mod tests { let mut aliasing_compose_hash = boot_info(1); aliasing_compose_hash.compose_hash.truncate(31); let err = auth - .is_app_allowed(&aliasing_compose_hash, false) + .is_app_allowed(&aliasing_compose_hash, false, None) .await .unwrap_err(); assert!(err.to_string().contains("compose_hash must be 32 bytes")); @@ -444,7 +718,7 @@ mod tests { let mut aliasing_instance_id = boot_info(1); aliasing_instance_id.instance_id.truncate(19); let err = auth - .is_app_allowed(&aliasing_instance_id, false) + .is_app_allowed(&aliasing_instance_id, false, None) .await .unwrap_err(); assert!(err @@ -455,7 +729,7 @@ mod tests { let mut no_instance_id = boot_info(1); no_instance_id.instance_id.clear(); assert!( - auth.is_app_allowed(&no_instance_id, false) + auth.is_app_allowed(&no_instance_id, false, None) .await .unwrap() .is_allowed @@ -472,8 +746,8 @@ mod tests { let auth = webhook(url); let info = boot_info(1); - let first = auth.is_app_allowed(&info, false).await.unwrap(); - let repeated = auth.is_app_allowed(&info, false).await.unwrap(); + let first = auth.is_app_allowed(&info, false, None).await.unwrap(); + let repeated = auth.is_app_allowed(&info, false, None).await.unwrap(); assert!(first.is_allowed); assert!(!repeated.is_allowed); assert_eq!(repeated.reason, "revoked"); @@ -495,8 +769,18 @@ mod tests { let app = boot_info(10); let kms = boot_info(20); - assert!(auth.is_app_allowed(&app, false).await.unwrap().is_allowed); - assert!(auth.is_app_allowed(&kms, true).await.unwrap().is_allowed); + assert!( + auth.is_app_allowed(&app, false, None) + .await + .unwrap() + .is_allowed + ); + assert!( + auth.is_app_allowed(&kms, true, None) + .await + .unwrap() + .is_allowed + ); let requests = server.join().unwrap(); assert_eq!(requests[0].0, "/bootAuth/app"); @@ -509,6 +793,195 @@ mod tests { assert_ne!(requests[0].1["mrAggregated"], requests[1].1["mrAggregated"]); } + #[rocket::async_test] + async fn authorization_operations_are_typed_and_sent_without_legacy_fallback() { + let operations = [ + Operation::KmsBootstrap, + Operation::KmsServe, + Operation::KmsExportKeys, + Operation::KmsImportKeys, + Operation::AppGetKey, + Operation::AppGetCert, + ]; + let response = r#"{"authorized":true,"rejectionReason":"","gatewayAppId":"","currentRootEpoch":0,"currentAppEpoch":0,"currentRootEpochHash":"0000000000000000000000000000000000000000000000000000000000000000","latestRootEpoch":0,"latestRootEpochHash":"0000000000000000000000000000000000000000000000000000000000000000"}"#; + let (url, server) = serve(vec![response; operations.len()]); + let auth = webhook(url); + for operation in operations { + if operation.is_kms() { + auth.authorize_kms(&boot_info(1), operation, Some(0)) + .await + .unwrap(); + } else { + auth.authorize_app(&boot_info(1), operation, None, None) + .await + .unwrap(); + } + } + 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() { + "/authorizeKms" + } else { + "/authorizeApp" + } + ); + assert_eq!( + serde_json::from_value::(body["operation"].clone()).unwrap(), + operation + ); + } + assert!(serde_json::from_str::(r#""unknown""#).is_err()); + } + + #[rocket::async_test] + async fn authorization_returns_the_epoch_with_each_decision() { + let response = |allowed: bool, epoch: u32| { + format!( + r#"{{"authorized":{allowed},"rejectionReason":"","gatewayAppId":"gw", "currentRootEpoch":{epoch},"currentAppEpoch":0,"currentRootEpochHash":"0x{}","latestRootEpoch":{latest},"latestRootEpochHash":"0x{}"}}"#, + "11".repeat(32), + "22".repeat(32), + latest = epoch + 1, + ) + }; + let (url, server) = serve(vec![ + response(true, 0), + response(false, 1), + response(true, 1), + response(true, 1), + response(false, 1), + ]); + let auth = webhook(url); + let info = boot_info(50); + let first = auth + .authorize_app(&info, Operation::AppGetKey, None, None) + .await + .unwrap(); + let second = auth + .authorize_app(&info, Operation::AppGetKey, None, None) + .await + .unwrap(); + assert!(first.authorized); + assert!(!second.authorized); + assert_eq!(first.current_root().unwrap().unwrap().epoch, 0); + 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)) + .await + .unwrap(); + auth.authorize_kms(&info, Operation::KmsServe, None) + .await + .unwrap(); + assert!( + !auth + .authorize_kms(&info, Operation::KmsImportKeys, Some(2)) + .await + .unwrap() + .authorized + ); + let requests = server.join().unwrap(); + 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); + assert_eq!(requests[2].1["requestedAppEpoch"], 0); + assert!(requests[0].1.get("requestedRootEpoch").is_none()); + assert_eq!(requests[2].1["requestedRootEpoch"], 0); + } + + #[test] + fn authorization_requires_the_latest_root_epoch_fields() { + let base = serde_json::json!({ + "authorized": true, "rejectionReason": "", "gatewayAppId": "", + "currentRootEpoch": 0, "currentAppEpoch": 0, + "currentRootEpochHash": format!("0x{}", "11".repeat(32)), + }); + assert!(serde_json::from_value::(base.clone()).is_err()); + let mut unregistered = base.clone(); + unregistered["latestRootEpoch"] = 0.into(); + unregistered["latestRootEpochHash"] = format!("0x{}", "00".repeat(32)).into(); + let result: AuthorizationResult = serde_json::from_value(unregistered).unwrap(); + assert_eq!(result.latest_root().unwrap(), None); + let mut invalid = base; + invalid["latestRootEpoch"] = 0.into(); + invalid["latestRootEpochHash"] = "0x11".into(); + let result: AuthorizationResult = serde_json::from_value(invalid).unwrap(); + assert!(result.latest_root().is_err()); + } + + #[rocket::async_test] + async fn authorization_without_epoch_fields_is_not_accepted_as_legacy() { + let (url, server) = serve(vec![ + r#"{"isAllowed":true,"reason":"","gatewayAppId":"gw"}"#, + ]); + assert!(webhook(url) + .authorize_app(&boot_info(50), Operation::AppGetKey, None, None) + .await + .is_err()); + assert_eq!(server.join().unwrap().len(), 1); + } + + #[rocket::async_test] + async fn root_epochs_are_read_from_info_and_sent_in_boot_checks() { + let hash = "0x".to_string() + &"11".repeat(32); + let (url, server) = serve(vec![ + format!( + r#"{{"status":"ok","kmsContractAddr":"0x00","gatewayAppId":"gw","chainId":1, + "appImplementation":"0x00","rootEpoch":3,"rootEpochHash":"{hash}"}}"# + ), + r#"{"status":"ok","kmsContractAddr":"0x00","gatewayAppId":"gw","chainId":1, + "appImplementation":"0x00","rootEpoch":3}"# + .into(), + r#"{"isAllowed":true,"gatewayAppId":"gw","reason":""}"#.into(), + r#"{"isAllowed":true,"gatewayAppId":"gw","reason":""}"#.into(), + ]); + let auth = webhook(url); + let current = CurrentRootEpoch { + epoch: 3, + chain_hash: [0x11; 32], + }; + + assert_eq!( + auth.get_info().await.unwrap().root_epochs, + RootEpochRegistry::Current(current) + ); + assert!(auth.get_info().await.is_err()); + let info = boot_info(50); + auth.is_app_allowed(&info, false, Some(current)) + .await + .unwrap(); + auth.is_app_allowed(&info, true, None).await.unwrap(); + + let requests = server.join().unwrap(); + assert_eq!(requests[2].1["rootEpoch"], 3); + assert_eq!(requests[2].1["rootEpochHash"], hash); + assert_eq!(requests[2].1["appId"], requests[3].1["appId"]); + assert!(requests[3].1.get("rootEpoch").is_none()); + } + #[rocket::async_test] async fn malformed_backend_response_fails_closed_and_next_request_recovers() { let (url, server) = serve(vec![ @@ -518,9 +991,9 @@ mod tests { let auth = webhook(url); let info = boot_info(30); - let error = auth.is_app_allowed(&info, false).await.unwrap_err(); + let error = auth.is_app_allowed(&info, false, None).await.unwrap_err(); assert!(error.to_string().contains("failed to decode response")); - let recovered = auth.is_app_allowed(&info, false).await.unwrap(); + let recovered = auth.is_app_allowed(&info, false, None).await.unwrap(); assert!(recovered.is_allowed); assert_eq!(recovered.reason, "recovered"); assert_eq!(server.join().unwrap().len(), 2); @@ -537,7 +1010,7 @@ mod tests { let (url, server) = serve(vec![body]); let error = webhook(url) - .is_app_allowed(&boot_info(40), false) + .is_app_allowed(&boot_info(40), false, None) .await .unwrap_err(); diff --git a/dstack/kms/src/onboard_service.rs b/dstack/kms/src/onboard_service.rs index 480f9b62a..281cbb2bb 100644 --- a/dstack/kms/src/onboard_service.rs +++ b/dstack/kms/src/onboard_service.rs @@ -36,9 +36,10 @@ use tracing::info; use crate::{ config::KmsConfig, main_service::{ - build_boot_info_for_attestation, + build_boot_info_for_attestation, root_chain_hashes, servable_epoch, upgrade_authority::{ app_attest, dstack_client, ensure_kms_allowed, ensure_self_kms_allowed, pad64, + RootEpochRegistration, RootEpochRegistry, }, }, }; @@ -146,16 +147,7 @@ impl OnboardRpc for OnboardHandler { let keys = Keys::generate(&request.domain, self.state.config.attest_rpc_cert) .await .context("Failed to generate keys")?; - - let k256_pubkey = keys.k256_key.verifying_key().to_sec1_bytes().to_vec(); - let ca_pubkey = keys.ca_key.public_key_der(); - let attestation = attest_keys(&ca_pubkey, &k256_pubkey).await?; - - let response = BootstrapResponse { - ca_pubkey, - k256_pubkey, - attestation, - }; + let response = keys.attest().await?; // Store the bootstrap info safe_write(cfg.bootstrap_info(), serde_json::to_vec(&response)?)?; keys.store(cfg)?; @@ -180,13 +172,29 @@ impl OnboardRpc for OnboardHandler { &source_url, &request.source_token, &request.domain, + request.rotate, self.state.attestation_verifier.clone(), ) .await .context("Failed to onboard")?; - let k256_pubkey = keys.k256_key.verifying_key().to_sec1_bytes().to_vec(); + if !request.rotate { + let k256_pubkey = keys.k256_key.verifying_key().to_sec1_bytes().to_vec(); + keys.store(cfg).context("Failed to store keys")?; + return Ok(OnboardResponse { + k256_pubkey, + ..Default::default() + }); + } + // The attestation over the new epoch is what governance registers, so + // it is persisted with the keys and stays readable via GetSetupStatus. + let info = keys.attest().await?; + safe_write(cfg.bootstrap_info(), serde_json::to_vec(&info)?)?; keys.store(cfg).context("Failed to store keys")?; - Ok(OnboardResponse { k256_pubkey }) + Ok(OnboardResponse { + k256_pubkey: info.k256_pubkey, + ca_pubkey: info.ca_pubkey, + attestation: info.attestation, + }) } async fn get_attestation_info(self) -> Result { @@ -287,6 +295,8 @@ mod tests { use crate::main_service::amd_attest::{ compute_expected_measurement, MeasurementInput, OvmfSectionParam, }; + use crate::main_service::upgrade_authority::CurrentRootEpoch; + use dstack_kms_rpc::KmsKeys; use sha2::Digest; fn hex_of(byte: u8, len: usize) -> String { @@ -536,6 +546,354 @@ mod tests { assert!(status.attestation.is_empty()); } + fn handover(epochs: u32) -> KmsKeyResponse { + KmsKeyResponse { + temp_ca_key: String::new(), + keys: (0..epochs) + .map(|_| KmsKeys { + ca_key: KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .unwrap() + .serialize_pem(), + k256_key: SigningKey::random(&mut rand::rngs::OsRng) + .to_bytes() + .to_vec(), + }) + .collect(), + } + } + + /// Epoch `epoch` of `res` as governance would register it. + fn epoch_of(res: &KmsKeyResponse, epoch: u32) -> CurrentRootEpoch { + let roots: Vec<_> = res + .keys + .iter() + .map(|k| { + ( + KeyPair::from_pem(&k.ca_key).unwrap().public_key_der(), + SigningKey::from_slice(&k.k256_key) + .unwrap() + .verifying_key() + .to_sec1_bytes() + .to_vec(), + ) + }) + .collect(); + let hashes = dstack_types::root_epoch::chain_hashes( + roots.iter().map(|(ca, k256)| (&ca[..], &k256[..])), + ); + CurrentRootEpoch { + epoch, + chain_hash: hashes[epoch as usize], + } + } + + /// The registry a correct handover of `res` would be checked against. + fn current_of(res: &KmsKeyResponse, epoch: u32) -> RootEpochRegistry { + RootEpochRegistry::Current(epoch_of(res, epoch)) + } + + /// Governance with `current` active and every epoch `res` holds registered. + fn registered(res: &KmsKeyResponse, current: u32) -> RootEpochRegistration { + registered_up_to(res, current, res.keys.len() as u32 - 1) + } + + fn registered_up_to(res: &KmsKeyResponse, current: u32, latest: u32) -> RootEpochRegistration { + RootEpochRegistration { + registry: current_of(res, current), + latest: Some(epoch_of(res, latest)), + } + } + + fn unregistered(registry: RootEpochRegistry) -> RootEpochRegistration { + RootEpochRegistration { + registry, + latest: None, + } + } + + async fn onboard( + cfg: &KmsConfig, + res: KmsKeyResponse, + rotate: bool, + registration: RootEpochRegistration, + ) -> Result { + Keys::from_key_response(cfg, res, "kms.example.com", rotate, registration).await + } + + #[test] + fn generated_keys_report_data_matches_the_published_format() { + let report_data = generated_keys_report_data(&[0xa0; 3], &[0xb0; 3]); + // keccak256("dstack-kms-genereted-keys-v1:a0a0a0;b0b0b0;"), zero-padded to 64 bytes. + assert_eq!( + hex::encode(&report_data), + format!( + "01597f74286b5a55b295f839d5b63946462681c7b07ab93b679262fc10946580{}", + "00".repeat(32) + ) + ); + assert_ne!(generated_keys_report_data(&[], &[0xb0; 3]), report_data); + assert_ne!(generated_keys_report_data(&[0xa0; 3], &[]), report_data); + } + + #[test] + fn compatibility_ca_survives_restart_and_recovers_a_missing_certificate() { + let dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(dir.path()); + let first = ensure_temp_ca(&cfg).unwrap(); + let second = ensure_temp_ca(&cfg).unwrap(); + assert_eq!(first.key.public_key_der(), second.key.public_key_der()); + assert_eq!(first.pem_cert, second.pem_cert); + fs::remove_file(cfg.tmp_ca_cert()).unwrap(); + let recovered = ensure_temp_ca(&cfg).unwrap(); + assert_eq!(first.key.public_key_der(), recovered.key.public_key_der()); + assert!(!cfg.root_keys_exist()); + } + + #[rocket::async_test] + async fn join_imports_and_stores_every_epoch() { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + let res = handover(3); + let sent: Vec<_> = res.keys.iter().map(|k| k.k256_key.clone()).collect(); + + let keys = onboard(&cfg, res, false, unregistered(RootEpochRegistry::Unchecked)) + .await + .unwrap(); + keys.store(&cfg).unwrap(); + assert!(cfg.tmp_ca_key().exists()); + assert!(cfg.tmp_ca_cert().exists()); + + assert_eq!(crate::main_service::read_root_epoch(&cfg).unwrap(), 2); + for (epoch, k256) in (0..).zip(&sent) { + assert_eq!( + &fs::read(cfg.root_key_paths(epoch, 2).k256_key).unwrap(), + k256 + ); + } + assert_eq!(&fs::read(cfg.k256_key()).unwrap(), &sent[2]); + } + + #[rocket::async_test] + async fn rotation_imports_the_keyring_and_generates_the_next_epoch() { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + let res = handover(2); + let registration = registered(&res, 1); + let sent: Vec<_> = res.keys.iter().map(|k| k.k256_key.clone()).collect(); + + let keys = onboard(&cfg, res, true, registration).await.unwrap(); + assert_eq!(keys.epoch, 2); + keys.store(&cfg).unwrap(); + + assert_eq!( + fs::read(cfg.root_key_paths(1, 2).k256_key).unwrap(), + sent[1] + ); + let current = fs::read(cfg.k256_key()).unwrap(); + assert!(!sent.contains(¤t)); + } + + #[rocket::async_test] + async fn onboarding_checks_the_handover_against_the_current_epoch() { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + let res = handover(2); + let other = handover(2); + + // A join may hold an epoch pending activation beyond the current one. + assert!(onboard(&cfg, res.clone(), false, registered(&res, 1)) + .await + .is_ok()); + assert!(onboard(&cfg, res.clone(), false, registered(&res, 0)) + .await + .is_ok()); + // A rotation must start at the current epoch. + assert!(onboard(&cfg, res.clone(), true, registered(&res, 0)) + .await + .is_err()); + // A keyring other than the registered one. + assert!(onboard(&cfg, res.clone(), false, registered(&other, 1)) + .await + .is_err()); + assert!(onboard(&cfg, res.clone(), true, registered(&other, 1)) + .await + .is_err()); + // A source behind the current epoch. + assert!(onboard(&cfg, handover(1), false, registered(&res, 1)) + .await + .is_err()); + // A missing epoch shifts the rest, so the chain hash no longer matches. + let mut gap = res.clone(); + gap.keys.remove(0); + assert!(onboard(&cfg, gap, false, registered(&res, 0)) + .await + .is_err()); + assert!(onboard( + &cfg, + handover(0), + false, + unregistered(RootEpochRegistry::Unchecked) + ) + .await + .is_err()); + } + + #[rocket::async_test] + async fn onboarding_binds_the_pending_tail_to_the_latest_registered_epoch() { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + let res = handover(3); + // A registered epoch pending activation imports before activation. + let keys = onboard(&cfg, res.clone(), false, registered(&res, 1)) + .await + .unwrap(); + assert_eq!(keys.epoch, 2); + // A tail nobody registered is refused. + let error = onboard(&cfg, res.clone(), false, registered_up_to(&res, 1, 1)) + .await + .err() + .unwrap(); + assert!( + format!("{error:#}").contains("source KMS holds unregistered root epochs"), + "{error:#}" + ); + // A tail other than the registered one: same current epoch, different epoch 2. + let mut forked = handover(3); + forked.keys[..2].clone_from_slice(&res.keys[..2]); + let error = onboard(&cfg, forked, false, registered(&res, 1)) + .await + .err() + .unwrap(); + assert!( + format!("{error:#}").contains("are not the registered ones"), + "{error:#}" + ); + // A source behind the latest registered epoch still serves the current one. + let mut behind = res.clone(); + behind.keys.pop(); + assert!( + onboard(&cfg, behind.clone(), false, registered_up_to(&res, 1, 2)) + .await + .is_ok() + ); + // But not behind the current epoch. + assert!(onboard(&cfg, behind, false, registered_up_to(&res, 2, 2)) + .await + .is_err()); + // A tail past the current epoch that stops short of the latest one cannot be + // checked, so a forked epoch 2 is refused while epoch 3 is registered. + let longer = handover(4); + let mut partial = handover(3); + partial.keys[..2].clone_from_slice(&longer.keys[..2]); + let error = onboard(&cfg, partial, false, registered_up_to(&longer, 1, 3)) + .await + .err() + .unwrap(); + assert!( + format!("{error:#}").contains("only epoch 3 can be checked"), + "{error:#}" + ); + } + + #[rocket::async_test] + async fn rotation_refuses_a_registered_epoch_pending_activation() { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + let res = handover(3); + let mut current = res.clone(); + current.keys.pop(); + // The locally generated epoch would collide with the registered epoch 2. + let error = onboard(&cfg, current.clone(), true, registered_up_to(&res, 1, 2)) + .await + .err() + .unwrap(); + assert!( + format!("{error:#}").contains("activate it before rotating"), + "{error:#}" + ); + assert_eq!( + onboard(&cfg, current.clone(), true, registered(¤t, 1)) + .await + .unwrap() + .epoch, + 2 + ); + } + + #[rocket::async_test] + async fn an_unregistered_deployment_can_only_join_at_epoch_0() { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + assert!(onboard( + &cfg, + handover(1), + false, + unregistered(RootEpochRegistry::Empty) + ) + .await + .is_ok()); + assert!(onboard( + &cfg, + handover(2), + false, + unregistered(RootEpochRegistry::Empty) + ) + .await + .is_err()); + assert!(onboard( + &cfg, + handover(1), + true, + unregistered(RootEpochRegistry::Empty) + ) + .await + .is_err()); + } + + /// A stored keyring at `latest`, loaded back. + async fn stored_keyring(epochs: u32) -> (tempfile::TempDir, KmsConfig, KmsKeyResponse) { + let cert_dir = tempfile::tempdir().unwrap(); + let cfg = auto_bootstrap_config(cert_dir.path()); + let res = handover(epochs); + onboard( + &cfg, + res.clone(), + false, + unregistered(RootEpochRegistry::Unchecked), + ) + .await + .unwrap() + .store(&cfg) + .unwrap(); + (cert_dir, cfg, res) + } + + #[rocket::async_test] + async fn a_keyring_serves_the_current_epoch_it_holds() { + use crate::main_service::Keyring; + + let (_dir, cfg, res) = stored_keyring(3).await; + let keyring = Keyring::load(&cfg).unwrap(); + assert_eq!(keyring.latest(), 2); + assert_eq!(keyring.servable_epoch(current_of(&res, 1)).unwrap(), 1); + assert_eq!(keyring.servable_epoch(current_of(&res, 2)).unwrap(), 2); + assert_eq!( + keyring + .servable_epoch(RootEpochRegistry::Unchecked) + .unwrap(), + 2 + ); + assert!(keyring.servable_epoch(RootEpochRegistry::Empty).is_err()); + assert!(keyring.servable_epoch(current_of(&handover(3), 1)).is_err()); + let mut ahead = handover(4); + ahead.keys[..3].clone_from_slice(&res.keys); + assert!(keyring.servable_epoch(current_of(&ahead, 3)).is_err()); + + let (_dir, cfg, _) = stored_keyring(1).await; + let keyring = Keyring::load(&cfg).unwrap(); + assert_eq!(keyring.servable_epoch(RootEpochRegistry::Empty).unwrap(), 0); + } + #[rocket::async_test] async fn setup_status_includes_bootstrap_info_when_present() { let cert_dir = tempfile::tempdir().unwrap(); @@ -607,10 +965,29 @@ mod tests { } } +/// Root keys of an epoch older than the current one. +struct HistoricalRoot { + ca_key: KeyPair, + ca_cert: Certificate, + k256_key: SigningKey, +} + +impl HistoricalRoot { + fn new(ca_key: KeyPair, k256_key: SigningKey) -> Result { + let ca_cert = self_signed_root_ca(&ca_key)?; + Ok(Self { + ca_key, + ca_cert, + k256_key, + }) + } +} + struct Keys { + /// Epoch of the root keys below; `history` holds epochs `0..epoch`. + epoch: u32, + history: Vec, k256_key: SigningKey, - tmp_ca_key: KeyPair, - tmp_ca_cert: Certificate, ca_key: KeyPair, ca_cert: Certificate, rpc_key: KeyPair, @@ -620,45 +997,32 @@ struct Keys { impl Keys { async fn generate(domain: &str, attest_rpc_cert: bool) -> Result { - let tmp_ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; let rpc_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; let k256_key = SigningKey::random(&mut rand::rngs::OsRng); - Self::from_keys( - tmp_ca_key, - ca_key, - rpc_key, - k256_key, - domain, - attest_rpc_cert, - ) - .await + Self::from_keys(ca_key, rpc_key, k256_key, domain, attest_rpc_cert).await + } + + /// Attest the current root public keys. + async fn attest(&self) -> Result { + let ca_pubkey = self.ca_key.public_key_der(); + let k256_pubkey = self.k256_key.verifying_key().to_sec1_bytes().to_vec(); + let attestation = attest_keys(&ca_pubkey, &k256_pubkey).await?; + Ok(BootstrapResponse { + ca_pubkey, + k256_pubkey, + attestation, + }) } async fn from_keys( - tmp_ca_key: KeyPair, ca_key: KeyPair, rpc_key: KeyPair, k256_key: SigningKey, domain: &str, attest_rpc_cert: bool, ) -> Result { - let tmp_ca_cert = CertRequest::builder() - .org_name("Dstack") - .subject("Dstack Client Temp CA") - .ca_level(0) - .key(&tmp_ca_key) - .build() - .self_signed()?; - - // Create self-signed KMS cert - let ca_cert = CertRequest::builder() - .org_name("Dstack") - .subject("Dstack KMS CA") - .ca_level(1) - .key(&ca_key) - .build() - .self_signed()?; + let ca_cert = self_signed_root_ca(&ca_key)?; // The only place the KMS embeds its own attestation. Skipping it lets // the KMS run outside a TEE for development; it does not affect the // verification of quotes presented *to* the KMS, which is a separate @@ -689,9 +1053,9 @@ impl Keys { .build() .signed_by(&ca_cert, &ca_key)?; Ok(Keys { + epoch: 0, + history: Vec::new(), k256_key, - tmp_ca_key, - tmp_ca_cert, ca_key, ca_cert, rpc_key, @@ -705,6 +1069,7 @@ impl Keys { other_kms_url: &str, source_token: &str, domain: &str, + rotate: bool, attestation_verifier: Arc, ) -> Result { let attestation_slot = Arc::new(Mutex::new(None::)); @@ -730,17 +1095,14 @@ impl Keys { .context("Missing source KMS attestation") }; - // With a token, `other_kms_url` is the source's admin listener, which accepts - // a self-issued RA-TLS client certificate. Without one, the source predates - // 0.6.0: it serves `KMS.GetKmsKey` publicly and pins its temp CA. - let tmp_ca = if source_token.is_empty() { + let temp_ca = if source_token.is_empty() { let client = RaClient::new(other_kms_url.to_string(), true)?; - let tmp_ca = KmsClient::new(client).get_temp_ca_cert().await?; - Some((tmp_ca.temp_ca_cert, tmp_ca.temp_ca_key)) + let ca = KmsClient::new(client).get_temp_ca_cert().await?; + Some((ca.temp_ca_cert, ca.temp_ca_key)) } else { None }; - let (ra_cert, ra_key) = gen_ra_cert(tmp_ca).await?; + let (ra_cert, ra_key) = gen_ra_cert(temp_ca).await?; let client = RaClientConfig::builder() .tls_skip_ca_verification(true) .tls_built_in_root_certs(false) @@ -763,52 +1125,83 @@ impl Keys { }; // The attestation is captured only on the connection that returned the keys; // nothing received is used before the source passes this check. - ensure_kms_allowed(cfg, &source_attestation()?, &attestation_verifier) + let highest = keys_res + .keys + .len() + .checked_sub(1) + .context("source KMS handed over no keys")?; + let highest = u32::try_from(highest).context("too many root epochs")?; + let registration = ensure_kms_allowed(cfg, &source_attestation()?, highest) .await - .context("Source KMS is not allowed for onboarding")?; - Self::from_key_response(cfg, keys_res, domain).await + .context("source KMS is not allowed for onboarding")?; + Self::from_key_response(cfg, keys_res, domain, rotate, registration).await } + /// Import the source's whole keyring, epochs 0..=latest; a rotation then + /// generates epoch latest+1. async fn from_key_response( cfg: &KmsConfig, keys_res: KmsKeyResponse, domain: &str, + rotate: bool, + registration: RootEpochRegistration, ) -> Result { - if keys_res.keys.len() != 1 { - return Err(anyhow::anyhow!("Invalid keys")); - } - let keys = keys_res.keys[0].clone(); - let tmp_ca_key_pem = keys_res.temp_ca_key; - let root_ca_key_pem = keys.ca_key; - let root_k256_key = keys.k256_key; + let latest = keys_res + .keys + .len() + .checked_sub(1) + .context("source KMS handed over no keys")? as u32; + let mut roots = keys_res + .keys + .into_iter() + .map(|keys| { + let ca_key = KeyPair::from_pem(&keys.ca_key).context("Failed to parse CA key")?; + let k256_key = + SigningKey::from_slice(&keys.k256_key).context("Failed to parse ECDSA key")?; + Ok((ca_key, k256_key)) + }) + .collect::>>()?; + ensure_handover_registered(&roots, registration, rotate)?; + let epoch = if rotate { latest + 1 } else { latest }; + let (ca_key, k256_key) = if rotate { + ( + KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?, + SigningKey::random(&mut rand::rngs::OsRng), + ) + } else { + roots.pop().context("source KMS handed over no keys")? + }; + let history = roots + .into_iter() + .map(|(ca_key, k256_key)| HistoricalRoot::new(ca_key, k256_key)) + .collect::>>()?; let rpc_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; - let ca_key = KeyPair::from_pem(&root_ca_key_pem).context("Failed to parse CA key")?; - let tmp_ca_key = - KeyPair::from_pem(&tmp_ca_key_pem).context("Failed to parse tmp CA key")?; - let ecdsa_key = - SigningKey::from_slice(&root_k256_key).context("Failed to parse ECDSA key")?; - Self::from_keys( - tmp_ca_key, - ca_key, - rpc_key, - ecdsa_key, - domain, - cfg.attest_rpc_cert, - ) - .await + let keys = Self::from_keys(ca_key, rpc_key, k256_key, domain, cfg.attest_rpc_cert).await?; + Ok(Self { + epoch, + history, + ..keys + }) } /// The root keys go last: see [`KmsConfig::root_keys_exist`]. fn store(&self, cfg: &KmsConfig) -> Result<()> { + ensure_temp_ca(cfg)?; + for (epoch, root) in (0..).zip(&self.history) { + let paths = cfg.root_key_paths(epoch, self.epoch); + safe_write(&paths.ca_cert, root.ca_cert.pem())?; + safe_write_with_mode(&paths.ca_key, root.ca_key.serialize_pem(), 0o600)?; + safe_write_with_mode(&paths.k256_key, root.k256_key.to_bytes(), 0o600)?; + } self.store_certs(cfg)?; safe_write(cfg.rpc_domain(), self.rpc_domain.as_bytes())?; + safe_write(cfg.root_epoch(), self.epoch.to_string())?; self.store_keys(cfg)?; Ok(()) } fn store_keys(&self, cfg: &KmsConfig) -> Result<()> { - safe_write_with_mode(cfg.tmp_ca_key(), self.tmp_ca_key.serialize_pem(), 0o600)?; safe_write_with_mode(cfg.rpc_key(), self.rpc_key.serialize_pem(), 0o600)?; safe_write_with_mode(cfg.root_ca_key(), self.ca_key.serialize_pem(), 0o600)?; safe_write_with_mode(cfg.k256_key(), self.k256_key.to_bytes(), 0o600)?; @@ -816,21 +1209,52 @@ impl Keys { } fn store_certs(&self, cfg: &KmsConfig) -> Result<()> { - safe_write(cfg.tmp_ca_cert(), self.tmp_ca_cert.pem())?; safe_write(cfg.root_ca_cert(), self.ca_cert.pem())?; safe_write(cfg.rpc_cert(), self.rpc_cert.pem())?; Ok(()) } } -pub(crate) async fn update_certs(cfg: &KmsConfig) -> Result<()> { +/// Keep the public compatibility CA independent of all epoch root keys. +pub(crate) fn ensure_temp_ca(cfg: &KmsConfig) -> Result { + let had_key = cfg.tmp_ca_key().exists(); + let key = if had_key { + KeyPair::from_pem(&fs::read_to_string(cfg.tmp_ca_key())?)? + } else { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; + safe_write_with_mode(cfg.tmp_ca_key(), key.serialize_pem(), 0o600)?; + key + }; + let cert = CertRequest::builder() + .org_name("Dstack") + .subject("Dstack Client Temp CA") + .ca_level(0) + .key(&key) + .build() + .self_signed()?; + if had_key && cfg.tmp_ca_cert().exists() { + renew_ca_cert_if_expiring( + cfg.tmp_ca_cert(), + cert.pem(), + "temporary client CA certificate", + )?; + } else { + safe_write(cfg.tmp_ca_cert(), cert.pem())?; + } + CaCert::load(cfg.tmp_ca_cert(), cfg.tmp_ca_key()) +} + +/// Reissue the RPC certificate under the root CA of the served `epoch`, so +/// guests reach this KMS through the CA of the keys it serves. +pub(crate) async fn update_certs(cfg: &KmsConfig, epoch: u32, latest: u32) -> Result<()> { + ensure_temp_ca(cfg)?; + let roots = cfg.root_key_paths(epoch, latest); // Read existing keys - let tmp_ca_key = KeyPair::from_pem(&fs::read_to_string(cfg.tmp_ca_key())?)?; - let ca_key = KeyPair::from_pem(&fs::read_to_string(cfg.root_ca_key())?)?; + let ca_key = KeyPair::from_pem(&fs::read_to_string(&roots.ca_key)?)?; let rpc_key = KeyPair::from_pem(&fs::read_to_string(cfg.rpc_key())?)?; // Read k256 key - let k256_key_bytes = fs::read(cfg.k256_key())?; + let k256_key_bytes = fs::read(&roots.k256_key)?; let k256_key = SigningKey::from_slice(&k256_key_bytes)?; let domain = if cfg.onboard.auto_bootstrap_domain.is_empty() { @@ -841,28 +1265,15 @@ pub(crate) async fn update_certs(cfg: &KmsConfig) -> Result<()> { let domain = domain.trim(); // Regenerate certificates using existing keys - let keys = Keys::from_keys( - tmp_ca_key, - ca_key, - rpc_key, - k256_key, - domain, - cfg.attest_rpc_cert, - ) - .await - .context("Failed to regenerate certificates")?; + let keys = Keys::from_keys(ca_key, rpc_key, k256_key, domain, cfg.attest_rpc_cert) + .await + .context("Failed to regenerate certificates")?; renew_ca_cert_if_expiring( - cfg.root_ca_cert(), + &roots.ca_cert, keys.ca_cert.pem(), "KMS root CA certificate", )?; - renew_ca_cert_if_expiring( - cfg.tmp_ca_cert(), - keys.tmp_ca_cert.pem(), - "temporary client CA certificate", - )?; - // The RPC leaf depends on the refreshed domain and platform attestation, so // it is reissued on every startup. safe_write(cfg.rpc_cert(), keys.rpc_cert.pem())?; @@ -922,14 +1333,88 @@ pub(crate) async fn bootstrap_keys(cfg: &KmsConfig, verifier: &AttestationVerifi Ok(()) } +/// Check the handed-over roots against governance: they must include the +/// current epoch with a matching chain hash, and nothing beyond the latest +/// registered epoch. When the source holds the latest registered epoch, its +/// cumulative chain hash binds the pending tail as well. A rotation must start +/// from the current epoch with nothing pending activation, or it would collide +/// with the next registered epoch. +fn ensure_handover_registered( + roots: &[(KeyPair, SigningKey)], + registration: RootEpochRegistration, + rotate: bool, +) -> Result<()> { + let RootEpochRegistration { registry, latest } = registration; + if rotate && registry == RootEpochRegistry::Empty { + bail!("register root epoch 0 before rotating"); + } + let hashes = root_chain_hashes(roots.iter().map(|(ca, k256)| (ca, k256))); + let current = servable_epoch(&hashes, registry)?; + let held = roots.len() as u32 - 1; + if let Some(latest) = latest { + if held > latest.epoch { + bail!( + "source KMS holds unregistered root epochs (up to {held}, registered up to {}); \ + register them first or onboard from another source", + latest.epoch + ); + } + // Only the latest registered epoch's hash binds epochs past the current one. + if held > current && held < latest.epoch { + bail!( + "source KMS holds root epochs up to {held}, past the current {current}, but only \ + epoch {} can be checked; onboard from a source holding every registered epoch", + latest.epoch + ); + } + if held == latest.epoch && hashes[held as usize] != latest.chain_hash { + bail!( + "the source root keys up to epoch {held} are not the registered ones; \ + onboard from another source" + ); + } + if rotate && latest.epoch != current { + bail!( + "root epoch {} is registered but {current} is current; activate it before rotating", + latest.epoch + ); + } + } + if rotate && current != held { + bail!( + "the source holds root epochs up to {held}, but {current} is current; rotate from \ + a source at the current epoch with no epoch pending activation" + ); + } + Ok(()) +} + +fn self_signed_root_ca(ca_key: &KeyPair) -> Result { + CertRequest::builder() + .org_name("Dstack") + .subject("Dstack KMS CA") + .ca_level(1) + .key(ca_key) + .build() + .self_signed() +} + +/// Report data binding freshly generated root public keys, for every epoch. The +/// evidence only proves the keys were generated in an approved TEE; the epoch +/// number and preceding chain come from the on-chain registration. The +/// "genereted" spelling is part of the released format. +fn generated_keys_report_data(p256_pubkey: &[u8], k256_pubkey: &[u8]) -> Vec { + let content = format!( + "dstack-kms-genereted-keys-v1:{};{};", + hex::encode(p256_pubkey), + hex::encode(k256_pubkey) + ); + pad64(keccak256(content.as_bytes())) +} + async fn attest_keys(p256_pubkey: &[u8], k256_pubkey: &[u8]) -> Result> { - let p256_hex = hex::encode(p256_pubkey); - let k256_hex = hex::encode(k256_pubkey); - let content_to_quote = format!("dstack-kms-genereted-keys-v1:{p256_hex};{k256_hex};"); - let hash = keccak256(content_to_quote.as_bytes()); - let report_data = pad64(hash); - let res = app_attest(report_data).await?; - Ok(res.attestation) + let report_data = generated_keys_report_data(p256_pubkey, k256_pubkey); + Ok(app_attest(report_data).await?.attestation) } fn keccak256(msg: &[u8]) -> [u8; 32] { @@ -939,7 +1424,7 @@ fn keccak256(msg: &[u8]) -> [u8; 32] { hasher.finalize().into() } -/// Mint an RA-TLS client certificate, signed by `ca` (cert and key PEM) or self-issued. +/// Legacy sources require a temp-CA-issued client certificate. async fn gen_ra_cert(ca: Option<(String, String)>) -> Result<(String, String)> { use ra_tls::cert::CertRequest; use ra_tls::rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256}; @@ -953,14 +1438,15 @@ async fn gen_ra_cert(ca: Option<(String, String)>) -> Result<(String, String)> { let attestation = VersionedAttestation::from_bytes(&response.attestation).context("Invalid attestation")?; let req = CertRequest::builder() - .subject("RA-TLS TEMP Cert") + .subject("RA-TLS Client Cert") .attestation(&attestation) .key(&key) + .usage_client_auth(true) .build(); let cert = match ca { - Some((cert_pem, key_pem)) => CaCert::new(cert_pem, key_pem)?.sign(req), + Some((cert, key)) => CaCert::new(cert, key)?.sign(req), None => req.self_signed(), } - .context("Failed to sign certificate")?; + .context("failed to sign client certificate")?; Ok((cert.pem(), key.serialize_pem())) } diff --git a/dstack/kms/src/www/onboard.html b/dstack/kms/src/www/onboard.html index 4794cf724..a5d428d0b 100644 --- a/dstack/kms/src/www/onboard.html +++ b/dstack/kms/src/www/onboard.html @@ -257,9 +257,16 @@

Onboard from an Existing KMS Instance

placeholder="RPC URL of the existing KMS">
- + + autocomplete="off" + placeholder="Without it, the source needs core.onboard.public_key_handover = true"> +
+
+
@@ -310,6 +317,7 @@

Onboard from an Existing KMS Instance

sourceUrl: '', sourceToken: '', onboardDomain: '', + rotate: false, error: '', success: '', result: '', @@ -350,7 +358,7 @@

Onboard from an Existing KMS Instance

return; } if (data.attestation) { - this.success = 'Bootstrap successful!'; + this.success = 'New root keys generated!'; this.result = JSON.stringify({ caPubkey: '0x' + data.ca_pubkey, k256Pubkey: '0x' + data.k256_pubkey, @@ -391,7 +399,8 @@

Onboard from an Existing KMS Instance

const data = await rpcCall('Onboard', { source_url: this.sourceUrl + "/prpc", source_token: this.sourceToken, - domain: this.onboardDomain + domain: this.onboardDomain, + rotate: this.rotate }); if (data.error) throw new Error(data.error); diff --git a/dstack/ra-rpc/src/ratls_client_verifier.rs b/dstack/ra-rpc/src/ratls_client_verifier.rs index d3c467808..f886aac5b 100644 --- a/dstack/ra-rpc/src/ratls_client_verifier.rs +++ b/dstack/ra-rpc/src/ratls_client_verifier.rs @@ -61,7 +61,7 @@ impl ClientCertVerifier for RaTlsClientVerifier { /// Anonymous connections stay allowed, matching the previous /// `[rpc.tls.mutual] mandatory = false`. Handlers that need an attested caller - /// enforce it themselves; the unauthenticated RPCs (`GetMeta`, `GetTempCaCert`) + /// enforce it themselves; the unauthenticated RPCs (such as `GetMeta`) /// are reachable without a certificate by design. fn client_auth_mandatory(&self) -> bool { false diff --git a/dstack/tests/docs/kms-bootstrap-onboard.md b/dstack/tests/docs/kms-bootstrap-onboard.md index f9f4d3234..a9efdde88 100644 --- a/dstack/tests/docs/kms-bootstrap-onboard.md +++ b/dstack/tests/docs/kms-bootstrap-onboard.md @@ -34,8 +34,8 @@ For a deeper authorization-focused runbook, also see: ```text Host / operator machine -├── auth-simple-src (policy for source KMS) -├── auth-simple-dst (policy for destination KMS) +├── dstack-auth-src (policy for source KMS) +├── dstack-auth-dst (policy for destination KMS) ├── kms-src (bootstrapped first) └── kms-dst (onboarded from kms-src) ``` @@ -60,14 +60,14 @@ Before starting, make sure the following are available: 1. a KMS image or branch containing the code under test 2. a working teepod / dstack-vmm target 3. routable HTTPS entrypoints for onboard and runtime RPC -4. `curl`, `jq`, Python 3, and `bun` -5. an auth service such as `kms/auth-simple`, or an equivalent webhook +4. `curl`, `jq`, and Python 3 +5. an auth service such as `dstack-auth` (`cargo build --release -p dstack-auth`), or an equivalent webhook Recommended references: - `docs/tutorials/kms-cvm-deployment.md` - `docs/tutorials/troubleshooting-kms-deployment.md` -- `kms/auth-simple/README.md` +- `docs/dstack-auth-operations.md` - `tests/docs/kms-self-authorization.md` Operational notes: @@ -76,7 +76,7 @@ Operational notes: 2. `Boot Progress: done` does **not** guarantee the onboard endpoint is ready. 3. The onboarding completion endpoint is **GET `/finish`**. 4. On teepod with gateway, onboard mode usually uses the `-8000` URL, while runtime TLS KMS RPC usually uses the `-8000s` URL. **Port forwarding** (`--port tcp:0.0.0.0::8000`) is simpler than gateway for testing, because gateway requires the auth API to return a `gatewayAppId` at boot time. -5. If you use a very small custom webhook instead of the real auth service, `KMS.GetMeta` may fail because `auth_api.get_info()` expects extra chain / contract metadata fields. In that case, use `GetTempCaCert` as the runtime readiness probe. +5. If you use a very small custom webhook instead of the real auth service, `KMS.GetMeta` may fail because `auth_api.get_info()` expects extra chain / contract metadata fields. In that case, use `GetAppEnvEncryptPubKey` as the runtime readiness probe. 6. dstack CVMs use QEMU user-mode networking — the host is reachable at **`10.0.2.2`** from inside the CVM. The `source_url` in `Onboard.Onboard` must use a CVM-reachable address (e.g., `https://10.0.2.2:/prpc`), not `127.0.0.1`. 7. **~~Remote KMS attestation has an empty `osImageHash`.~~** Fixed: RA-TLS certs now use the unified `PHALA_RATLS_ATTESTATION` format which preserves `vm_config`. For old source KMS instances, the receiver-side check fills `osImageHash` from the local KMS's own value automatically. No special `"0x"` entry in `osImages` is needed anymore. @@ -107,7 +107,7 @@ They can be: At minimum, both policies must allow the KMS instance they serve. During onboard, source-side policy must also allow the destination KMS caller. -For `auth-simple`, `kms.mrAggregated = []` is a deny-all policy for KMS. Add the current KMS MR values explicitly when switching a test from deny to allow. +For `dstack-auth`, `kms.mrAggregated = []` is a deny-all policy for KMS. Add the current KMS MR values explicitly when switching a test from deny to allow. You no longer need `"0x"` in the `osImages` array — the receiver-side check now resolves `osImageHash` automatically. @@ -119,6 +119,9 @@ Deploy both KMS instances in onboard mode with: - `core.onboard.auto_bootstrap_domain = ""` - `core.auth_api.type = "webhook"` +`kms-dst` onboards through the public `KMS.GetKmsKey` without a `source_token`, +so `kms-src` also needs `core.onboard.public_key_handover = true`. + Record: ```bash @@ -205,7 +208,7 @@ curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetMeta?json" \ - `k256_pubkey` - `bootstrap_info` -If `KMS.GetMeta` fails because your minimal webhook does not return chain / contract info, use `GetTempCaCert` below as the runtime readiness probe instead. +If `KMS.GetMeta` fails because your minimal webhook does not return chain / contract info, use `GetAppEnvEncryptPubKey` below as the runtime readiness probe instead. --- @@ -273,24 +276,25 @@ This section folds the runtime trusted-RPC verification into the same flow. | bootstrap deny | source-side auth leaves `kms.mrAggregated` empty or omits the current `kms-src` MR | `Onboard.Bootstrap` on `kms-src` | `KMS is not allowed to bootstrap`, `MR aggregated not allowed` | | onboard deny (receiver-side) | destination-side auth leaves `kms.mrAggregated` empty or omits the current `kms-src` MR | `Onboard.Onboard` on `kms-dst` | source KMS not allowed / onboarding failed | | onboard deny (source-side) | source-side auth leaves `kms.mrAggregated` empty or omits the current `kms-dst` MR | `Onboard.Onboard` on `kms-dst` | source rejected destination caller / `GetKmsKey` authorization failed | -| runtime deny | auth removes the running KMS from `kms.mrAggregated` | `GetTempCaCert` or another trusted RPC | `KMS self authorization failed`, `KMS is not allowed` | +| runtime deny | auth removes the running KMS from `kms.mrAggregated` | `GetAppEnvEncryptPubKey` or another trusted RPC | `KMS self authorization failed`, `KMS is not allowed` | Use the happy-path steps below first, then flip policies one by one and rerun the indicated probe. -### 7.1 Minimum canary: `GetTempCaCert` +### 7.1 Minimum canary: `GetAppEnvEncryptPubKey` ```bash -curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetTempCaCert?json" \ - | tee /tmp/kms-bootstrap-onboard/kms-src-get-temp-ca.json | jq . +curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetAppEnvEncryptPubKey?json" \ + -H 'Content-Type: application/json' -d '{"app_id":"1111111111111111111111111111111111111111"}' \ + | tee /tmp/kms-bootstrap-onboard/kms-src-get-env-pubkey.json | jq . ``` Expected result: - success - response contains: - - `temp_ca_cert` - - `temp_ca_key` - - `ca_cert` + - `public_key` + - `signature_v1` + - `timestamp` ### 7.2 `GetKmsKey` @@ -332,8 +336,9 @@ Expected result: After a normal happy-path run, flip source-side auth policy to deny `kms-src` itself and retry: ```bash -curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetTempCaCert?json" \ - | tee /tmp/kms-bootstrap-onboard/kms-src-get-temp-ca-after-deny.json | jq . +curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetAppEnvEncryptPubKey?json" \ + -H 'Content-Type: application/json' -d '{"app_id":"1111111111111111111111111111111111111111"}' \ + | tee /tmp/kms-bootstrap-onboard/kms-src-get-env-pubkey-after-deny.json | jq . ``` Expected result: @@ -405,8 +410,9 @@ Then restore both source-side and destination-side allowlists and rerun the norm After a successful bootstrap or onboard, remove the running KMS's own MR from `kms.mrAggregated` and retry: ```bash -curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetTempCaCert?json" \ - | tee /tmp/kms-bootstrap-onboard/kms-src-get-temp-ca-denied.json | jq . +curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetAppEnvEncryptPubKey?json" \ + -H 'Content-Type: application/json' -d '{"app_id":"1111111111111111111111111111111111111111"}' \ + | tee /tmp/kms-bootstrap-onboard/kms-src-get-env-pubkey-denied.json | jq . ``` Expected result: @@ -414,7 +420,7 @@ Expected result: - response contains `.error` - error indicates KMS self authorization failed or the KMS is not allowed -You can repeat the same check on `kms-dst` after onboard by removing `kms-dst` from destination-side policy and retrying `KMS.GetTempCaCert`. +You can repeat the same check on `kms-dst` after onboard by removing `kms-dst` from destination-side policy and retrying `KMS.GetAppEnvEncryptPubKey`. --- @@ -427,8 +433,8 @@ For each run, save: 3. onboard response 4. `/finish` responses 5. runtime `KMS.GetMeta` responses -6. trusted RPC responses such as `GetTempCaCert` -7. deny-case responses such as `kms-src-bootstrap-denied.json`, `kms-dst-onboard-denied.json`, `kms-dst-onboard-denied-by-src.json`, and `kms-src-get-temp-ca-denied.json` +6. trusted RPC responses such as `GetAppEnvEncryptPubKey` +7. deny-case responses such as `kms-src-bootstrap-denied.json`, `kms-dst-onboard-denied.json`, `kms-dst-onboard-denied-by-src.json`, and `kms-src-get-env-pubkey-denied.json` 8. auth policy snapshots used during the run Recommended archive: @@ -447,8 +453,8 @@ The flow is considered validated if all of the following are true: 2. `kms-src` transitions to runtime mode successfully 3. `kms-dst` onboard succeeds against `kms-src` 4. `kms-dst` transitions to runtime mode successfully -5. runtime metadata probes succeed on both KMS instances, or `GetTempCaCert` succeeds when `GetMeta` is unavailable with a minimal webhook -6. at least one trusted runtime RPC such as `GetTempCaCert` succeeds +5. runtime metadata probes succeed on both KMS instances, or `GetAppEnvEncryptPubKey` succeeds when `GetMeta` is unavailable with a minimal webhook +6. at least one trusted runtime RPC such as `GetAppEnvEncryptPubKey` succeeds 7. the selected deny cases fail at the expected RPC with an authorization error --- diff --git a/dstack/tests/docs/kms-self-authorization.md b/dstack/tests/docs/kms-self-authorization.md index d9e365fd9..19efe2039 100644 --- a/dstack/tests/docs/kms-self-authorization.md +++ b/dstack/tests/docs/kms-self-authorization.md @@ -6,14 +6,14 @@ The goal is to validate the following behaviors without depending on `kms/e2e/` 1. **Bootstrap self-check**: a KMS must call the auth API and verify that **itself** is allowed before bootstrap succeeds. 2. **Onboard receiver-side source check**: a new KMS must reject onboarding if the **source KMS** is not allowed by the receiver's auth policy. -3. **Trusted RPC self-check**: trusted KMS RPCs such as `GetTempCaCert`, `GetKmsKey`, `GetAppKey`, and `SignCert` must fail when the running KMS is no longer allowed by its auth policy. +3. **Trusted RPC self-check**: trusted KMS RPCs such as `GetAppEnvEncryptPubKey`, `GetKmsKey`, `GetAppKey`, and `SignCert` must fail when the running KMS is no longer allowed by its auth policy. 4. **Attestation requirement**: KMS always requires attestation; for local development without TDX hardware, use `sdk/simulator`. This guide is written as a deployment-and-test runbook so an AI agent can follow it end-to-end. > **Execution notes from real runs on teepod2 (2026-03-19):** > -> 1. Do **not** assume a host-local `auth-simple` instance is reachable from a CVM. In practice, the auth API must be: +> 1. Do **not** assume a host-local `dstack-auth` instance is reachable from a CVM. In practice, the auth API must be: > - publicly reachable by the CVM, or > - deployed as a sidecar/internal service inside the same test environment. > - dstack CVMs use QEMU user-mode networking — the host is reachable at **`10.0.2.2`** from inside the CVM. @@ -23,7 +23,7 @@ This guide is written as a deployment-and-test runbook so an AI agent can follow > 5. The onboard completion endpoint is **GET `/finish`**, not POST. > 6. Do **not** reuse a previously captured `mr_aggregated` across redeploys. Auth policies must be generated from the attestation of the **current** VM under test. > 7. KMS now always requires quote/attestation. For local development without TDX hardware, use `sdk/simulator` instead of trying to run a no-attestation KMS flow. -> 8. For `auth-simple`, `kms.mrAggregated = []` is a deny-all policy for KMS. Use that as the baseline deny configuration, then add the measured KMS MR values for allow cases. +> 8. For `dstack-auth`, `kms.mrAggregated = []` is a deny-all policy for KMS. Use that as the baseline deny configuration, then add the measured KMS MR values for allow cases. > 9. **Port forwarding is simpler than gateway for testing.** Using `--gateway` requires the auth API to return a valid `gatewayAppId`, which adds unnecessary complexity. Use `--port tcp:0.0.0.0::8000` instead. > 10. **~~Remote KMS attestation has an empty `osImageHash`.~~** Fixed: RA-TLS certs now use the unified `PHALA_RATLS_ATTESTATION` format which preserves `vm_config`. For old source KMS instances that still use the legacy cert format, the receiver-side `ensure_kms_allowed` automatically fills `osImageHash` from the local KMS's own value. No special `"0x"` entry in `osImages` is needed anymore. > 11. The `source_url` in the `Onboard.Onboard` request must use an address **reachable from inside the CVM** (e.g., `https://10.0.2.2:/prpc`), not `127.0.0.1` which is the CVM's own loopback. @@ -52,7 +52,7 @@ This guide is written as a deployment-and-test runbook so an AI agent can follow This guide provides a standalone test procedure that does not depend on a dedicated e2e framework. It uses: - existing KMS deploy flows -- `auth-simple` as a controllable auth API +- `dstack-auth` as a controllable auth API - manual RPC calls via `curl` This exercises real deployment paths with minimal dependencies. @@ -61,11 +61,11 @@ This exercises real deployment paths with minimal dependencies. ## 2. Test strategy -Use **real KMS CVMs** with a hot-reloadable `auth-simple` policy. +Use **real KMS CVMs** with a hot-reloadable `dstack-auth` policy. -Why `auth-simple`: +Why `dstack-auth`: -- it implements the same `/bootAuth/kms` webhook contract used by KMS +- it implements the same webhook contract (`/authorizeKms`, `/bootAuth/kms`) used by KMS - its config is re-read on every request - allow/deny behavior can be changed without restarting the service @@ -79,18 +79,18 @@ Use the following layout: ```text Host / operator machine -├── auth-simple-src (source KMS auth policy) -├── auth-simple-dst (target KMS auth policy) +├── dstack-auth-src (source KMS auth policy) +├── dstack-auth-dst (target KMS auth policy) ├── kms-src (bootstrapped, later used as source KMS) ├── kms-dst (fresh KMS used for onboard tests) ``` Policy responsibilities: -- `auth-simple-src` must authorize: +- `dstack-auth-src` must authorize: - `kms-src` itself, for bootstrap and trusted RPC self-checks - `kms-dst`, when `kms-dst` calls `GetKmsKey` during onboarding -- `auth-simple-dst` decides whether `kms-dst` accepts `kms-src` as an allowed source KMS +- `dstack-auth-dst` decides whether `kms-dst` accepts `kms-src` as an allowed source KMS --- @@ -101,14 +101,14 @@ Before starting, make sure the following are available: 1. A KMS image built from current `master` (includes PR #573 auth checks, #579 mandatory attestation, #581 dedup refactor) 2. A working `dstack-vmm` or teepod deployment target 3. Two routable KMS onboard URLs -4. `bun` installed on the host, because `kms/auth-simple` runs on Bun +4. A `dstack-auth` binary on the host (`cargo build --release -p dstack-auth` in `dstack/`, or the one `dstackup` installs) 5. `jq`, `curl`, and Python 3 on the host Recommended references: - KMS deployment tutorial: `docs/tutorials/kms-cvm-deployment.md` - KMS troubleshooting: `docs/tutorials/troubleshooting-kms-deployment.md` -- `auth-simple` usage: `kms/auth-simple/README.md` +- `dstack-auth` usage: `docs/dstack-auth-operations.md` If deploying on teepod/dstack-vmm, the easiest pattern is: @@ -144,11 +144,11 @@ cd /tmp/kms-self-auth ### 5.2 Make the auth API reachable from the test KMS instances -The original plan was to run two host-local `auth-simple` processes. In practice, this only works if the CVMs can reach that host directly. +The original plan was to run two host-local `dstack-auth` processes. In practice, this only works if the CVMs can reach that host directly. Choose one of these options: -1. **Preferred:** run `auth-simple` on the operator host and point KMS at `http://10.0.2.2:` (QEMU host gateway). This is the simplest if the CVMs use QEMU user-mode networking. +1. **Preferred:** run `dstack-auth` on the operator host and point KMS at `http://10.0.2.2:` (QEMU host gateway). This is the simplest if the CVMs use QEMU user-mode networking. 2. **Also fine:** deploy the auth API as a separate public service or CVM 3. **Sidecar:** run the auth API as a sidecar in the same KMS test deployment @@ -159,11 +159,12 @@ If you use the sidecar/public-service pattern, keep the same logical split: and make sure you still have a way to update allow/deny policy during the test. -### 5.3 If using host-local `auth-simple`, install and start two instances +### 5.3 If using host-local `dstack-auth`, start two instances ```bash -cd "$REPO_ROOT/dstack/kms/auth-simple" -bun install +cd "$REPO_ROOT/dstack" +cargo build --release -p dstack-auth +DSTACK_AUTH="$REPO_ROOT/dstack/target/release/dstack-auth" ``` Create placeholder configs: @@ -200,13 +201,16 @@ These placeholder configs intentionally deny all KMS boots until you populate `k Start the services: +`dstack-auth` binds to `127.0.0.1` by default, which CVMs on QEMU user-mode +networking reach as `10.0.2.2`. Pass `--address 0.0.0.0` only if the CVMs reach +the host another way. + ```bash -cd "$REPO_ROOT/dstack/kms/auth-simple" -AUTH_CONFIG_PATH=/tmp/kms-self-auth/auth-src.json PORT=3101 bun run start \ +"$DSTACK_AUTH" --config /tmp/kms-self-auth/auth-src.json --port 3101 \ >/tmp/kms-self-auth/auth-src.log 2>&1 & echo $! >/tmp/kms-self-auth/auth-src.pid -AUTH_CONFIG_PATH=/tmp/kms-self-auth/auth-dst.json PORT=3102 bun run start \ +"$DSTACK_AUTH" --config /tmp/kms-self-auth/auth-dst.json --port 3102 \ >/tmp/kms-self-auth/auth-dst.log 2>&1 & echo $! >/tmp/kms-self-auth/auth-dst.pid ``` @@ -228,7 +232,10 @@ Requirements for **both** VMs: - `core.onboard.auto_bootstrap_domain = ""` - `core.auth_api.type = "webhook"` -Point them at different auth services. If using host-local `auth-simple` with QEMU user-mode networking: +`kms-dst` onboards through the public `KMS.GetKmsKey` without a `source_token`, +so `kms-src` also needs `core.onboard.public_key_handover = true`. + +Point them at different auth services. If using host-local `dstack-auth` with QEMU user-mode networking: - `kms-src` → `http://10.0.2.2:3101` - `kms-dst` → `http://10.0.2.2:3102` @@ -307,7 +314,7 @@ DST_MR=$(jq -r '.mr_aggregated' /tmp/kms-self-auth/kms-dst-attestation.json) DST_DEV=$(jq -r '.device_id' /tmp/kms-self-auth/kms-dst-attestation.json) ``` -All three values above are expected to be hex strings **without** the `0x` prefix. When writing `auth-simple` config, prepend `0x`. +All three values above are expected to be hex strings **without** the `0x` prefix. When writing `dstack-auth` config, prepend `0x`. ### 5.6 Helper configs @@ -388,7 +395,7 @@ sed "s/REPLACE_OS/$SRC_OS/g" \ >/tmp/kms-self-auth/auth-dst-deny-src.json ``` -Because `auth-simple` hot reloads its config on every request, switching policy is just a file copy: +Because `dstack-auth` hot reloads its config on every request, switching policy is just a file copy: ```bash cp /tmp/kms-self-auth/auth-src-deny-self.json /tmp/kms-self-auth/auth-src.json @@ -409,7 +416,7 @@ Verify that a KMS refuses bootstrap if the auth API denies **its own** measureme ### Steps 1. Make sure `kms-src` is still fresh and not bootstrapped yet. -2. Apply the deny-self policy to `auth-simple-src`: +2. Apply the deny-self policy to `dstack-auth-src`: ```bash cp /tmp/kms-self-auth/auth-src-deny-self.json /tmp/kms-self-auth/auth-src.json @@ -448,7 +455,7 @@ Verify that bootstrap succeeds once the same KMS is explicitly allowed. ### Steps -1. Switch `auth-simple-src` to allow `kms-src`: +1. Switch `dstack-auth-src` to allow `kms-src`: ```bash cp /tmp/kms-self-auth/auth-src-allow-self.json /tmp/kms-self-auth/auth-src.json @@ -502,10 +509,10 @@ Verify that the onboarding receiver rejects a source KMS whose attestation is de For this scenario to reach the receiver-side check: -- `auth-simple-src` must allow **both** `kms-src` and `kms-dst` +- `dstack-auth-src` must allow **both** `kms-src` and `kms-dst` - `kms-src` must allow itself, because trusted RPC self-checks run on the source - `kms-src` must also allow `kms-dst`, because `GetKmsKey` verifies the caller KMS -- `auth-simple-dst` must initially **deny** `kms-src` +- `dstack-auth-dst` must initially **deny** `kms-src` ### Steps @@ -589,18 +596,19 @@ Verify that a running KMS re-checks its own authorization on trusted RPCs. ### Recommended canary RPC -Use `GetTempCaCert` first. It is simpler than `GetAppKey` because it does not require preparing an attested app client, but it still exercises the new runtime self-check. +Use `GetAppEnvEncryptPubKey` first. It is simpler than `GetAppKey` because it does not require preparing an attested app client, but it still exercises the new runtime self-check. ### Steps 1. While `kms-src` is healthy, confirm the canary RPC works: ```bash -curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetTempCaCert?json" \ - | tee /tmp/kms-self-auth/get-temp-ca-allowed.json | jq . +curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetAppEnvEncryptPubKey?json" \ + -H 'Content-Type: application/json' -d '{"app_id":"1111111111111111111111111111111111111111"}' \ + | tee /tmp/kms-self-auth/get-env-pubkey-allowed.json | jq . ``` -2. Flip `auth-simple-src` to deny `kms-src` itself: +2. Flip `dstack-auth-src` to deny `kms-src` itself: ```bash cp /tmp/kms-self-auth/auth-src-deny-self.json /tmp/kms-self-auth/auth-src.json @@ -609,13 +617,14 @@ cp /tmp/kms-self-auth/auth-src-deny-self.json /tmp/kms-self-auth/auth-src.json 3. Retry the same RPC: ```bash -curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetTempCaCert?json" \ - | tee /tmp/kms-self-auth/get-temp-ca-denied.json | jq . +curl -sk "${KMS_SRC_RUNTIME%/}/prpc/KMS.GetAppEnvEncryptPubKey?json" \ + -H 'Content-Type: application/json' -d '{"app_id":"1111111111111111111111111111111111111111"}' \ + | tee /tmp/kms-self-auth/get-env-pubkey-denied.json | jq . ``` ### Expected result -- before the policy flip: `GetTempCaCert` succeeds +- before the policy flip: `GetAppEnvEncryptPubKey` succeeds - after the policy flip: the response contains `.error` - the error should indicate KMS self-authorization failed, or that the KMS is not allowed @@ -667,7 +676,7 @@ For each run, save: 2. auth config snapshots used for each step 3. bootstrap/onboard RPC responses 4. `KMS.GetMeta` output after successful boot -5. `GetTempCaCert` allow/deny responses +5. `GetAppEnvEncryptPubKey` allow/deny responses 6. relevant CVM logs if a step fails unexpectedly Recommended archive: diff --git a/dstack/vmm/Cargo.toml b/dstack/vmm/Cargo.toml index 5a62c930f..dd4f91d54 100644 --- a/dstack/vmm/Cargo.toml +++ b/dstack/vmm/Cargo.toml @@ -70,6 +70,11 @@ vfio-ioctls.workspace = true [dev-dependencies] insta.workspace = true +host-api = { workspace = true, features = ["client"] } +dcap-qvl.workspace = true +ra-tls.workspace = true +sodiumbox.workspace = true +tdx-attest.workspace = true [build-dependencies] or-panic.workspace = true diff --git a/dstack/vmm/requirements.txt b/dstack/vmm/requirements.txt index 2f4317237..9ec6ca2f0 100644 --- a/dstack/vmm/requirements.txt +++ b/dstack/vmm/requirements.txt @@ -7,6 +7,7 @@ eth-keys==0.7.0 eth-typing==5.2.1 eth-utils==5.3.0 pycparser==2.22 +pyhpke==0.6.5 pydantic==2.11.3 pydantic_core==2.33.1 safe-pysha3==1.0.4 diff --git a/dstack/vmm/rpc/proto/vmm_rpc.proto b/dstack/vmm/rpc/proto/vmm_rpc.proto index 84b66b2d6..e3f7980a9 100644 --- a/dstack/vmm/rpc/proto/vmm_rpc.proto +++ b/dstack/vmm/rpc/proto/vmm_rpc.proto @@ -109,6 +109,8 @@ message VmConfiguration { repeated PortMapping ports = 7; // Encrypted environment variables bytes encrypted_env = 8; + // Sealed environment variables. Takes precedence over encrypted_env. + bytes sealed_env = 24; // App ID. // If not provided, it assumes the app is newly created. // If provided, and KMS is enabled, it assumes the app is upgraded from given app_id. @@ -214,6 +216,8 @@ message UpdateVmRequest { string compose_file = 2; // Optional update encrypted environment variables. Leave empty to not update. bytes encrypted_env = 3; + // Optional sealed environment update. Leave empty to not update. + bytes sealed_env = 24; // Optional update user config. Leave empty to not update. string user_config = 4; // Optional update port mapping. @@ -282,6 +286,8 @@ message ImageInfo { string description = 2; string version = 3; bool is_dev = 4; + // Whether this guest supports SealedEnv; absent means legacy encryption only. + bool sealed_env = 5; } // App identifier used for encrypting env vars and routing telemetry. @@ -290,6 +296,14 @@ message AppId { } // Response with the KMS-backed public key used for env encryption plus audit signature. +message SealedEnvPubKeyResponse { + bytes public_key = 1; + uint32 root_epoch = 2; + uint64 timestamp = 3; + bytes signature = 4; + uint32 app_epoch = 5; +} + message PublicKeyResponse { bytes public_key = 1; // Legacy signature without timestamp (for backward compatibility). @@ -423,6 +437,7 @@ service Vmm { // Get Env encrypt public key rpc GetAppEnvEncryptPubKey(AppId) returns (PublicKeyResponse); + rpc GetSealedEnvPubKey(AppId) returns (SealedEnvPubKeyResponse); // Get VM info by ID rpc GetInfo(Id) returns (GetInfoResponse); diff --git a/dstack/vmm/src/app.rs b/dstack/vmm/src/app.rs index 379bea90a..1dd65be85 100644 --- a/dstack/vmm/src/app.rs +++ b/dstack/vmm/src/app.rs @@ -16,7 +16,8 @@ use bon::Builder; use dstack_kms_rpc::kms_client::KmsClient; use dstack_types::mr_config::MrConfigV3; use dstack_types::shared_filenames::{ - APP_COMPOSE, ENCRYPTED_ENV, INSTANCE_INFO, SYS_CONFIG, TEE_SIMULATOR_CONFIG, USER_CONFIG, + APP_COMPOSE, ENCRYPTED_ENV, INSTANCE_INFO, SEALED_ENV, SYS_CONFIG, TEE_SIMULATOR_CONFIG, + USER_CONFIG, }; use dstack_types::version::Version; use dstack_vmm_rpc::{ @@ -530,9 +531,16 @@ impl App { } self.sync_dynamic_config(id)?; self.set_started(id, true)?; + // A guest the VMM did not launch may already run application code. + let env_update = if is_running { + None + } else { + Some(self.sha256_of_env(id)?) + }; let vm_config = { let mut state = self.lock(); let vm_state = state.get_mut(id).context("VM not found")?; + vm_state.state.env_update = env_update; // Older images does not support for progress reporting if vm_state.config.image.info.shared_ro { vm_state.state.start(is_running); @@ -1561,7 +1569,22 @@ impl App { vm.state.events.pop_front(); } match event { - "boot.progress" => vm.state.boot_progress = body, + "boot.progress" => { + // Application code (init and pre-launch scripts, containers) + // runs from here on, so the guest OS can no longer vouch for + // an env update. + if matches!( + body.as_str(), + "init-script" + | "pre-launch" + | "starting containers" + | "running main script" + | "done" + ) { + vm.state.env_update = None; + } + vm.state.boot_progress = body; + } "boot.error" => vm.state.boot_error = body, "shutdown.progress" => { let powering_off = body == "powering off"; @@ -1582,12 +1605,78 @@ impl App { Ok(()) } - pub(crate) fn compose_file_path(&self, id: &str) -> Result { - Ok(self.shared_dir(id)?.join(APP_COMPOSE)) + /// Close the boot's env update window and store the encrypted env the + /// guest re-encrypted, if any. Accepted once per boot, before application + /// code runs, and only over the env the VM booted with, so an update made + /// since then is never overwritten. + /// + /// Once the VM is found, the window is closed and the call succeeds whether + /// or not the env is stored: the guest starts application code only after + /// an answer. + pub(crate) async fn update_sealed_env(&self, cid: u32, env: Vec) -> Result<()> { + let id = self + .lock() + .vms + .values() + .find(|vm| vm.config.cid == cid) + .context("VM not found")? + .config + .manifest + .id + .clone(); + let _launch = self.launch_lock(&id).await; + let (boot_env, supports_sealed_env) = { + let mut state = self.lock(); + let vm = state.vms.get_mut(&id).context("VM not found")?; + if vm.config.cid != cid { + bail!("VM CID changed"); + } + (vm.state.env_update.take(), vm.config.image.info.sealed_env) + }; + let stored = (|| { + let Some(boot_env) = boot_env else { + bail!("the env can only be updated once per boot, before applications start"); + }; + if env.is_empty() { + return Ok(false); + } + if !supports_sealed_env { + bail!("the VM image does not support sealed_env"); + } + if self.sha256_of_env(&id)? != boot_env { + bail!("the env changed since the VM booted; not replacing it"); + } + self.work_dir(&id)?.write_env(&[], &env)?; + Ok(true) + })(); + match stored { + Ok(true) => info!(id, "stored sealed env from the guest"), + Ok(false) => {} + Err(err) => warn!(id, "not storing the guest's sealed env: {err:#}"), + } + Ok(()) + } + + fn sha256_of_env(&self, id: &str) -> Result<[u8; 32]> { + let shared = self.shared_dir(id)?; + let mut hasher = Sha256::new(); + for name in [ENCRYPTED_ENV, SEALED_ENV] { + hasher.update(name.as_bytes()); + match fs::read(shared.join(name)) { + Ok(data) => { + hasher.update([1]); + hasher.update((data.len() as u64).to_le_bytes()); + hasher.update(data); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => hasher.update([0]), + Err(err) => return Err(err).context("failed to read env"), + } + } + Ok(hasher.finalize().into()) } - pub(crate) fn encrypted_env_path(&self, id: &str) -> Result { - Ok(self.shared_dir(id)?.join(ENCRYPTED_ENV)) + pub(crate) fn compose_file_path(&self, id: &str) -> Result { + Ok(self.shared_dir(id)?.join(APP_COMPOSE)) } pub(crate) fn user_config_path(&self, id: &str) -> Result { @@ -1605,15 +1694,13 @@ impl App { req: &VmConfiguration, app_id: &str, ) -> Result { + VmWorkDir::validate_env_update(&req.encrypted_env, &req.sealed_env)?; let work_dir = self.work_dir(id)?; let shared_dir = work_dir.join("shared"); fs::create_dir_all(&shared_dir).context("Failed to create shared directory")?; fs::write(shared_dir.join(APP_COMPOSE), &req.compose_file) .context("Failed to write compose file")?; - if !req.encrypted_env.is_empty() { - fs::write(shared_dir.join(ENCRYPTED_ENV), &req.encrypted_env) - .context("Failed to write encrypted env")?; - } + work_dir.write_env(&req.encrypted_env, &req.sealed_env)?; if !req.user_config.is_empty() { fs::write(shared_dir.join(USER_CONFIG), &req.user_config) .context("Failed to write user config")?; @@ -2508,6 +2595,263 @@ mod tests { (vm, networks) } + #[test] + fn prepare_work_dir_stores_sealed_and_rejects_both_formats() { + let dir = tempfile::tempdir().unwrap(); + let app = App::new( + test_config(&dir.path().join("absent-netd.sock"), dir.path()), + SupervisorClient::new("http://127.0.0.1:0"), + ); + let mut request = VmConfiguration { + compose_file: "{}".into(), + sealed_env: b"sealed".to_vec(), + ..Default::default() + }; + let workdir = app.prepare_work_dir("vm-1", &request, "").unwrap(); + assert_eq!(fs::read(workdir.sealed_env_path()).unwrap(), b"sealed"); + assert!(!workdir.encrypted_env_path().exists()); + request.encrypted_env = b"legacy".to_vec(); + assert!(app.prepare_work_dir("vm-2", &request, "").is_err()); + assert!(!app.work_dir("vm-2").unwrap().path().exists()); + } + + // Only the socket boundary is substituted: the guest wrapper, generated RPC + // decoder, host handler and filesystem/CAS implementation are production code. + #[rocket::post("/UpdateSealedEnv", data = "")] + async fn sealed_env_rpc( + body: Vec, + state: &rocket::State<(App, u32)>, + ) -> (rocket::http::Status, Vec) { + use ra_rpc::{CallContext, RemoteEndpoint, RpcCall}; + let handler = crate::host_api_service::HostApiHandler::construct( + CallContext::builder() + .state(&state.0) + .remote_endpoint(RemoteEndpoint::Vsock { + cid: state.1, + port: 1234, + }) + .build(), + ) + .unwrap(); + let (status, body) = handler + .call("UpdateSealedEnv".into(), body, true, false) + .await; + (rocket::http::Status::new(status), body) + } + + #[tokio::test] + async fn sealed_env_rpc_keeps_injection_payloads_bound_to_the_transport_vm() { + use crate::guest_host_api::HostApi; + use ra_rpc::{CallContext, RemoteEndpoint, RpcCall}; + + // HostApi intentionally skips unsupported platforms; do not let that turn + // this regression into a passing no-op. + assert!(matches!( + dstack_types::Platform::detect_or_dstack(), + dstack_types::Platform::Dstack + )); + let dir = tempfile::tempdir().unwrap(); + let app = App::new( + test_config(&dir.path().join("absent-netd.sock"), dir.path()), + SupervisorClient::new("http://127.0.0.1:0"), + ); + for (id, cid) in [("sender", 3), ("victim", 4)] { + let (mut vm, _) = bridge_vm(&app, id); + vm.cid = cid; + vm.image.info.sealed_env = true; + app.lock().vms.insert(id.into(), VmState::new(vm)); + let workdir = app.work_dir(id).unwrap(); + fs::create_dir_all(workdir.shared_dir()).unwrap(); + workdir.write_env(&[], id.as_bytes()).unwrap(); + let hash = app.sha256_of_env(id).unwrap(); + app.lock().vms.get_mut(id).unwrap().state.env_update = Some(hash); + } + let sender = app.work_dir("sender").unwrap(); + let victim = app.work_dir("victim").unwrap(); + let marker = dir.path().join("injected"); + let outside = dir.path().join("outside"); + fs::write(&outside, b"untouched").unwrap(); + let mut payload = format!( + "\"}},\"cid\":4,\"id\":\"victim\",\"path\":\"{}\",\"sealed_env\":\"overwritten\"}}\n../../../outside\n{}\n$(touch {}) ; touch {} #\n`touch {}`\n\\u0022\\n\\x00\r\n", + victim.sealed_env_path().display(), outside.display(), + marker.display(), marker.display(), marker.display(), + ).into_bytes(); + payload.extend(0u8..=255); + + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = Mutex::new(Some(tx)); + let server = rocket::custom( + rocket::Config::figment() + .merge(("address", "127.0.0.1")) + .merge(("port", 0)) + .merge(("log_level", "off")) + .merge(("shutdown.ctrlc", false)), + ) + .manage((app.clone(), 3u32)) + .mount("/", rocket::routes![sealed_env_rpc]) + .attach(rocket::fairing::AdHoc::on_liftoff("port", move |rocket| { + tx.lock() + .unwrap() + .take() + .unwrap() + .send( + rocket + .endpoints() + .find_map(|endpoint| endpoint.port()) + .unwrap(), + ) + .unwrap(); + Box::pin(async {}) + })) + .ignite() + .await + .unwrap(); + let shutdown = server.shutdown(); + let server = tokio::spawn(server.launch()); + let host = HostApi::new( + Some(format!("http://127.0.0.1:{}", rx.await.unwrap())), + None, + ); + host.update_sealed_env(payload.clone()).await.unwrap(); + assert_eq!(fs::read(sender.sealed_env_path()).unwrap(), payload); + assert!(!sender.encrypted_env_path().exists()); + assert_eq!(fs::read(victim.sealed_env_path()).unwrap(), b"victim"); + assert_eq!(fs::read(&outside).unwrap(), b"untouched"); + assert!(!marker.exists()); + assert!(app + .lock() + .vms + .get("victim") + .unwrap() + .state + .env_update + .is_some()); + // A repeated call, as after a lost answer, is answered but stores nothing. + host.update_sealed_env(b"again".to_vec()).await.unwrap(); + assert_eq!(fs::read(sender.sealed_env_path()).unwrap(), payload); + + // Even top-level forged JSON fields cannot select a VM or revive the CAS. + let forged = serde_json::to_vec(&serde_json::json!({ + "sealed_env": hex::encode(&payload), "cid": 4, "id": "victim", + "path": victim.sealed_env_path(), "env_update": true, + })) + .unwrap(); + for cid in [3, 999] { + let handler = crate::host_api_service::HostApiHandler::construct( + CallContext::builder() + .state(&app) + .remote_endpoint(RemoteEndpoint::Vsock { cid, port: 1234 }) + .build(), + ) + .unwrap(); + let (status, body) = handler + .call("UpdateSealedEnv".into(), forged.clone(), true, false) + .await; + if cid == 3 { + assert_eq!(status, 200); + } else { + assert_eq!(status, 400); + assert!(String::from_utf8(body).unwrap().contains("VM not found")); + } + } + assert!(crate::host_api_service::HostApiHandler::construct( + CallContext::builder().state(&app).build() + ) + .is_err()); + + // An owner change after the boot snapshot wins over the guest payload. + let hash = app.sha256_of_env("sender").unwrap(); + app.lock().vms.get_mut("sender").unwrap().state.env_update = Some(hash); + sender.write_env(&[], b"owner update").unwrap(); + host.update_sealed_env(payload).await.unwrap(); + assert_eq!(fs::read(sender.sealed_env_path()).unwrap(), b"owner update"); + assert_eq!(fs::read(victim.sealed_env_path()).unwrap(), b"victim"); + assert_eq!(fs::read(outside).unwrap(), b"untouched"); + assert!(!marker.exists()); + shutdown.notify(); + server.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn sealed_env_cas_binds_both_files_and_serializes_owner_updates() { + let dir = tempfile::tempdir().unwrap(); + let app = App::new( + test_config(&dir.path().join("absent-netd.sock"), dir.path()), + SupervisorClient::new("http://127.0.0.1:0"), + ); + let (vm, _) = bridge_vm(&app, "vm-1"); + let cid = vm.cid; + app.lock().vms.insert("vm-1".into(), VmState::new(vm)); + let workdir = app.work_dir("vm-1").unwrap(); + fs::create_dir_all(workdir.shared_dir()).unwrap(); + let boot = || { + let hash = app.sha256_of_env("vm-1").unwrap(); + app.lock().vms.get_mut("vm-1").unwrap().state.env_update = Some(hash); + }; + let window_open = || app.lock().vms["vm-1"].state.env_update.is_some(); + // Every answer closes the window, whether or not the env is stored. + let update = |env: &[u8]| app.update_sealed_env(cid, env.to_vec()); + update(b"new").await.unwrap(); + assert!(!workdir.sealed_env_path().exists()); + workdir.write_env(b"legacy", &[]).unwrap(); + boot(); + update(b"sealed").await.unwrap(); + assert!(!window_open()); + assert_eq!(fs::read(workdir.encrypted_env_path()).unwrap(), b"legacy"); + assert!(!workdir.sealed_env_path().exists()); + Arc::get_mut(&mut app.lock().vms.get_mut("vm-1").unwrap().config) + .unwrap() + .image + .info + .sealed_env = true; + boot(); + update(b"sealed").await.unwrap(); + assert!(!window_open()); + assert!(!workdir.encrypted_env_path().exists()); + assert_eq!(fs::read(workdir.sealed_env_path()).unwrap(), b"sealed"); + update(b"again").await.unwrap(); + assert_eq!(fs::read(workdir.sealed_env_path()).unwrap(), b"sealed"); + + for name in [ENCRYPTED_ENV, SEALED_ENV] { + let path = workdir.shared_dir().join(name); + let _ = fs::remove_file(&path); + boot(); + fs::write(&path, []).unwrap(); + update(b"stale").await.unwrap(); + assert!(!window_open()); + assert_eq!(fs::read(&path).unwrap(), b""); + } + + boot(); + let held = app.launch_lock("vm-1").await; + let guest = update(b"stale"); + tokio::pin!(guest); + assert!(tokio::time::timeout(Duration::from_millis(20), &mut guest) + .await + .is_err()); + workdir.write_env(b"owner", &[]).unwrap(); + drop(held); + guest.await.unwrap(); + assert_eq!(fs::read(workdir.encrypted_env_path()).unwrap(), b"owner"); + assert!(!workdir.sealed_env_path().exists()); + + // An empty update closes the window too. + boot(); + update(b"").await.unwrap(); + assert!(!window_open()); + update(b"again").await.unwrap(); + assert_eq!(fs::read(workdir.encrypted_env_path()).unwrap(), b"owner"); + // An application-stage progress report still closes it. + boot(); + app.vm_event_report(cid, "boot.progress", "init-script".into()) + .unwrap(); + assert!(!window_open()); + update(b"late").await.unwrap(); + assert!(!workdir.sealed_env_path().exists()); + // A VM the host does not know gets an error. + assert!(app.update_sealed_env(cid + 1000, Vec::new()).await.is_err()); + } + /// A launch that never reached a netd built nothing, so it must not leave /// a marker that makes every later stop and removal wait for one. What /// an earlier boot may still hold keeps its marker. @@ -3406,6 +3750,7 @@ mod tests { let tdx_measurement = supports_lite.then(dummy_tdx_measurement_document); Image { info: ImageInfo { + sealed_env: false, cmdline: None, kernel: "kernel".to_string(), initrd: "initrd".to_string(), @@ -3950,6 +4295,10 @@ struct VmStateMut { auto_restart: AutoRestartState, /// True when the VM is being removed (cleanup in progress). removing: bool, + /// SHA-256 of both env files, including presence, this boot started with, while the guest + /// may still replace it; taken by the first update and cleared once the + /// boot reaches application code. + env_update: Option<[u8; 32]>, } impl VmStateMut { diff --git a/dstack/vmm/src/app/image.rs b/dstack/vmm/src/app/image.rs index ae941fb74..686680c4d 100644 --- a/dstack/vmm/src/app/image.rs +++ b/dstack/vmm/src/app/image.rs @@ -36,6 +36,9 @@ pub struct ImageInfo { pub shared_ro: bool, #[serde(default)] pub version: String, + /// Whether this guest supports SealedEnv. Missing metadata means legacy only. + #[serde(default)] + pub sealed_env: bool, #[serde(default)] pub is_dev: bool, /// OVMF measurement layout declared by the image. Older metadata.json files diff --git a/dstack/vmm/src/app/qemu.rs b/dstack/vmm/src/app/qemu.rs index e7594da57..e1d090db3 100644 --- a/dstack/vmm/src/app/qemu.rs +++ b/dstack/vmm/src/app/qemu.rs @@ -1487,6 +1487,7 @@ mod tests { }, image: Image { info: ImageInfo { + sealed_env: false, cmdline: Some("console=hvc0".into()), kernel: "kernel".into(), initrd: "initrd".into(), diff --git a/dstack/vmm/src/app/vm_info.rs b/dstack/vmm/src/app/vm_info.rs index 26e4e1f84..557a089e9 100644 --- a/dstack/vmm/src/app/vm_info.rs +++ b/dstack/vmm/src/app/vm_info.rs @@ -203,6 +203,7 @@ impl VmInfo { compose_file: fs::read_to_string(workdir.app_compose_path()) .unwrap_or_default(), encrypted_env: fs::read(workdir.encrypted_env_path()).unwrap_or_default(), + sealed_env: fs::read(workdir.sealed_env_path()).unwrap_or_default(), user_config: fs::read_to_string(workdir.user_config_path()).unwrap_or_default(), vcpu: self.manifest.vcpu, memory: self.manifest.memory, diff --git a/dstack/vmm/src/app/workdir.rs b/dstack/vmm/src/app/workdir.rs index fd67072f3..06ed7c139 100644 --- a/dstack/vmm/src/app/workdir.rs +++ b/dstack/vmm/src/app/workdir.rs @@ -9,7 +9,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use dstack_types::{ - shared_filenames::{APP_COMPOSE, ENCRYPTED_ENV, INSTANCE_INFO, SYS_CONFIG, USER_CONFIG}, + shared_filenames::{ + APP_COMPOSE, ENCRYPTED_ENV, INSTANCE_INFO, SEALED_ENV, SYS_CONFIG, USER_CONFIG, + }, AppCompose, SysConfig, }; use fs_err as fs; @@ -131,10 +133,55 @@ impl VmWorkDir { self.shared_dir().join(USER_CONFIG) } + pub(crate) fn validate_env_update(encrypted: &[u8], sealed: &[u8]) -> Result<()> { + if !encrypted.is_empty() && !sealed.is_empty() { + anyhow::bail!("encrypted_env and sealed_env are mutually exclusive"); + } + Ok(()) + } + + /// Empty inputs preserve existing environment files, including invalid sealed files. + pub(crate) fn uses_sealed_env(&self, encrypted: &[u8], sealed: &[u8]) -> Result { + Self::validate_env_update(encrypted, sealed)?; + if !sealed.is_empty() { + return Ok(true); + } + if !encrypted.is_empty() { + return Ok(false); + } + match fs::symlink_metadata(self.sealed_env_path()) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err).context("failed to inspect sealed environment"), + } + } + + /// Caller serializes updates to an existing VM with its launch lock. + pub(crate) fn write_env(&self, encrypted: &[u8], sealed: &[u8]) -> Result<()> { + Self::validate_env_update(encrypted, sealed)?; + let (path, other, data) = if !sealed.is_empty() { + (self.sealed_env_path(), self.encrypted_env_path(), sealed) + } else if !encrypted.is_empty() { + (self.encrypted_env_path(), self.sealed_env_path(), encrypted) + } else { + return Ok(()); + }; + safe_write::safe_write(path, data).context("failed to write env")?; + match fs::remove_file(other) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).context("failed to remove superseded env"), + } + } + pub fn encrypted_env_path(&self) -> PathBuf { self.shared_dir().join(ENCRYPTED_ENV) } + pub fn sealed_env_path(&self) -> PathBuf { + self.shared_dir().join(SEALED_ENV) + } + pub fn instance_info_path(&self) -> PathBuf { self.shared_dir().join(INSTANCE_INFO) } @@ -389,3 +436,39 @@ mod tests { workdir.remove_all().unwrap(); } } + +#[cfg(test)] +mod env_tests { + use super::*; + + #[test] + fn env_updates_replace_the_other_format_and_reject_ambiguity() { + let dir = tempfile::tempdir().unwrap(); + let workdir = VmWorkDir::new(dir.path()); + fs::create_dir_all(workdir.shared_dir()).unwrap(); + workdir.write_env(b"legacy", &[]).unwrap(); + workdir.write_env(&[], b"sealed").unwrap(); + assert!(!workdir.encrypted_env_path().exists()); + assert_eq!(fs::read(workdir.sealed_env_path()).unwrap(), b"sealed"); + assert!(workdir.write_env(b"ambiguous", b"ambiguous").is_err()); + assert_eq!(fs::read(workdir.sealed_env_path()).unwrap(), b"sealed"); + workdir.write_env(&[], &[]).unwrap(); + assert_eq!(fs::read(workdir.sealed_env_path()).unwrap(), b"sealed"); + workdir.write_env(b"owner", &[]).unwrap(); + assert!(!workdir.sealed_env_path().exists()); + assert_eq!(fs::read(workdir.encrypted_env_path()).unwrap(), b"owner"); + } + + #[test] + fn env_write_failure_preserves_original_and_cleanup_errors_propagate() { + let dir = tempfile::tempdir().unwrap(); + let workdir = VmWorkDir::new(dir.path()); + fs::create_dir_all(workdir.shared_dir()).unwrap(); + workdir.write_env(b"original", &[]).unwrap(); + fs::create_dir(workdir.sealed_env_path()).unwrap(); + assert!(workdir.write_env(&[], b"replacement").is_err()); + assert_eq!(fs::read(workdir.encrypted_env_path()).unwrap(), b"original"); + assert!(workdir.write_env(b"updated", &[]).is_err()); + assert_eq!(fs::read(workdir.encrypted_env_path()).unwrap(), b"updated"); + } +} diff --git a/dstack/vmm/src/config.rs b/dstack/vmm/src/config.rs index c923db519..4f76e742a 100644 --- a/dstack/vmm/src/config.rs +++ b/dstack/vmm/src/config.rs @@ -1432,6 +1432,19 @@ impl Config { mod tests { use super::*; + #[test] + fn host_api_limits_sealed_env_updates() { + use rocket::data::ToByteUnit; + use rocket::figment::providers::Serialized; + // Built the way `run_host_api` builds the host API server's config. + let figment = load_config_figment(None); + let figment = figment.clone().merge(Serialized::defaults( + figment.find_value("host_api").unwrap(), + )); + let config: rocket::Config = figment.extract().unwrap(); + assert_eq!(config.limits.get("UpdateSealedEnv"), Some(1.mebibytes())); + } + #[test] fn image_dir_stays_a_direct_child_of_the_image_root() { let cfg = ImageConfig { diff --git a/dstack/vmm/src/host_api_service.rs b/dstack/vmm/src/host_api_service.rs index fc4dcc704..0ad3c53eb 100644 --- a/dstack/vmm/src/host_api_service.rs +++ b/dstack/vmm/src/host_api_service.rs @@ -46,6 +46,12 @@ impl HostApiRpc for HostApiHandler { .vm_event_report(self.endpoint.cid, &request.event, request.payload) } + async fn update_sealed_env(self, request: host_api::SealedEnv) -> Result<()> { + self.app + .update_sealed_env(self.endpoint.cid, request.sealed_env) + .await + } + async fn get_sealing_key(self, request: GetSealingKeyRequest) -> Result { let key_provider = &self.app.config.key_provider; if !key_provider.enabled { diff --git a/dstack/vmm/src/main.rs b/dstack/vmm/src/main.rs index 0d0c7f8f5..86fc02adb 100644 --- a/dstack/vmm/src/main.rs +++ b/dstack/vmm/src/main.rs @@ -35,6 +35,16 @@ mod one_shot; mod openapi; mod vm_launcher; +// Exercise the guest's actual HostApi wrapper against VMM storage in tests. +#[cfg(test)] +#[allow(dead_code, unused_imports)] +#[path = "../../dstack-util/src/host_api.rs"] +mod guest_host_api; +#[cfg(test)] +#[allow(dead_code, unused_imports)] +#[path = "../../dstack-util/src/utils.rs"] +mod utils; + const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const GIT_REV: &str = dstack_build_info::git_revision!(); diff --git a/dstack/vmm/src/main_service.rs b/dstack/vmm/src/main_service.rs index ffd06ad61..df581f98e 100644 --- a/dstack/vmm/src/main_service.rs +++ b/dstack/vmm/src/main_service.rs @@ -14,8 +14,9 @@ use dstack_vmm_rpc::vmm_server::{VmmRpc, VmmServer}; use dstack_vmm_rpc::{ AppId, ComposeHash as RpcComposeHash, GatewaySettings, GetInfoResponse, GetMetaResponse, Id, ImageInfo as RpcImageInfo, ImageListResponse, KmsSettings, ListGpusResponse, PublicKeyResponse, - ReloadVmsResponse, ResizeVmRequest, ResourcesSettings, StatusRequest, StatusResponse, - SvListResponse, SvProcessInfo, UpdateVmRequest, VersionResponse, VmConfiguration, + ReloadVmsResponse, ResizeVmRequest, ResourcesSettings, SealedEnvPubKeyResponse, StatusRequest, + StatusResponse, SvListResponse, SvProcessInfo, UpdateVmRequest, VersionResponse, + VmConfiguration, }; use fs_err as fs; use path_absolutize::Absolutize; @@ -311,6 +312,7 @@ pub fn create_manifest_from_vm_config( request: VmConfiguration, cvm_config: &crate::config::CvmConfig, ) -> Result { + VmWorkDir::validate_env_update(&request.encrypted_env, &request.sealed_env)?; validate_label(&request.name)?; // Same checks as `validate_resize_request`. if request.vcpu == 0 { @@ -935,6 +937,25 @@ impl RpcHandler { Ok(true) } + fn validate_env_image( + &self, + work_dir: &VmWorkDir, + image: &str, + encrypted_env: &[u8], + sealed_env: &[u8], + ) -> Result<()> { + if !work_dir.uses_sealed_env(encrypted_env, sealed_env)? { + return Ok(()); + } + let image_dir = self.app.config.image.image_dir(image)?; + let target = + Image::load(&image_dir).with_context(|| format!("failed to load image {image:?}"))?; + if !target.info.sealed_env { + bail!("image {image:?} does not support sealed_env; provide encrypted_env or choose a compatible image"); + } + Ok(()) + } + /// Grows the data disk to `manifest.disk_size` if that exceeds `old_size`. async fn grow_disk_if_needed( vm_work_dir: &VmWorkDir, @@ -992,6 +1013,12 @@ impl VmmRpc for RpcHandler { info!(vm_id = %id, "create_vm RPC called"); let app_id = manifest.app_id.clone(); let vm_work_dir = self.app.work_dir(&id)?; + self.validate_env_image( + &vm_work_dir, + &manifest.image, + &request.encrypted_env, + &request.sealed_env, + )?; vm_work_dir .put_manifest(&manifest) .context("Failed to write manifest")?; @@ -1068,6 +1095,7 @@ impl VmmRpc for RpcHandler { name, description: serde_json::to_string(&info).unwrap_or_default(), version: info.version, + sealed_env: info.sealed_env, is_dev: info.is_dev, }) .collect(), @@ -1092,6 +1120,7 @@ impl VmmRpc for RpcHandler { // deleted that directory would recreate it holding nothing but a // manifest: invisible to `list_vms`, unloadable at every start, and // claiming the VM's netd interfaces against collection forever. + VmWorkDir::validate_env_update(&request.encrypted_env, &request.sealed_env)?; let _launch = self.app.launch_lock(&request.id).await; // Again under the lock: removal can have claimed the VM while this // waited for it. @@ -1099,6 +1128,12 @@ impl VmmRpc for RpcHandler { let vm_work_dir = self.app.work_dir(&request.id)?; let mut manifest = vm_work_dir.manifest().context("Failed to read manifest")?; let old_disk_size = manifest.disk_size; + self.validate_env_image( + &vm_work_dir, + request.image.as_deref().unwrap_or(&manifest.image), + &request.encrypted_env, + &request.sealed_env, + )?; // Validate the whole request against an in-memory manifest before // touching the host, so a rejected update changes nothing. let new_id = if !request.compose_file.is_empty() { @@ -1207,11 +1242,7 @@ impl VmmRpc for RpcHandler { fs::write(self.compose_file_path(&request.id)?, &request.compose_file) .context("Failed to write compose file")?; } - if !request.encrypted_env.is_empty() { - let encrypted_env_path = self.encrypted_env_path(&request.id)?; - fs::write(encrypted_env_path, &request.encrypted_env) - .context("Failed to write encrypted env")?; - } + vm_work_dir.write_env(&request.encrypted_env, &request.sealed_env)?; if !request.user_config.is_empty() { let user_config_path = self.user_config_path(&request.id)?; fs::write(user_config_path, &request.user_config) @@ -1243,6 +1274,22 @@ impl VmmRpc for RpcHandler { }) } + async fn get_sealed_env_pub_key(self, request: AppId) -> Result { + let kms = self.kms_client()?; + let response = kms + .get_sealed_env_pub_key(dstack_kms_rpc::AppId { + app_id: request.app_id, + }) + .await?; + Ok(SealedEnvPubKeyResponse { + app_epoch: response.app_epoch, + public_key: response.public_key, + root_epoch: response.root_epoch, + timestamp: response.timestamp, + signature: response.signature, + }) + } + async fn get_info(self, request: Id) -> Result { info!(vm_id = %request.id, "get_info RPC called"); if let Some(vm) = self.app.vm_info(&request.id).await? { @@ -1277,6 +1324,12 @@ impl VmmRpc for RpcHandler { let vm_work_dir = self.app.work_dir(&request.id)?; let mut manifest = vm_work_dir.manifest().context("failed to read manifest")?; let old_disk_size = manifest.disk_size; + self.validate_env_image( + &vm_work_dir, + request.image.as_deref().unwrap_or(&manifest.image), + &[], + &[], + )?; self.apply_resource_updates( &request.id, &mut manifest, @@ -1497,6 +1550,7 @@ mod tests { disk_size: 10, ports: vec![], encrypted_env: vec![], + sealed_env: vec![], app_id: None, user_config: String::new(), hugepages: false, @@ -2863,6 +2917,172 @@ mod tests { assert!(port_map_from_proto(&[port(8080), port(8081)], &pm_cfg, &held).is_err()); } + fn env_image_handler(dir: &std::path::Path) -> RpcHandler { + let mut config: crate::config::Config = + Figment::from(crate::config::load_config_figment(None)) + .extract() + .unwrap(); + config.run_path = dir.join("vms"); + config.image.path = dir.join("images"); + for (name, capability) in [ + ("legacy", None), + ("unsupported", Some(false)), + ("sealed", Some(true)), + ] { + let image_dir = config.image.path.join(name); + fs::create_dir_all(&image_dir).unwrap(); + fs::write(image_dir.join("kernel"), b"test kernel").unwrap(); + fs::write(image_dir.join("initrd"), b"test initrd").unwrap(); + let mut metadata = serde_json::json!({"kernel": "kernel", "initrd": "initrd"}); + if let Some(capability) = capability { + metadata["sealed_env"] = capability.into(); + } + fs::write( + image_dir.join("metadata.json"), + serde_json::to_vec(&metadata).unwrap(), + ) + .unwrap(); + } + RpcHandler { + app: App::new( + config, + supervisor_client::SupervisorClient::new("http://127.0.0.1:0"), + ), + } + } + + #[test] + fn env_image_validation_uses_the_effective_environment() { + let tmp = tempfile::tempdir().unwrap(); + let handler = env_image_handler(tmp.path()); + let work_dir = handler.app.work_dir("vm-test").unwrap(); + for image in ["legacy", "unsupported", "sealed"] { + assert!(handler + .validate_env_image(&work_dir, image, b"", b"") + .is_ok()); + assert!(handler + .validate_env_image(&work_dir, image, b"legacy", b"") + .is_ok()); + assert_eq!( + handler + .validate_env_image(&work_dir, image, b"", b"sealed") + .is_ok(), + image == "sealed" + ); + } + fs::create_dir_all(work_dir.shared_dir()).unwrap(); + // Presence, including an empty file or dangling link, selects SealedEnv. + for dangling in [false, true] { + if dangling { + fs::remove_file(work_dir.sealed_env_path()).unwrap(); + std::os::unix::fs::symlink("missing", work_dir.sealed_env_path()).unwrap(); + } else { + fs::write(work_dir.sealed_env_path(), b"").unwrap(); + } + for image in ["legacy", "unsupported", "sealed"] { + assert_eq!( + handler + .validate_env_image(&work_dir, image, b"", b"") + .is_ok(), + image == "sealed" + ); + assert!(handler + .validate_env_image(&work_dir, image, b"replacement", b"") + .is_ok()); + } + } + } + + #[tokio::test] + async fn incompatible_env_create_writes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let handler = env_image_handler(tmp.path()); + for image in ["legacy", "unsupported"] { + let err = RpcHandler { + app: handler.app.clone(), + } + .create_vm(VmConfiguration { + image: image.into(), + sealed_env: b"sealed".to_vec(), + ..test_vm_configuration() + }) + .await + .unwrap_err(); + assert!( + err.to_string().contains("does not support sealed_env"), + "{err:#}" + ); + assert!(!handler.app.config.run_path.exists()); + } + } + + #[tokio::test] + async fn incompatible_env_update_and_resize_write_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let handler = env_image_handler(tmp.path()); + let mut manifest = + create_manifest_from_vm_config(test_vm_configuration(), &handler.app.config.cvm) + .unwrap(); + let work_dir = handler.app.work_dir(&manifest.id).unwrap(); + fs::create_dir_all(work_dir.shared_dir()).unwrap(); + fs::write(work_dir.app_compose_path(), b"{}").unwrap(); + fs::write(work_dir.sealed_env_path(), b"original sealed env").unwrap(); + for (current, target, sealed) in [ + ("legacy", None, b"new sealed".as_slice()), + ("sealed", Some("legacy"), b"".as_slice()), + ("sealed", Some("unsupported"), b"new sealed".as_slice()), + ] { + manifest.image = current.into(); + work_dir.put_manifest(&manifest).unwrap(); + let original = fs::read(work_dir.manifest_path()).unwrap(); + let err = RpcHandler { + app: handler.app.clone(), + } + .update_vm(UpdateVmRequest { + id: manifest.id.clone(), + image: target.map(str::to_string), + sealed_env: sealed.to_vec(), + compose_file: r#"{"manifest_version":"2","name":"new","runner":"docker-compose"}"# + .into(), + disk_size: Some(manifest.disk_size + 1), + ..Default::default() + }) + .await + .unwrap_err(); + assert!( + err.to_string().contains("does not support sealed_env"), + "{err:#}" + ); + assert_eq!(fs::read(work_dir.manifest_path()).unwrap(), original); + assert_eq!(fs::read(work_dir.app_compose_path()).unwrap(), b"{}"); + assert_eq!( + fs::read(work_dir.sealed_env_path()).unwrap(), + b"original sealed env" + ); + assert!(!work_dir.hda_path().exists()); + } + manifest.image = "sealed".into(); + work_dir.put_manifest(&manifest).unwrap(); + let original = fs::read(work_dir.manifest_path()).unwrap(); + let err = RpcHandler { + app: handler.app.clone(), + } + .resize_vm(ResizeVmRequest { + id: manifest.id.clone(), + image: Some("legacy".into()), + disk_size: Some(manifest.disk_size + 1), + ..Default::default() + }) + .await + .unwrap_err(); + assert!( + err.to_string().contains("does not support sealed_env"), + "{err:#}" + ); + assert_eq!(fs::read(work_dir.manifest_path()).unwrap(), original); + assert!(!work_dir.hda_path().exists()); + } + #[tokio::test] async fn a_rejected_update_writes_nothing() { let tmp = tempfile::tempdir().unwrap(); diff --git a/dstack/vmm/src/one_shot.rs b/dstack/vmm/src/one_shot.rs index 4aa36741b..6eafeeab6 100644 --- a/dstack/vmm/src/one_shot.rs +++ b/dstack/vmm/src/one_shot.rs @@ -100,12 +100,7 @@ pub async fn run_one_shot( // Create or use specified workdir and setup files let workdir_path = match workdir_option { - Some(workdir_str) => { - let workdir_path = std::path::PathBuf::from(workdir_str); - fs_err::create_dir_all(&workdir_path) - .with_context(|| format!("Failed to create workdir: {}", workdir_path.display()))?; - workdir_path - } + Some(workdir_str) => std::path::PathBuf::from(workdir_str), None => { // Create a persistent directory in current working directory let vm_name = &manifest.name; @@ -114,14 +109,18 @@ pub async fn run_one_shot( .unwrap_or_default() .as_secs(); let workdir_name = format!("dstack-oneshot-{}-{}", vm_name, timestamp); - let workdir_path = std::env::current_dir()?.join(workdir_name); - fs_err::create_dir_all(&workdir_path) - .with_context(|| format!("Failed to create workdir: {}", workdir_path.display()))?; - workdir_path + std::env::current_dir()?.join(workdir_name) } }; let vm_work_dir = VmWorkDir::new(&workdir_path); + if vm_work_dir.uses_sealed_env(&vm_config.encrypted_env, &vm_config.sealed_env)? + && !image.info.sealed_env + { + anyhow::bail!("image {:?} does not support sealed_env; provide encrypted_env or choose a compatible image", manifest.image); + } + fs::create_dir_all(&workdir_path) + .with_context(|| format!("failed to create workdir: {}", workdir_path.display()))?; vm_work_dir .put_manifest(&manifest) @@ -216,10 +215,7 @@ Compose file content (first 200 chars): .context("Failed to write app compose file")?; // Write other files if present - if !vm_config.encrypted_env.is_empty() { - fs_err::write(vm_work_dir.encrypted_env_path(), &vm_config.encrypted_env) - .context("Failed to write encrypted env")?; - } + vm_work_dir.write_env(&vm_config.encrypted_env, &vm_config.sealed_env)?; if !vm_config.user_config.is_empty() { fs_err::write(vm_work_dir.user_config_path(), &vm_config.user_config) @@ -393,6 +389,72 @@ Compose file content (first 200 chars): mod tests { use super::*; + #[rocket::async_test] + async fn sealed_env_requires_a_capable_image_before_writing_one_shot_state() { + use rocket::figment::{ + providers::{Format, Toml}, + Figment, + }; + for capability in [None, Some(false)] { + for existing in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let mut config: Config = Figment::from(Toml::string(crate::config::DEFAULT_CONFIG)) + .extract() + .unwrap(); + config.image.path = dir.path().join("images"); + let image_dir = config.image.path.join("legacy"); + fs::create_dir_all(&image_dir).unwrap(); + fs::write(image_dir.join("kernel"), b"kernel").unwrap(); + fs::write(image_dir.join("initrd"), b"initrd").unwrap(); + let mut metadata = serde_json::json!({"kernel": "kernel", "initrd": "initrd"}); + if let Some(capability) = capability { + metadata["sealed_env"] = capability.into(); + } + fs::write(image_dir.join("metadata.json"), metadata.to_string()).unwrap(); + let workdir = dir.path().join("vm"); + let state = VmWorkDir::new(&workdir); + if existing { + fs::create_dir_all(state.shared_dir()).unwrap(); + // Presence alone selects the sealed format, even for an empty file. + fs::write(state.sealed_env_path(), b"").unwrap(); + fs::write(state.manifest_path(), b"original manifest").unwrap(); + } + let request = dstack_vmm_rpc::VmConfiguration { + name: "test".into(), + image: "legacy".into(), + vcpu: 1, + memory: 1024, + disk_size: 1, + compose_file: r#"{"manifest_version":2,"name":"test","runner":"none"}"#.into(), + sealed_env: if existing { vec![] } else { b"sealed".to_vec() }, + ..Default::default() + }; + let path = dir.path().join("vm.json"); + fs::write(&path, serde_json::to_vec(&request).unwrap()).unwrap(); + let error = run_one_shot( + path.to_str().unwrap(), + config, + Some(workdir.to_str().unwrap().into()), + true, + ) + .await + .unwrap_err(); + assert!( + format!("{error:#}").contains("does not support sealed_env"), + "{error:#}" + ); + if existing { + assert_eq!( + fs::read(state.manifest_path()).unwrap(), + b"original manifest" + ); + } else { + assert!(!workdir.exists()); + } + } + } + } + #[test] fn compose_preview_truncates_by_character() { let preview = compose_preview(&"\u{4e2d}".repeat(250)); diff --git a/dstack/vmm/src/tests/test_vmm_cli.py b/dstack/vmm/src/tests/test_vmm_cli.py index f3c33c371..c677575bf 100644 --- a/dstack/vmm/src/tests/test_vmm_cli.py +++ b/dstack/vmm/src/tests/test_vmm_cli.py @@ -5,12 +5,20 @@ from __future__ import annotations +import argparse +import base64 import contextlib import importlib.util import io +import json import sys import unittest from pathlib import Path +from unittest.mock import Mock, patch + +from eth_keys import keys +from eth_utils import keccak +from pyhpke import AEADId, CipherSuite, KDFId, KEMId def load_vmm_cli(): @@ -55,5 +63,379 @@ def test_kms_urls_are_sent_to_upgrade_app(self) -> None: ) +class SealedEnvTests(unittest.TestCase): + """Exercise signed recipient selection and SealedEnv compatibility.""" + + def setUp(self): + """Create synthetic keys, a signed response and an isolated client.""" + self.app_id = "11" * 20 + self.epoch = 3 + self.timestamp = 1700000000 + self.clock = patch.object(VMM_CLI.time, "time", return_value=self.timestamp) + self.clock.start() + self.addCleanup(self.clock.stop) + self.signer = keys.PrivateKey(b"\x22" * 32) + self.pin = self.signer.public_key.to_compressed_bytes().hex() + self.suite = CipherSuite.new( + KEMId.DHKEM_X25519_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.AES256_GCM + ) + self.recipient = self.suite.kem.derive_key_pair(b"test recipient key material") + self.response = { + "public_key": self.recipient.public_key.to_public_bytes().hex(), + "root_epoch": self.epoch, + "timestamp": self.timestamp, + } + digest = keccak( + b"dstack-sealed-env-pubkey-v1:" + + bytes.fromhex(self.app_id) + + self.epoch.to_bytes(4, "big") + + (0).to_bytes(4, "big") + + bytes.fromhex(self.response["public_key"]) + + self.timestamp.to_bytes(8, "big") + ) + self.response["signature"] = self.signer.sign_msg_hash(digest).to_bytes().hex() + self.cli = VMM_CLI.VmmCLI("https://vmm.example") + self.cli.client.request = Mock(return_value=(200, self.response)) + self.cli.get_app_env_encrypt_pub_key = Mock( + return_value=self.response["public_key"] + ) + self.pins = patch.object(VMM_CLI, "load_whitelist", return_value=[self.pin]) + self.pins.start() + self.addCleanup(self.pins.stop) + self.envs = {"SECRET": "synthetic test value", "UNICODE": "测试"} + + def decrypt(self, payload): + """Check plaintext recovery and rejection under a different epoch context.""" + self.assertNotIn("encrypted_env", payload) + envelope = json.loads(bytes.fromhex(payload["sealed_env"])) + self.assertEqual( + set(envelope), {"version", "root_epoch", "app_epoch", "enc", "ciphertext"} + ) + self.assertEqual(envelope["version"], 1) + self.assertEqual(envelope["root_epoch"], self.epoch) + enc = base64.b64decode(envelope["enc"], validate=True) + ciphertext = base64.b64decode(envelope["ciphertext"], validate=True) + receiver = self.suite.create_recipient_context( + enc, + self.recipient.private_key, + info=f"dstack-sealed-env-v1:{self.epoch}:0".encode(), + ) + plaintext = json.loads(receiver.open(ciphertext, aad=b"")) + self.assertEqual( + plaintext, {"env": [{"key": k, "value": v} for k, v in self.envs.items()]} + ) + wrong_epoch = self.suite.create_recipient_context( + enc, self.recipient.private_key, info=b"dstack-sealed-env-v1:0:0" + ) + with self.assertRaises(Exception): + wrong_epoch.open(ciphertext, aad=b"") + + def test_sealed_payload_round_trip_and_normalized_pins(self): + """Accept equivalent pin encodings without using legacy encryption.""" + for pin in [self.pin, "0x" + self.pin.upper()]: + with ( + self.subTest(pin=pin), + patch.object(VMM_CLI, "load_whitelist", return_value=[pin]), + ): + self.decrypt( + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + ) + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_app_epoch_is_signed_and_used_for_sealing(self): + """Bind the app epoch in both the signature and HPKE context.""" + self.response["app_epoch"] = 2 + digest = keccak( + b"dstack-sealed-env-pubkey-v1:" + + bytes.fromhex(self.app_id) + + self.epoch.to_bytes(4, "big") + + (2).to_bytes(4, "big") + + bytes.fromhex(self.response["public_key"]) + + self.timestamp.to_bytes(8, "big") + ) + self.response["signature"] = self.signer.sign_msg_hash(digest).to_bytes().hex() + payload = self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + envelope = json.loads(bytes.fromhex(payload["sealed_env"])) + self.assertEqual(envelope["version"], 1) + self.assertEqual(envelope["app_epoch"], 2) + receiver = self.suite.create_recipient_context( + base64.b64decode(envelope["enc"]), + self.recipient.private_key, + info=f"dstack-sealed-env-v1:{self.epoch}:2".encode(), + ) + plaintext = json.loads(receiver.open(base64.b64decode(envelope["ciphertext"]))) + self.assertEqual( + plaintext, {"env": [{"key": k, "value": v} for k, v in self.envs.items()]} + ) + self.response["app_epoch"] = 1 + with self.assertRaises(ValueError): + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + + def test_unpinned_signer_never_becomes_trusted(self): + """Reject recipients without an independently trusted signer.""" + for pins in [ + [], + [keys.PrivateKey(b"\x33" * 32).public_key.to_compressed_bytes().hex()], + ]: + with ( + self.subTest(pins=pins), + patch.object(VMM_CLI, "load_whitelist", return_value=pins), + patch.object(self.cli, "confirm_untrusted_signer") as confirm, + ): + with self.assertRaisesRegex(ValueError, "not pinned"): + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + confirm.assert_not_called() + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_signature_binds_every_field_and_app(self): + """Reject mutations of the signed claim or application identity.""" + cases = [ + ("public_key", "44" * 32), + ("root_epoch", 4), + ("timestamp", self.timestamp + 1), + ("signature", "00" * 65), + ("signature", "00" * 64), + ("public_key", "00" * 31), + ("root_epoch", -1), + ("root_epoch", 2**32), + ("root_epoch", True), + ("timestamp", -1), + ("timestamp", 2**64), + ("timestamp", True), + ] + for field, value in cases: + with self.subTest(field=field, value=value): + self.cli.client.request.return_value = ( + 200, + {**self.response, field: value}, + ) + with self.assertRaises(Exception): + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + self.cli.client.request.return_value = (200, self.response) + for app_id in ["55" * 20, "11" * 19]: + with self.assertRaises(ValueError): + self.cli.encrypt_env_payload(self.envs, app_id, None, True) + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_pinned_signed_key_timestamp_window(self): + """Enforce the freshness window for a trusted signer.""" + for age in [-60, 0, 300]: + with ( + self.subTest(age=age), + patch.object(VMM_CLI.time, "time", return_value=self.timestamp + age), + ): + self.decrypt( + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + ) + for age, reason in [(-61, "future"), (301, "stale")]: + with ( + self.subTest(age=age), + patch.object(VMM_CLI.time, "time", return_value=self.timestamp + age), + ): + with self.assertRaisesRegex(ValueError, reason): + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_public_key_request_contains_only_app_id(self): + """Keep epoch selection on the KMS rather than in the lookup request.""" + response = self.cli.get_sealed_env_pub_key(self.app_id) + self.assertEqual( + self.cli.client.request.call_args.kwargs["body"], + {"app_id": self.app_id}, + ) + self.assertEqual(response["root_epoch"], self.epoch) + + def test_missing_rpc_cannot_replay_a_legacy_recipient(self): + """Reject capability downgrade attempts using a captured legacy key.""" + legacy_digest = keccak( + b"dstack-env-encrypt-pubkey:" + + bytes.fromhex(self.app_id) + + bytes.fromhex(self.response["public_key"]) + ) + legacy = { + "public_key": self.response["public_key"], + "signature": self.signer.sign_msg_hash(legacy_digest).to_bytes().hex(), + } + missing = {"error": "Service not found: GetSealedEnvPubKey"} + errors = [(status, missing) for status in [400, 404]] + errors += [ + ( + 400, + { + "error": f"Request failed with status={status}, error={json.dumps(missing)}" + }, + ) + for status in ["400 Bad Request", "404 Not Found"] + ] + for error in errors: + with ( + self.subTest(error=error), + patch.object(VMM_CLI, "VmmClient") as client, + ): + client.return_value.request.side_effect = [error, (200, legacy)] + with self.assertRaises(RuntimeError): + self.cli.encrypt_env_payload( + self.envs, self.app_id, "https://kms.example", True + ) + client.return_value.request.assert_called_once() + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_errors_and_bad_responses_do_not_downgrade(self): + """Fail closed on lookup errors and malformed key responses.""" + for status, body in [ + (401, {"error": "Service not found: GetSealedEnvPubKey"}), + (403, {"error": "forbidden"}), + (404, "Not Found"), + (400, {"error": "invalid app"}), + (500, {"error": "unavailable"}), + ( + 400, + { + "error": 'Request failed with status=500 Internal Server Error, error={"error":"Service not found: GetSealedEnvPubKey"}' + }, + ), + ( + 400, + { + "error": 'Request failed with status=404 Not Found, error={"error":"other service"}' + }, + ), + (400, {"error": "Request failed with status=404 Not Found, error=invalid"}), + (200, {}), + (200, "invalid JSON"), + ]: + with self.subTest(status=status, body=body): + self.cli.client.request.return_value = (status, body) + with self.assertRaises(Exception): + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + self.cli.client.request.side_effect = TimeoutError("test timeout") + with self.assertRaises(TimeoutError): + self.cli.encrypt_env_payload(self.envs, self.app_id, None, True) + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_direct_kms_verifies_pin_and_does_not_forward_vmm_credentials(self): + """Authenticate direct KMS keys without forwarding VMM credentials.""" + with patch.object(VMM_CLI, "VmmClient") as client: + client.return_value.request.return_value = (200, self.response) + self.decrypt( + self.cli.encrypt_env_payload( + self.envs, self.app_id, "https://kms.example", True + ) + ) + client.assert_called_once_with("https://kms.example", verify_tls=False) + self.cli.get_app_env_encrypt_pub_key.assert_not_called() + + def test_image_downgrade_and_env_replacement_share_one_request(self): + """Replace the image and environment format in the same update.""" + + def rpc(method, params=None): + if method == "GetInfo": + return { + "found": True, + "info": { + "app_id": self.app_id, + "configuration": { + "image": "new", + "compose_file": '{"kms_enabled":true}', + }, + }, + } + if method == "ListImages": + return { + "images": [{"name": "new", "sealed_env": True}, {"name": "old"}] + } + return {} + + self.cli.rpc_call = Mock(side_effect=rpc) + with ( + patch.object(VMM_CLI, "parse_env_file", return_value=self.envs), + contextlib.redirect_stdout(io.StringIO()), + ): + self.cli.update_vm( + "vm", image="old", env_file="env", kms_urls=["https://kms.example"] + ) + calls = self.cli.rpc_call.call_args_list + self.assertNotIn("ResizeVm", [call.args[0] for call in calls]) + method, payload = calls[-1].args + self.assertEqual(method, "UpgradeApp") + self.assertEqual(payload["image"], "old") + self.assertIn("encrypted_env", payload) + self.assertNotIn("sealed_env", payload) + self.cli.client.request.assert_not_called() + + def test_create_update_and_update_env_send_only_the_negotiated_field(self): + """Use the selected ciphertext field consistently across CLI commands.""" + compose = json.dumps({"kms_enabled": True}) + args = argparse.Namespace( + compose="compose.json", + env_file="env", + name="vm", + image="image", + vcpu=1, + memory=1024, + disk=10, + app_id=self.app_id, + user_config=None, + port=None, + hugepages=False, + pin_numa=False, + stopped=False, + no_tee=False, + annotation=None, + disk_prealloc=None, + simulated_tee=None, + swap=None, + ppcie=False, + gpu=None, + kms_url=["https://kms.example"], + gateway_url=None, + net_queues=None, + net=None, + net_vhost=None, + kms_encrypt_url=None, + ) + + def rpc(method, params=None): + if method == "ListImages": + return {"images": [{"name": "image", **capability}]} + if method == "GetInfo": + return { + "found": True, + "info": { + "app_id": self.app_id, + "configuration": {"compose_file": compose, "image": "image"}, + }, + } + return {"id": "vm"} + + self.cli.rpc_call = Mock(side_effect=rpc) + with ( + patch.object(VMM_CLI.os.path, "exists", return_value=True), + patch.object(VMM_CLI, "read_utf8", return_value=compose), + patch.object(VMM_CLI, "parse_env_file", return_value=self.envs), + patch.object(VMM_CLI, "VmmClient") as client, + contextlib.redirect_stdout(io.StringIO()), + ): + for capability in [{"sealed_env": True}, {"sealed_env": False}, {}]: + client.return_value.request.return_value = (200, self.response) + for action in [ + lambda: self.cli.create_vm(args), + lambda: self.cli.update_vm( + "vm", env_file="env", kms_urls=args.kms_url + ), + lambda: self.cli.update_vm_env("vm", self.envs, args.kms_url), + ]: + with self.subTest(capability=capability, action=action): + self.cli.rpc_call.reset_mock() + action() + method, payload = self.cli.rpc_call.call_args.args + self.assertIn(method, ["CreateVm", "UpgradeApp"]) + if capability.get("sealed_env"): + self.decrypt(payload) + else: + self.assertIn("encrypted_env", payload) + self.assertNotIn("sealed_env", payload) + + if __name__ == "__main__": unittest.main() diff --git a/dstack/vmm/src/vmm-cli.py b/dstack/vmm/src/vmm-cli.py index ad469f981..31e48c23b 100755 --- a/dstack/vmm/src/vmm-cli.py +++ b/dstack/vmm/src/vmm-cli.py @@ -16,6 +16,7 @@ import ssl import sys import tempfile +import time import urllib.parse from typing import Any, BinaryIO, Dict, List, Optional, Tuple, Union @@ -310,6 +311,76 @@ def cmd_switch_vmm(args): print(f"Switched to VMM {selected['id'][:8]} ({url})") +def seal_env(envs, hex_public_key: str, root_epoch: int, app_epoch: int = 0) -> str: + """Return hex-encoded SealedEnv JSON using RFC 9180 base mode.""" + from pyhpke import AEADId, CipherSuite, KDFId, KEMId + + validate_uint(root_epoch, 32, "root_epoch") + validate_uint(app_epoch, 32, "app_epoch") + info = f"dstack-sealed-env-v1:{root_epoch}:{app_epoch}" + suite = CipherSuite.new( + KEMId.DHKEM_X25519_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.AES256_GCM + ) + public_key = suite.kem.deserialize_public_key( + bytes.fromhex(hex_public_key.removeprefix("0x")) + ) + enc, context = suite.create_sender_context(public_key, info=info.encode("utf-8")) + plaintext = json.dumps({"env": envs}).encode("utf-8") + envelope = { + "version": 1, + "root_epoch": root_epoch, + "app_epoch": app_epoch, + "enc": base64.b64encode(enc).decode("ascii"), + "ciphertext": base64.b64encode(context.seal(plaintext, aad=b"")).decode( + "ascii" + ), + } + return json.dumps(envelope, separators=(",", ":")).encode("utf-8").hex() + + +def validate_uint(value: int, bits: int, name: str) -> None: + """Require an integer in the specified unsigned range.""" + if type(value) is not int or not 0 <= value < 1 << bits: + raise ValueError(f"{name} must be a uint{bits}") + + +def verify_sealed_env_pubkey(response: Dict, app_id: str) -> None: + """Authenticate a SealedEnv key against the locally pinned epoch signers.""" + if not CRYPTO_AVAILABLE: + raise ImportError("Install the VMM CLI requirements to verify SealedEnv keys") + app_id_bytes = bytes.fromhex(app_id.removeprefix("0x")) + public_key = bytes.fromhex(response["public_key"].removeprefix("0x")) + signature = bytes.fromhex(response["signature"].removeprefix("0x")) + epoch, timestamp = response["root_epoch"], response["timestamp"] + response_app_epoch = response.get("app_epoch", 0) + validate_uint(response_app_epoch, 32, "app_epoch") + validate_uint(epoch, 32, "root_epoch") + validate_uint(timestamp, 64, "timestamp") + now = int(time.time()) + if now - timestamp > 300: + raise ValueError("SealedEnv public key timestamp is stale") + if timestamp - now > 60: + raise ValueError("SealedEnv public key timestamp is in the future") + if len(app_id_bytes) != 20 or len(public_key) != 32 or len(signature) != 65: + raise ValueError("Invalid SealedEnv app ID, public key or signature length") + digest = keccak( + b"dstack-sealed-env-pubkey-v1:" + + app_id_bytes + + epoch.to_bytes(4, "big") + + response_app_epoch.to_bytes(4, "big") + + public_key + + timestamp.to_bytes(8, "big") + ) + signer = keys.Signature(signature_bytes=signature).recover_public_key_from_msg_hash( + digest + ) + pins = {bytes.fromhex(pin.removeprefix("0x")) for pin in load_whitelist()} + if signer.to_compressed_bytes() not in pins: + raise ValueError( + "SealedEnv signer is not pinned; configure a trusted epoch signer with kms add" + ) + + def encrypt_env(envs, hex_public_key: str) -> str: """Encrypts environment variables using a one-time X25519 key exchange and AES-GCM. @@ -776,6 +847,60 @@ def list_images(self, json_output: bool = False) -> None: rows = [[img["name"], img.get("version", "-")] for img in images] print(format_table(rows, headers)) + def image_supports_sealed_env(self, image: Optional[str]) -> bool: + """Read the selected image capability, treating missing metadata as legacy.""" + if not image: + return False + images = self.rpc_call("ListImages").get("images", []) + return any( + item.get("name") == image and item.get("sealed_env") is True + for item in images + ) + + def get_sealed_env_pub_key( + self, + app_id: str, + kms_url: Optional[str] = None, + ) -> Dict: + """Return the authenticated current key; never downgrade on an unsigned error.""" + # KMS RA-TLS is authenticated by the pinned signer below. The VMM + # path retains its configured TLS verification and authentication. + client = VmmClient(kms_url, verify_tls=False) if kms_url else self.client + status, response = client.request( + "POST", + "/prpc/GetSealedEnvPubKey?json", + headers={"Content-Type": "application/json"}, + body={"app_id": app_id}, + ) + if status != 200: + raise RuntimeError(f"GetSealedEnvPubKey failed with HTTP {status}") + if not isinstance(response, dict): + raise ValueError("Invalid GetSealedEnvPubKey response") + verify_sealed_env_pubkey(response, app_id) + return response + + def encrypt_env_payload( + self, + envs: Dict[str, str], + app_id: str, + kms_url: Optional[str], + supports_sealed_env: bool, + ) -> Dict: + """Select encryption using the target image capability and KMS RPC support.""" + envs_list = [{"key": k, "value": v} for k, v in envs.items()] + if supports_sealed_env: + key = self.get_sealed_env_pub_key(app_id, kms_url) + return { + "sealed_env": seal_env( + envs_list, + key["public_key"], + key["root_epoch"], + key.get("app_epoch", 0), + ) + } + public_key = self.get_app_env_encrypt_pub_key(app_id, kms_url) + return {"encrypted_env": encrypt_env(envs_list, public_key)} + def get_app_env_encrypt_pub_key( self, app_id: str, kms_url: Optional[str] = None ) -> Dict: @@ -828,7 +953,10 @@ def get_app_env_encrypt_pub_key( if signer_pubkey: whitelist = load_whitelist() - if whitelist and signer_pubkey not in whitelist: + normalized_signer = signer_pubkey.removeprefix("0x") + if whitelist and normalized_signer not in { + pin.removeprefix("0x") for pin in whitelist + }: print( f"WARNING: Signer {signer_pubkey} is not in the trusted whitelist!" ) @@ -1039,10 +1167,14 @@ def create_vm(self, args) -> None: encrypt_url = args.kms_encrypt_url or ( args.kms_url[0] if args.kms_url else None ) - encrypt_pubkey = self.get_app_env_encrypt_pub_key(app_id, encrypt_url) - print(f"Encrypting environment variables with key: {encrypt_pubkey}") - envs_list = [{"key": k, "value": v} for k, v in envs.items()] - params["encrypted_env"] = encrypt_env(envs_list, encrypt_pubkey) + params.update( + self.encrypt_env_payload( + envs, + app_id, + encrypt_url, + self.image_supports_sealed_env(args.image), + ) + ) response = self.rpc_call("CreateVm", params) print(f"Created VM with ID: {response.get('id')}") return response.get("id") @@ -1067,16 +1199,15 @@ def update_vm_env( vm_configuration = vm_info_response["info"].get("configuration") or {} compose_file = vm_configuration.get("compose_file") - # Now get the encryption key for the app - encrypt_pubkey = self.get_app_env_encrypt_pub_key( - app_id, kms_urls[0] if kms_urls else None + payload = {"id": vm_id} + payload.update( + self.encrypt_env_payload( + envs, + app_id, + kms_urls[0], + self.image_supports_sealed_env(vm_configuration.get("image")), + ) ) - print(f"Encrypting environment variables with key: {encrypt_pubkey}") - envs_list = [{"key": k, "value": v} for k, v in envs.items()] - encrypted_env = encrypt_env(envs_list, encrypt_pubkey) - - # Use UpdateApp with the VM ID - payload = {"id": vm_id, "encrypted_env": encrypted_env} if compose_file: try: @@ -1171,12 +1302,14 @@ def update_vm( resize_params["image"] = image updates.append(f"image: {image}") - if resize_params: + if resize_params and not env_file: resize_params["id"] = vm_id self.rpc_call("ResizeVm", resize_params) # handle upgrade operations (compose, env, user_config, ports, gpu) upgrade_params = {"id": vm_id} + if env_file: + upgrade_params.update(resize_params) # handle compose file updates (docker-compose, prelaunch script, swap) needs_compose_update = ( @@ -1238,11 +1371,18 @@ def update_vm( vm_configuration = vm_info_response["info"].get("configuration") or {} compose_file_content = vm_configuration.get("compose_file") - encrypt_pubkey = self.get_app_env_encrypt_pub_key( - app_id, kms_urls[0] if kms_urls else None + upgrade_params.update( + self.encrypt_env_payload( + envs, + app_id, + kms_urls[0], + self.image_supports_sealed_env( + image + if image is not None + else vm_configuration.get("image") + ), + ) ) - envs_list = [{"key": k, "value": v} for k, v in envs.items()] - upgrade_params["encrypted_env"] = encrypt_env(envs_list, encrypt_pubkey) updates.append("environment variables") # update allowed_envs in compose file if needed diff --git a/dstack/vmm/ui/build.mjs b/dstack/vmm/ui/build.mjs index 00c06fc5a..85a814731 100644 --- a/dstack/vmm/ui/build.mjs +++ b/dstack/vmm/ui/build.mjs @@ -24,6 +24,9 @@ function canonicalId(absPath) { } function resolveModule(parentId, request) { + if (request === 'crypto') { + return canonicalId(path.join(TS_OUT_DIR, 'lib/browserCrypto.js')); + } const base = parentId ? path.dirname(path.resolve(ROOT, parentId)) : MODULE_DIR; const absPath = nodeRequire.resolve(request, { paths: [base] }); return canonicalId(absPath); @@ -162,6 +165,7 @@ async function compileTypeScript() { await fs.rm(TS_OUT_DIR, { recursive: true, force: true }); await run(TSC, ['--project', path.join(ROOT, 'tsconfig.json')]); await copyDir(path.join(SOURCE_DIR, 'templates'), path.join(TS_OUT_DIR, 'templates')); + await fs.writeFile(path.join(TS_OUT_DIR, 'package.json'), JSON.stringify({ type: 'commonjs' })); } async function build({ watch = false } = {}) { diff --git a/dstack/vmm/ui/package-lock.json b/dstack/vmm/ui/package-lock.json index b04b7ed8d..1d9101ebf 100644 --- a/dstack/vmm/ui/package-lock.json +++ b/dstack/vmm/ui/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "vmm-ui", "version": "0.1.0", + "dependencies": { + "@hpke/core": "1.10.0", + "@hpke/dhkem-x25519": "1.8.0" + }, "devDependencies": { "@types/node": "^20.11.30", "protobufjs": "^7.5.8", @@ -64,6 +68,47 @@ "node": ">=6.9.0" } }, + "node_modules/@hpke/common": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.12.0.tgz", + "integrity": "sha512-os6y44MDJmrx7MVAG8rb6LyV3BEXSFtX/g4KMhKxVtpXvCWwL4fbBbbtG45fkx25JmxpCJdGzla9asLNMI99/A==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dajiaji" + } + }, + "node_modules/@hpke/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.10.0.tgz", + "integrity": "sha512-toCyUYXvU5KdDjqtWeMRRhR09R2us4OeHGo5YkdDOpo8+QJ6sIpuuOGLDu0VbNBskf5acHgsqgF53w2mU1NNYQ==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.11.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dajiaji" + } + }, + "node_modules/@hpke/dhkem-x25519": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@hpke/dhkem-x25519/-/dhkem-x25519-1.8.0.tgz", + "integrity": "sha512-S1MWWkAfu+TFxySgv5+2P3O4Mx/jk7BsoplzQaA1s3sfUJVJ2UsZsSzSsMc+FXJumLXncoJFlO6mK6mDGspfmA==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.10.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@jsdoc/salty": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.9.tgz", diff --git a/dstack/vmm/ui/package.json b/dstack/vmm/ui/package.json index b88e34045..985719998 100644 --- a/dstack/vmm/ui/package.json +++ b/dstack/vmm/ui/package.json @@ -6,12 +6,17 @@ "scripts": { "generate:proto": "bash scripts/build_proto.sh", "build": "node build.mjs", - "watch": "node build.mjs --watch" + "watch": "node build.mjs --watch", + "test": "DSTACK_UI_OUT=dist/index.html npm run build && node --test tests/*.test.cjs" }, "devDependencies": { "@types/node": "^20.11.30", "protobufjs": "^7.5.8", "protobufjs-cli": "^1.3.3", "typescript": "^5.4.5" + }, + "dependencies": { + "@hpke/core": "1.10.0", + "@hpke/dhkem-x25519": "1.8.0" } } diff --git a/dstack/vmm/ui/src/composables/useVmManager.ts b/dstack/vmm/ui/src/composables/useVmManager.ts index f81aede22..bb4178a12 100644 --- a/dstack/vmm/ui/src/composables/useVmManager.ts +++ b/dstack/vmm/ui/src/composables/useVmManager.ts @@ -4,6 +4,7 @@ declare const Vue: any; const { ref, computed, watch, onMounted } = Vue; import type { vmm as VmmTypes } from '../proto/vmm_rpc'; +import { encryptEnvPayload } from '../lib/envEncryption'; // Types based on Rust definitions type VmConfiguration = VmmTypes.IVmConfiguration; @@ -379,7 +380,7 @@ fi const vmForm: Ref = ref(createVmFormState(preLaunchScript)); - const availableImages = ref([] as Array<{ name: string; version?: string }>); + const availableImages = ref([] as Array<{ name: string; version?: string; sealed_env?: boolean }>); const availableGpus = ref([] as Array); const availableGpuProducts = ref([] as Array); const allowAttachAllGpus = ref(false); @@ -643,6 +644,7 @@ type CreateVmPayloadSource = { disk_size: number; ports: PortFormEntry[]; encrypted_env?: Uint8Array; + sealed_env?: Uint8Array; app_id?: string | null; user_config?: string; hugepages?: boolean; @@ -670,6 +672,7 @@ type CreateVmPayloadSource = { disk_size: Math.max(0, Number(source.disk_size) || 0), ports: normalizedPorts, encrypted_env: source.encrypted_env, + sealed_env: source.sealed_env, app_id: source.app_id || undefined, user_config: source.user_config || '', hugepages: !!source.hugepages, @@ -872,6 +875,9 @@ type CreateVmPayloadSource = { return image?.version; }; + const supportsSealedEnv = (imageName: string) => + availableImages.value.find((img) => img.name === imageName)?.sealed_env ?? false; + // Mirror of dstack-types::version::Version::parse. The prerelease/build // suffix is stripped at the first '-' or '+', then major.minor[.patch] are // read as decimal integers with patch defaulting to 0. So 0.6.1, 0.6.1-rc1, @@ -1222,7 +1228,7 @@ type CreateVmPayloadSource = { async function encryptEnv(envs: EncryptedEnvEntry[], kmsEnabled: boolean, appId: string | null) { if (!kmsEnabled || envs.length === 0) { - return undefined; + return {}; } let appIdToUse = appId; if (!appIdToUse) { @@ -1230,8 +1236,7 @@ type CreateVmPayloadSource = { appIdToUse = await calcAppId(appCompose); } const keyBytes = hexToBytes(appIdToUse); - const response = await vmmRpc.getAppEnvEncryptPubKey({ app_id: keyBytes }); - return encryptEnvWithKey(envs, response.public_key); + return encryptEnvPayload(vmmRpc, envs, keyBytes, encryptEnvWithKey, supportsSealedEnv(vmForm.value.image)); } async function encryptEnvWithKey(envs: EncryptedEnvEntry[], publicKeyBytes: Uint8Array) { @@ -1281,7 +1286,7 @@ type CreateVmPayloadSource = { memory: vmForm.value.memory, disk_size: vmForm.value.disk_size, ports: vmForm.value.ports, - encrypted_env: encryptedEnv || undefined, + ...encryptedEnv, app_id: vmForm.value.app_id || undefined, user_config: vmForm.value.user_config, hugepages: vmForm.value.hugepages, @@ -1362,14 +1367,16 @@ type CreateVmPayloadSource = { const composeWasExplicitlyUpdated = updateDialog.value.updateCompose; let composeNeedsUpdate = composeWasExplicitlyUpdated; - let encryptedEnvPayload; + let encryptedEnvPayload = {}; if (updateDialog.value.resetSecrets) { - const keyResponse = await vmmRpc.getAppEnvEncryptPubKey({ app_id: hexToBytes(vm.app_id || '') }); - encryptedEnvPayload = await encryptEnvWithKey(updateDialog.value.encryptedEnvs, keyResponse.public_key); + encryptedEnvPayload = await encryptEnvPayload( + vmmRpc, updateDialog.value.encryptedEnvs, hexToBytes(vm.app_id || ''), encryptEnvWithKey, + supportsSealedEnv(updated.image), + ); composeNeedsUpdate = true; } body.compose_file = composeNeedsUpdate ? await makeUpdateComposeFile() : undefined; - body.encrypted_env = encryptedEnvPayload; + Object.assign(body, encryptedEnvPayload); body.user_config = updated.user_config; body.update_ports = true; body.ports = normalizePorts(updated.ports); diff --git a/dstack/vmm/ui/src/lib/browserCrypto.ts b/dstack/vmm/ui/src/lib/browserCrypto.ts new file mode 100644 index 000000000..37da2d201 --- /dev/null +++ b/dstack/vmm/ui/src/lib/browserCrypto.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// HPKE's Node fallback uses the browser's native Web Crypto in this bundle. +export const webcrypto = globalThis.crypto; diff --git a/dstack/vmm/ui/src/lib/envEncryption.ts b/dstack/vmm/ui/src/lib/envEncryption.ts new file mode 100644 index 000000000..c4159b8f0 --- /dev/null +++ b/dstack/vmm/ui/src/lib/envEncryption.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +import { Aes256Gcm, CipherSuite, HkdfSha256 } from '@hpke/core'; +import { DhkemX25519HkdfSha256 } from '@hpke/dhkem-x25519'; +import type { vmm as VmmTypes } from '../proto/vmm_rpc'; + +export type EnvEntry = { key: string; value: string }; +type EnvPayload = { sealed_env?: Uint8Array; encrypted_env?: Uint8Array }; +type KeyClient = { + getSealedEnvPubKey(request: VmmTypes.IAppId): Promise; + getAppEnvEncryptPubKey(request: VmmTypes.IAppId): Promise; +}; + +/** Keep key retrieval on the authenticated, same-origin VMM transport. */ +export async function encryptEnvPayload( + client: KeyClient, + envs: EnvEntry[], + appId: Uint8Array, + encryptLegacy: (envs: EnvEntry[], key: Uint8Array) => Promise, + supportsSealedEnv: boolean, +): Promise { + const legacyPayload = async () => { + const legacy = await client.getAppEnvEncryptPubKey({ app_id: appId }); + return { encrypted_env: await encryptLegacy(envs, legacy.public_key) }; + }; + if (!supportsSealedEnv) return legacyPayload(); + const response = await client.getSealedEnvPubKey({ app_id: appId }); + const epoch = response.root_epoch; + const appEpoch = response.app_epoch ?? 0; + if (!Number.isInteger(appEpoch) || appEpoch < 0 || appEpoch > 0xffffffff) throw new Error('invalid app epoch'); + if (!Number.isInteger(epoch) || epoch < 0 || epoch > 0xffffffff || response.public_key?.length !== 32) { + throw new Error('invalid sealed env public key response'); + } + const suite = new CipherSuite({ + kem: new DhkemX25519HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); + const recipientPublicKey = await suite.kem.deserializePublicKey(new Uint8Array(response.public_key)); + const { enc, ct } = await suite.seal({ + recipientPublicKey, + info: new TextEncoder().encode(`dstack-sealed-env-v1:${epoch}:${appEpoch}`), + }, new TextEncoder().encode(JSON.stringify({ env: envs }))); + const base64 = (bytes: ArrayBuffer) => btoa(Array.from(new Uint8Array(bytes), (byte) => String.fromCharCode(byte)).join('')); + return { sealed_env: new TextEncoder().encode(JSON.stringify({ + version: 1, + root_epoch: epoch, + app_epoch: appEpoch, + enc: base64(enc), + ciphertext: base64(ct), + })) }; +} diff --git a/dstack/vmm/ui/src/lib/vmmRpcClient.ts b/dstack/vmm/ui/src/lib/vmmRpcClient.ts index 683f499fd..e1ae87505 100644 --- a/dstack/vmm/ui/src/lib/vmmRpcClient.ts +++ b/dstack/vmm/ui/src/lib/vmmRpcClient.ts @@ -8,6 +8,13 @@ const textDecoder = new TextDecoder(); const EMPTY_BODY = new Uint8Array(); let cachedClient: any; +export class RpcError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = 'RpcError'; + } +} + function decodePrpcError(buffer: Uint8Array) { try { if (buffer && buffer.length > 0) { @@ -76,7 +83,7 @@ export function getVmmRpcClient(basePath = '/prpc') { .then(async (response) => { const buffer = new Uint8Array(await response.arrayBuffer()); if (!response.ok) { - callback(new Error(decodePrpcError(buffer))); + callback(new RpcError(decodePrpcError(buffer), response.status)); return; } callback(null, buffer); diff --git a/dstack/vmm/ui/tests/envEncryption.test.cjs b/dstack/vmm/ui/tests/envEncryption.test.cjs new file mode 100644 index 000000000..bc3af9cf0 --- /dev/null +++ b/dstack/vmm/ui/tests/envEncryption.test.cjs @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { runInNewContext } = require('node:vm'); +const { webcrypto } = require('node:crypto'); +const { CipherSuite, Aes256Gcm, HkdfSha256 } = require('@hpke/core'); +const { DhkemX25519HkdfSha256 } = require('@hpke/dhkem-x25519'); +const { encryptEnvPayload } = require('../build/ts/lib/envEncryption.js'); +const { RpcError } = require('../build/ts/lib/vmmRpcClient.js'); +const { vmm } = require('../build/ts/proto/vmm_rpc.js'); +const { prpc } = require('../build/ts/proto/prpc.js'); + +const suite = new CipherSuite({ kem: new DhkemX25519HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes256Gcm() }); +const appId = new Uint8Array(20).fill(0x11); +const envs = [{ key: 'GREETING', value: 'Hello 世界' }]; +const missing = 'Service not found: GetSealedEnvPubKey'; + +async function recipient() { + const pair = await suite.kem.generateKeyPair(); + const public_key = new Uint8Array(await suite.kem.serializePublicKey(pair.publicKey)); + return { pair, response: { public_key, root_epoch: 7, timestamp: 123, signature: new Uint8Array(65) } }; +} + +async function open(pair, bytes, epoch) { + const envelope = JSON.parse(new TextDecoder().decode(bytes)); + assert.equal(envelope.version, 1); + const pt = await suite.open({ + recipientKey: pair.privateKey, + enc: Buffer.from(envelope.enc, 'base64'), + info: new TextEncoder().encode(`dstack-sealed-env-v1:${epoch ?? envelope.root_epoch}:${envelope.app_epoch}`), + }, Buffer.from(envelope.ciphertext, 'base64')); + return JSON.parse(new TextDecoder().decode(pt)); +} + +const noLegacy = async () => { throw new Error('unexpected legacy fallback'); }; + +test('seals JSON bytes with HPKE, binds epoch, and rejects tampering', async () => { + const { pair, response } = await recipient(); + for (const epoch of [0, 7, 0xffffffff]) { + const payload = await encryptEnvPayload({ + getSealedEnvPubKey: async (request) => { + assert.deepEqual(request, { app_id: appId }); + return { ...response, root_epoch: epoch }; + }, + getAppEnvEncryptPubKey: noLegacy, + }, envs, appId, noLegacy, true); + assert.deepEqual(Object.keys(payload), ['sealed_env']); + assert.deepEqual(await open(pair, payload.sealed_env), { env: envs }); + await assert.rejects(open(pair, payload.sealed_env, epoch === 0 ? 1 : 0)); + const modified = JSON.parse(new TextDecoder().decode(payload.sealed_env)); + const ct = Buffer.from(modified.ciphertext, 'base64'); + ct[0] ^= 1; + modified.ciphertext = ct.toString('base64'); + await assert.rejects(open(pair, new TextEncoder().encode(JSON.stringify(modified)))); + } +}); + +test('old or unknown images use legacy encryption even with a capable KMS', async () => { + for (const capability of [undefined, false]) { + const payload = await encryptEnvPayload({ + getSealedEnvPubKey: async () => { throw new Error('must not request SealedEnv'); }, + getAppEnvEncryptPubKey: async () => ({ public_key: new Uint8Array(32) }), + }, envs, appId, async () => new Uint8Array([7]), capability); + assert.deepEqual(payload, { encrypted_env: new Uint8Array([7]) }); + } +}); + +test('uses and authenticates the app epoch returned by the KMS', async () => { + const { pair, response } = await recipient(); + const payload = await encryptEnvPayload({ + getSealedEnvPubKey: async () => ({ ...response, app_epoch: 2 }), + getAppEnvEncryptPubKey: noLegacy, + }, envs, appId, noLegacy, true); + assert.deepEqual(await open(pair, payload.sealed_env), { env: envs }); + const envelope = JSON.parse(new TextDecoder().decode(payload.sealed_env)); + assert.equal(envelope.app_epoch, 2); + envelope.app_epoch = 3; + await assert.rejects(open(pair, new TextEncoder().encode(JSON.stringify(envelope)))); +}); + +test('network, HTTP, authentication and unrelated service errors never downgrade', async () => { + for (const error of [ + new RpcError(missing, 404), new RpcError(missing, 400), + new RpcError(`Request failed with status=404 Not Found, error=${JSON.stringify({ error: missing })}`, 400), + new Error('network failed'), new Error(missing), new RpcError('Not Found', 404), + new RpcError(missing, 401), new RpcError(missing, 403), new RpcError(missing, 500), + new RpcError('Service not found: DifferentMethod', 404), + new RpcError('failed to validate the server attestation', 400), + new RpcError(`Request failed with status=500 Internal Server Error, error={"error":"${missing}"}`, 400), + ]) { + await assert.rejects(encryptEnvPayload({ + getSealedEnvPubKey: async () => { throw error; }, + getAppEnvEncryptPubKey: noLegacy, + }, envs, appId, noLegacy, true), (actual) => actual === error); + } +}); + +test('invalid key or epoch never downgrades after a successful key RPC', async () => { + const { response } = await recipient(); + for (const invalid of [ + { public_key: new Uint8Array(31) }, { public_key: new Uint8Array(33) }, + { public_key: new Uint8Array(32) }, + { root_epoch: -1 }, { root_epoch: 1.5 }, { root_epoch: 0x100000000 }, + ]) { + let legacyCalls = 0; + await assert.rejects(encryptEnvPayload({ + getSealedEnvPubKey: async () => ({ ...response, ...invalid }), + getAppEnvEncryptPubKey: async () => { legacyCalls++; return response; }, + }, envs, appId, noLegacy, true)); + assert.equal(legacyCalls, 0); + } +}); + +// Execute the shipped bundle with browser Web Crypto and protobuf transport. +// Vue lifecycle hooks are inert so tests do not start background polling. +function consoleApp(response, unsupported = false) { + const calls = []; + const alerts = []; + let manager; + const html = readFileSync(new URL('../dist/index.html', `file://${__filename}`), 'utf8'); + const scripts = [...html.matchAll(/