diff --git a/.github/workflows/trusted-workload-launcher-release.yml b/.github/workflows/trusted-workload-launcher-release.yml new file mode 100644 index 0000000..acea21c --- /dev/null +++ b/.github/workflows/trusted-workload-launcher-release.yml @@ -0,0 +1,92 @@ +name: trusted-workload-launcher Release +on: + workflow_dispatch: {} + push: + tags: + - 'trusted-workload-launcher-v*' + +permissions: + contents: write + packages: write + attestations: write + id-token: write + +jobs: + build-and-attest: + runs-on: ubuntu-latest + env: + IMAGE_REGISTRY: docker.io + IMAGE_REPOSITORY: ${{ vars.DOCKERHUB_ORG }}/trusted-workload-launcher + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse version from tag + run: | + VERSION=${GITHUB_REF#refs/tags/trusted-workload-launcher-v} + if [ -z "${VERSION}" ]; then + echo "Unable to parse version from ref: ${GITHUB_REF}" >&2 + exit 1 + fi + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "IMAGE_REFERENCE=${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${VERSION}" >> "$GITHUB_ENV" + echo "Parsed version: ${VERSION}" + + - name: Run launcher tests + working-directory: trusted-workload-launcher + run: ./tests/run-tests.sh + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker registry + uses: docker/login-action@v3 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: trusted-workload-launcher + file: trusted-workload-launcher/docker/Dockerfile + push: true + tags: docker.io/${{ vars.DOCKERHUB_ORG }}/trusted-workload-launcher:${{ env.VERSION }} + platforms: linux/amd64 + labels: | + org.opencontainers.image.title=trusted-workload-launcher + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ env.VERSION }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: docker.io/${{ vars.DOCKERHUB_ORG }}/trusted-workload-launcher + subject-digest: ${{ steps.build-and-push.outputs.digest }} + push-to-registry: true + + - name: Publish summary + env: + IMAGE_REFERENCE: ${{ env.IMAGE_REFERENCE }} + IMAGE_DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: | + { + echo "## trusted-workload-launcher image" + echo "" + echo "- Tag: \`${IMAGE_REFERENCE}\`" + echo "- Digest: \`${IMAGE_DIGEST}\`" + echo "- Sigstore: https://search.sigstore.dev/?hash=${IMAGE_DIGEST}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Release + uses: softprops/action-gh-release@v1 + with: + body: | + ## trusted-workload-launcher image (SHA256) + + - Image: `${{ env.IMAGE_REFERENCE }}` + - Digest: `${{ steps.build-and-push.outputs.digest }}` + - Verification: https://search.sigstore.dev/?hash=${{ steps.build-and-push.outputs.digest }} diff --git a/README.md b/README.md index 4e6e5e5..edb97ef 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ Implementation details and infrastructure patterns. | Example | Description | |---------|-------------| -| [launcher](./launcher) | Generic launcher pattern for Docker Compose apps | +| [launcher](./launcher) | Generic launcher pattern for Docker Compose apps (auto-update) | +| [trusted-workload-launcher](./trusted-workload-launcher) | Run a pinned Git commit in a TEE. | | [prelaunch-script](./prelaunch-script) | Pre-launch script patterns (Phala Cloud) | | [private-docker-image-deployment](./private-docker-image-deployment) | Using private Docker registries | | [attestation/rtmr3-based](./attestation/rtmr3-based) | RTMR3-based attestation (legacy) | diff --git a/trusted-workload-launcher/.dockerignore b/trusted-workload-launcher/.dockerignore new file mode 100644 index 0000000..b2266cf --- /dev/null +++ b/trusted-workload-launcher/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +tests +examples +README.md diff --git a/trusted-workload-launcher/README.md b/trusted-workload-launcher/README.md new file mode 100644 index 0000000..17a9958 --- /dev/null +++ b/trusted-workload-launcher/README.md @@ -0,0 +1,365 @@ +# trusted-workload-launcher + +A minimal, auditable launcher image for dstack. Given a config file that +names an upstream Git repo and a full commit SHA, the launcher fetches that +exact commit, verifies `HEAD` after checkout, and runs the workload's own +entry point script — with no fallback to branches, tags, or short SHAs. + +"Trusted" in the name refers to what a dstack deployment using this image +can produce — a *trusted workload deployment* — not to any intrinsic +property of the workload code. The launcher's job is to make the identity +of what runs in the TEE checkable: it combines TEE attestation with an +auditable image digest and an attested config that names the workload +commit. Whether the workload at that commit is itself trustworthy is up to +the auditor. + +By convention, **the workload repo provides its own bash entry point at +`entrypoint.sh`** (default mode). This keeps install/build/run logic inside +the workload repo, where it is covered by source provenance of the pinned +`COMMIT_SHA` and is **not** a trust-bearing field in the launcher config. +A verifier therefore audits two things: the launcher image's identity, and +the `REPO_URL` + `COMMIT_SHA` pair (plus `REPO_SUBDIR` and +`ENTRYPOINT_SCRIPT` when used, since each selects *which* script runs) in +the attested config. + +The launcher image is **generic**: its digest attests the launcher's +implementation, not the workload. The workload identity comes from the +config file, which must be attested separately (see [Trust model](#trust-model)). + +This is a separate example from [`launcher/`](../launcher), which is a +Docker Compose auto-update pattern. This launcher does the opposite — it +*prevents* auto-update by pinning to one full commit SHA per deploy. + +## What this is — and what it is not + +The launcher is **not** the workload. It is intentionally tiny so the contents +of this directory at a given commit can be read end-to-end before trusting it +to bootstrap anything else. + +| Layer | Lives in | Job | +| --- | --- | --- | +| Launcher | this directory | Fetch and run *one* program from *one* pinned upstream commit. | +| Workload | a separate upstream Git repo | The actual application — business logic, secrets handling, network surface. | + +The launcher's only job is to make sure that, given a config, the bytes that +end up running inside the dstack VM are exactly the bytes at a specific commit +in a specific upstream Git repo. Everything else lives in that upstream repo. + +## Trust model + +The launcher image and the config file are two separate trust inputs, and a +verifier must attest both. The launcher image alone does **not** determine +which workload commit runs. + +For a step-by-step verifier checklist that chains dstack attestation to the +pinned workload commit, see [`VERIFY.md`](./VERIFY.md). + +``` +launcher image digest ──► launcher implementation identity + (this directory at commit L; the release + workflow publishes a Sigstore build-provenance + attestation that binds the image digest to a + specific GitHub workflow run / repo / ref / + SHA. This is a signed chain of custody, not a + claim of bit-for-bit reproducibility.) + +launcher config file ──► workload pin + (REPO_URL + full COMMIT_SHA U; selects which + upstream commit gets fetched and run) + + ──► workload running inside the TEE + = workload repo at commit U, + starting from its entrypoint.sh +``` + +The published launcher image is a **generic** runner: the same image digest +can drive any pinned workload, depending on which config it is started with. +The config is therefore part of the deployment's trust surface and must be +attested separately. dstack provides a few standard ways to do that — pick +the one that matches how strictly you want to bind the workload pin to the +attestation: + +| Binding | What attests the config | When to use | +| --- | --- | --- | +| **dstack app config / `compose_hash` / `config_id`** | dstack measures the compose file (and any files it references that participate in compose-hash) into the TEE's attested config; a verifier compares against an expected hash | Default for production. The config travels with the deployment and is covered by the existing dstack attestation chain. | +| **Baked into a derived image** | Build a small downstream image `FROM @sha256:…` that `COPY`s the config in; deploy that derived image. The derived image digest then implies both the launcher and the pin | When you want image-digest-only binding (one digest fully determines the workload). | +| **Runtime bind-mount from the host** | Nothing — the host can swap the file | Local development only. Do not use for production trust. | + +Once the config is attested by one of the first two options, a relying party +verifies in four steps: + +1. The launcher image digest in the dstack attestation matches the digest + published by the release workflow for this directory at commit `L` + (verified via the Sigstore build-provenance attestation, which binds + the digest to a specific GitHub Actions workflow run / repo / ref / + SHA — see [`VERIFY.md`](./VERIFY.md) for the exact check). +2. The launcher script at commit `L` is the audited script — small, parses + (does not source) its config, refuses anything but a full commit SHA, and + verifies `HEAD` after checkout. +3. The launcher config the runtime actually loaded is the attested config + (via `compose_hash` / `config_id`, or by deriving it from the derived + image's digest). +4. The `COMMIT_SHA` in that config is the workload commit the relying party + expected. + +Because the launcher does no fallback — missing or invalid commit is a hard +failure — there is exactly one workload commit that can ever boot from a +given (launcher image digest, attested config) pair. + +## CLI + +``` +trusted-workload-launcher +``` + +The launcher is a single bash script (`bin/trusted-workload-launcher`). It +depends only on `bash`, `git`, and POSIX coreutils. It is **not** sourced +and **does not source** the config. In default mode, the only bytes it +executes are those of the workload repo's `entrypoint.sh` at the pinned +`COMMIT_SHA`. In advanced mode (see below), it additionally executes the +configured `INSTALL_CMD` / `RUN_CMD` via `bash -c`. + +## Config contract + +An env-file with `KEY=VALUE` lines. Comments start with `#`. Surrounding +matching single or double quotes are stripped (one layer). Unknown keys are +rejected. The config is parsed, not sourced — no command substitution and +no shell expansion in the parse step. + +### Required + +| Key | Meaning | +| --- | --- | +| `REPO_URL` | Git URL of the upstream workload repo (`https://…` or `git@…`). | +| `COMMIT_SHA` | **Full** 40-hex SHA-1 or 64-hex SHA-256. Branches, tags, and short SHAs are rejected. | +| `WORK_DIR` | Local directory used as the checkout. Created if missing. Reused on subsequent runs as long as the existing clone's `origin` URL matches `REPO_URL`. | + +### Optional + +| Key | Meaning | +| --- | --- | +| `REPO_SUBDIR` | Relative directory inside the repo to `cd` into before running the entry point or `RUN_CMD`. Must not be absolute and must not contain `..`. | +| `ENTRYPOINT_SCRIPT` | Relative path (inside `REPO_SUBDIR` or repo root) to the bash entry script for default mode. Defaults to `entrypoint.sh`. Must not be absolute and must not contain `..`. Trust-bearing in default mode — like `REPO_SUBDIR`, it selects which script runs. | +| `CHILD_ENV_FILE` | Path to a separate env file. Each `KEY=VALUE` line is `export`ed into the environment seen by `entrypoint.sh` / `INSTALL_CMD` / `RUN_CMD`. The file is parsed line-by-line just like the main config (not sourced). | +| `RUN_CMD` | **Advanced.** Shell command to exec instead of the default `entrypoint.sh`. Use only when the workload repo cannot host its own entry script. | +| `INSTALL_CMD` | **Advanced.** Shell command to run before `RUN_CMD`. Only valid alongside `RUN_CMD`. | + +### Default mode: `entrypoint.sh` in the workload repo + +Recommended for every workload you control. The workload repo provides a +bash script at the fixed path `entrypoint.sh` (at the repo root, or at +`REPO_SUBDIR/entrypoint.sh` if `REPO_SUBDIR` is set). The launcher runs it +with `bash entrypoint.sh` after checkout — **no executable bit is +required**. All install/build/run logic lives in that script. + +In this mode the trust-bearing config in the launcher's config file is +`REPO_URL` + `COMMIT_SHA` (and `REPO_SUBDIR` if used, since it selects +which `entrypoint.sh` runs). `WORK_DIR` is local plumbing — it names +where on the in-TEE filesystem to keep the checkout — and is not +trust-bearing. `CHILD_ENV_FILE` (and any env it provides) can change the +script's runtime behavior but does not change the bytes that run; if the +deployment uses it, audit it the same way you audit any other runtime +configuration the deployment ships with. + +Because `entrypoint.sh`'s bytes are pinned by `COMMIT_SHA` and stored in +the workload repo, they are covered by source provenance of the pinned +commit. The verifier does not need to extract or audit any command string +out of the launcher config. + +### Advanced mode: explicit `RUN_CMD` / `INSTALL_CMD` + +Use this when the workload repo cannot be modified to add a +`entrypoint.sh` (e.g. you are pinning a third-party repo unchanged). +Setting `RUN_CMD` switches the launcher into advanced mode; if you need +more than one command, set `INSTALL_CMD` to run before `RUN_CMD`. Each is +a single-line shell string and the launcher does not implement multi-line +parsing. In this mode both values are trust-bearing config and must be +audited alongside `COMMIT_SHA`. + +### What the launcher will and will not do + +* Will: clone fresh if `WORK_DIR` is empty; reuse the existing clone otherwise + (after asserting that its `origin` URL matches `REPO_URL`). +* Will: `git fetch --tags --prune origin`, then `git checkout --detach $SHA`, + then `git rev-parse HEAD` and assert it equals `COMMIT_SHA`. +* Will not: fall back to a branch, tag, or `HEAD` if the commit is missing. + A missing commit is a hard failure. +* Will not: accept short SHAs. A truncated SHA could resolve ambiguously if + the upstream history changes. +* Will not: source the config or `eval` config values. In default mode the + launcher executes `bash entrypoint.sh` from the pinned commit; in advanced + mode it executes `INSTALL_CMD` / `RUN_CMD` via `bash -c`. Nothing else + from the config reaches a shell. + +## Example + +See [`examples/web-app.conf`](./examples/web-app.conf). Adapt `REPO_URL`, +`COMMIT_SHA`, and (if you need it) `REPO_SUBDIR` for your workload, and +make sure the workload repo has a `entrypoint.sh` at the pinned commit. + +```sh +./bin/trusted-workload-launcher ./examples/web-app.conf +``` + +The launcher logs the resolved repo, commit, workdir, and selected mode at +startup, then logs the verified `HEAD` after checkout, before handing +control to `entrypoint.sh` (or `INSTALL_CMD` / `RUN_CMD` in advanced mode). + +## Deploying with dstack + +Always pin the launcher image by its OCI digest (`@sha256:…`) — not by tag — +so the dstack attestation binds to the exact launcher bytes you audited. +How the config gets in front of the launcher depends on which binding from +the trust model above you chose. + +### Mounting the dstack socket + +If the workload uses the dstack SDK (Rust, Python, etc.) to request KMS +keys or TDX quotes — the recommended pattern for workloads that need +in-TEE identity — the dstack agent's Unix socket must be visible inside +the workload container. By default the SDK looks at `/var/run/dstack.sock`. +Mount it in every compose snippet below; the snippets already include the +mount. + +If your workload talks to a non-default dstack endpoint, set +`DSTACK_LLM_ROUTER_DSTACK_ENDPOINT` (or whatever endpoint variable your +workload reads) via `CHILD_ENV_FILE` rather than baking it into the +launcher config — it is runtime configuration, not a trust-bearing field. + +### Local development (host bind-mount) + +Convenient for iterating on the config. **Not for production**: the host +can swap the mounted file at any time and nothing about that swap is +reflected in the dstack attestation. + +```yaml +services: + workload: + image: docker.io//trusted-workload-launcher@sha256: + command: ["/etc/trusted-workload-launcher/config.conf"] + volumes: + - ./web-app.conf:/etc/trusted-workload-launcher/config.conf:ro + - workload-checkout:/var/lib/trusted-workload-launcher + - /var/run/dstack.sock:/var/run/dstack.sock + restart: unless-stopped + +volumes: + workload-checkout: +``` + +### Production option A (recommended): attest the config via dstack compose + +Inline the config inside the compose file (or reference a sibling file +that participates in the compose hash). dstack measures the compose into +the attested app config, so a verifier can compare the deployed compose +against the one they audited: + +```yaml +services: + workload: + image: docker.io//trusted-workload-launcher@sha256: + command: ["/etc/trusted-workload-launcher/config.conf"] + configs: + - source: pin + target: /etc/trusted-workload-launcher/config.conf + volumes: + - workload-checkout:/var/lib/trusted-workload-launcher + - /var/run/dstack.sock:/var/run/dstack.sock + restart: unless-stopped + +configs: + pin: + content: | + REPO_URL=https://github.com/example-org/example-web-app.git + COMMIT_SHA= + WORK_DIR=/var/lib/trusted-workload-launcher/example-web-app + +volumes: + workload-checkout: +``` + +A verifier compares the deployed `compose_hash` / `config_id` against the +one they audited; that binds the launcher image **and** the pinned +`COMMIT_SHA` to the attestation. + +### Production option B: bake the config into a derived image + +If you want a single digest to fully determine the workload, build a small +downstream image that copies the config in: + +```dockerfile +FROM docker.io//trusted-workload-launcher@sha256: +COPY web-app.conf /etc/trusted-workload-launcher/config.conf +CMD ["/etc/trusted-workload-launcher/config.conf"] +``` + +Deploy that derived image (pinned by its own `@sha256:…`). The derived +image digest now implies both the launcher and the workload pin. Still +mount `/var/run/dstack.sock` from the host into the workload container in +the deploying compose if the workload uses the dstack SDK. + +## Tests + +`tests/run-tests.sh` builds a throwaway local git repo, points the launcher +at specific commits, and asserts: + +* Happy path: launcher checks out the pinned commit and `exec`s the run + command from inside the requested subdirectory. +* Re-running with a different `COMMIT_SHA` advances the pin in-place. +* Bogus commit SHA aborts before running anything. +* Branch names and short SHAs are rejected during validation. +* Missing required keys are rejected. +* Unknown keys are rejected. +* `REPO_SUBDIR` containing `..` is rejected. +* Pre-existing `WORK_DIR` whose `origin` differs from `REPO_URL` is rejected. +* `CHILD_ENV_FILE` values reach the child process. +* `INSTALL_CMD` runs before `RUN_CMD`. +* `--help` exits zero. +* The release workflow runs launcher tests before building the image and + generates a GitHub artifact attestation bound to the pushed image digest. +* The Dockerfile uses a small runtime base and exposes the launcher as + the entrypoint. + +Run with: + +```sh +./tests/run-tests.sh +``` + +The tests only require `bash`, `git`, and standard coreutils, so they run +unprivileged in CI or on a developer laptop. + +## Release image provenance + +The release workflow (`.github/workflows/trusted-workload-launcher-release.yml` +in this repository's root `.github/`) follows the dstack-examples pattern: + +1. run `./tests/run-tests.sh`; +2. build and push `docker.io/${DOCKERHUB_ORG}/trusted-workload-launcher:`; +3. call `actions/attest-build-provenance@v1` with the Docker build digest; +4. write the digest and a Sigstore search link into both the GitHub Actions + step summary and the GitHub release body. + +The attestation subject is the immutable OCI digest emitted by +`docker/build-push-action`, not the mutable tag. A verifier should pin and +compare that digest before trusting the launcher image. + +## Audit checklist + +If you are reviewing this directory at commit `L` before signing off on a +launcher image, the relevant audit surface is: + +1. `bin/trusted-workload-launcher` — every line. Confirm: + * No `eval`, no `source`/`.`, no command substitution applied to config + values during parsing. + * `git checkout` always uses the verbatim `COMMIT_SHA` and the result is + reverified with `git rev-parse`. + * `INSTALL_CMD` / `RUN_CMD` are executed exactly once each, via a fresh + `bash -c`, with no implicit fallbacks. +2. The config the launcher will load at deploy time (`REPO_URL`, + `COMMIT_SHA`, etc.). This pins which workload code runs, and is **not** + covered by the launcher image digest — verify it via the dstack attested + `compose_hash` / `config_id`, or via the digest of a derived image that + bakes the config in. See [Trust model](#trust-model). +3. The contents of the upstream workload repo at the pinned `COMMIT_SHA` — + that is the surface that actually serves traffic. diff --git a/trusted-workload-launcher/VERIFY.md b/trusted-workload-launcher/VERIFY.md new file mode 100644 index 0000000..cb22959 --- /dev/null +++ b/trusted-workload-launcher/VERIFY.md @@ -0,0 +1,348 @@ +# Verifying a trusted-workload-launcher deployment + +How a relying party verifies that a dstack CVM is running +`trusted-workload-launcher` and that the workload commit executed inside the +TEE is the one they audited. + +## Quick path (default mode, 4 steps) + +In default mode the workload repo provides its own `entrypoint.sh` at the +pinned commit, so the trust-bearing config is `REPO_URL + COMMIT_SHA` +(plus `REPO_SUBDIR` and `ENTRYPOINT_SCRIPT` when used, since each selects +which script in the pinned repo gets run) and the install/run command +chain disappears from the verifier's checklist. `WORK_DIR` is local +plumbing and is not trust-bearing. +The whole chain is: + +```mermaid +flowchart LR + A[dstack attestation] --> B[launcher image digest
+ REPO_URL
+ COMMIT_SHA
+ REPO_SUBDIR / ENTRYPOINT_SCRIPT if used] + B --> C[Sigstore attestation
for launcher image digest
= dstack-examples@ref/SHA] + B --> D[upstream repo at COMMIT_SHA
incl. the chosen entry script] +``` + +1. **Verify the dstack attestation, and compare reference values.** + `phala cvms attestation --cvm-id --json` and feed the TDX quote + into the dstack verifier (or trust the Phala Cloud verifier as a lite + path). Then compare the attestation's measurements against + pre-published reference values: `mrtd` and `rtmr0`–`rtmr2` against the + dstack OS image you expect, the `compose-hash` event against + `sha256(tcb_info.app_compose)`, the launcher image digest inside the + attested compose against your audited release digest, and the + attested `REPO_URL` + `COMMIT_SHA` (and `REPO_SUBDIR` / + `ENTRYPOINT_SCRIPT` if present) against the workload pin you intended + to deploy. The deep-path checklist below has the exact extraction + commands. +2. **Verify launcher image provenance via Sigstore.** Confirm the image + digest from step 1 carries a build-provenance attestation signed by + the expected `Dstack-TEE/dstack-examples` GitHub Actions workflow at + the ref / commit you audited. +3. **Audit the upstream commit.** Check out the workload repo at + `COMMIT_SHA` and review it. In default mode this single review covers + the workload code *and* its entry point `entrypoint.sh`; no separate + install/run command audit is needed. +4. **Spot-check runtime logs.** `phala logs --cvm-id ` should show + `HEAD verified: ` and `exec in : bash entrypoint.sh`. + Logs are corroborating only; the trust root is steps 1–3. + +If all four line up, the bytes executing in the TEE are exactly the +upstream commit you audited, produced by an audited launcher. + +> **Advanced mode adds one step.** If the launcher config sets `RUN_CMD` +> (and optionally `INSTALL_CMD`) instead of relying on `entrypoint.sh`, +> those strings are trust-bearing deployment config: read them from the +> attested compose in step 1 and audit them like any other deployment +> code — they are not part of the upstream repo at `COMMIT_SHA` and so +> are not covered by its source provenance. The simplification of the +> default mode is exactly that this extra step does not exist. + +The rest of this document explains how the chain works and what to do at +each step. + +## How the chain works + +Two configuration approaches are supported. The recommended one for +production is **compose-mounted config**: the workload pin lives inline in +the compose file, dstack measures the compose into the attested +`compose-hash`, and the same compose can be governed by dstack's KMS +policy. The other approach, **derived image**, bakes the config into a +downstream image; the image digest then covers both launcher and pin. + +### Recommended: compose-mounted config + +```mermaid +flowchart LR + L[launcher image
@sha256:<L>] --> CMP + P[config bytes
REPO_URL
COMMIT_SHA] -.inline configs:.-> CMP + CMP[docker-compose.yml] --> CH[compose-hash
= sha256 app_compose] + CH --> Q[dstack attestation
TDX quote] +``` + +The compose YAML references the generic launcher image by digest and +provides the launcher's config via a compose `configs:` block (with +`content:` inline). In default mode the config is just `REPO_URL` + +`COMMIT_SHA` + `WORK_DIR`; in advanced mode it also carries `RUN_CMD` +(and optionally `INSTALL_CMD`). Either way dstack measures the resulting +`app_compose` JSON into the quote as the `compose-hash` event, so changing +either the image reference or the config bytes changes the attestation. + +This is also the surface that dstack KMS policy governs: a CVM can only +unwrap KMS-protected secrets while running a compose whose hash matches +what the policy allows. + +### Alternative: derived image + +```mermaid +flowchart LR + L[launcher image
@sha256:<L>] --> D[derived image
FROM launcher
COPY config.conf] + D --> Q[dstack attestation
TDX quote] +``` + +A small downstream image is built `FROM` the launcher image and `COPY`s +the config in. Its single digest binds both launcher and pin. The +attested compose carries just the derived image reference. This avoids +inline `configs:` but means the pin is no longer governed by compose-level +KMS policy — change the pin, rebuild the image, get a new digest. + +Use this path if you need a single digest to fully describe the workload, +or if downstream tooling cannot author compose `configs:` blocks. + +## Step-by-step verifier checklist + +The CLI calls below assume a Phala CLI authenticated against the workspace +that owns the CVM. The CVM identifier can be UUID, `app_id`, instance ID, +or name. + +### 1. Fetch and verify the dstack attestation + +```sh +phala cvms attestation --cvm-id --json > attestation.json +``` + +The JSON contains the TDX quote, `tcb_info` (with `mrtd`, `rtmr0`–`rtmr3`, +`event_log`, `app_compose`), and the certificate chain. Feed it into the +dstack verifier (or trust the Phala Cloud verifier as the lite path) to +confirm: + +* The quote signs over dstack's measurements with a valid Intel TDX + signing chain. +* The measurements are consistent with the running platform identity. + +### 2. Read image digest and compose hash from the attestation + +The attested compose lives at `tcb_info.app_compose` (a JSON string). Its +SHA-256 is the `compose-hash` event in `tcb_info.event_log` (`imr: 3`, +`event: "compose-hash"`), and is what the TDX quote attests. + +```sh +jq -r '.tcb_info.app_compose' attestation.json | sha256sum +jq -r '.tcb_info.event_log[] | select(.event=="compose-hash") | .event_payload' attestation.json +``` + +The two hex strings must match. Then parse the compose and pull the +launcher image reference plus the inline `configs:` block: + +```sh +jq -r '.tcb_info.app_compose' attestation.json \ + | jq -r '.docker_compose_file' +``` + +The image reference is what you compare to your published launcher image +in step 3; the `configs:` block is what you parse in step 5. + +#### Reference values to compare + +This step is where reference-value checking actually happens — the +attestation is only useful insofar as you compare its measurements to a +known-expected set. Concretely, before signing off on a deployment, decide +the expected value for each row below, then run the JSON-extraction +command and assert equality: + +| Reference value | Source of truth | Where in `attestation.json` | +| --- | --- | --- | +| Launcher image digest | The published image digest at the launcher release you audited (and that step 3 verifies via Sigstore). | The `image:` reference inside `tcb_info.app_compose.docker_compose_file`. | +| Compose hash | `sha256` of the JSON-encoded `tcb_info.app_compose` you audited locally. | `tcb_info.event_log[] \| select(.event=="compose-hash") \| .event_payload`. | +| `mrtd` | The TDX measurement of the dstack OS image you expect (published with each dstack OS release). | `tcb_info.mrtd`. | +| `rtmr0` / `rtmr1` / `rtmr2` | Boot-time measurements of the same dstack OS image. Published with the dstack release alongside `mrtd`. | `tcb_info.rtmr0` / `rtmr1` / `rtmr2`. | +| `os-image-hash` event | The dstack OS image hash you expect (matches the `mrtd` / `rtmr0..2` set above). | `tcb_info.event_log[] \| select(.event=="os-image-hash") \| .event_payload`. | +| `app-id` event | Either the on-chain dstack app contract / config ID you registered, or, for KMS-less deployments, the value you accept for this CVM. | `tcb_info.event_log[] \| select(.event=="app-id") \| .event_payload`. | + +A one-shot reference-comparison script looks roughly like this: + +```sh +expected_image=docker.io//trusted-workload-launcher@sha256: +expected_compose_hash=$(sha256sum < audited-app_compose.json | awk '{print $1}') +expected_mrtd= +expected_rtmr0= +expected_rtmr1= +expected_rtmr2= + +a=attestation.json +[ "$(jq -r '.tcb_info.mrtd' $a)" = "$expected_mrtd" ] || { echo MRTD mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.rtmr0' $a)" = "$expected_rtmr0" ] || { echo RTMR0 mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.rtmr1' $a)" = "$expected_rtmr1" ] || { echo RTMR1 mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.rtmr2' $a)" = "$expected_rtmr2" ] || { echo RTMR2 mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.event_log[] | select(.event=="compose-hash") | .event_payload' $a)" \ + = "$expected_compose_hash" ] || { echo compose-hash mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.app_compose | fromjson | .docker_compose_file' $a | grep -oP 'image:\s*\K\S+')" \ + = "$expected_image" ] || { echo launcher image mismatch >&2; exit 1; } +echo OK +``` + +`rtmr3` is intentionally not compared as a single reference value because +it is the running extension over the runtime event log (`app-id`, +`compose-hash`, `os-image-hash`, instance bring-up events, etc.); verify +its constituent events individually as above, or replay the event log +into `rtmr3` if your verifier supports it. + +### 3. Verify launcher image provenance via Sigstore + +The `trusted-workload-launcher-release.yml` workflow publishes an +`actions/attest-build-provenance` attestation bound to the pushed image +digest. The attestation is not a claim of bit-for-bit reproducibility — it +is a signed statement that *this* OCI digest was produced by *this* +GitHub Actions workflow run, from a specific repo / ref / commit, using +the GitHub OIDC identity. + +```sh +gh attestation verify \ + --owner Dstack-TEE \ + oci://docker.io//trusted-workload-launcher@sha256: +``` + +or equivalently with `cosign verify-attestation` against +`https://search.sigstore.dev/?hash=sha256:`. Confirm: + +* the subject digest equals the image digest from step 2; +* the signing identity is the expected + `Dstack-TEE/dstack-examples` workflow at the expected ref / commit. + +That commit is the source of truth for the launcher's bytes. Treat the +Sigstore attestation as the chain of custody from the +`trusted-workload-launcher/` source at that commit to the deployed image +digest. + +If you want to go further you can rebuild the image from that commit and +compare digests. The image build is deterministic in practice (Ubuntu +base pinned by digest, minimal apt install, single `COPY` of the bash +script), but the release process does not guarantee bit-for-bit +reproducibility, so a digest mismatch on rebuild is not necessarily +evidence of tampering. + +### 4. Extract and audit the workload pin + +Parse the `configs:` content from step 2 and read `REPO_URL` and +`COMMIT_SHA` (plus `REPO_SUBDIR` and `ENTRYPOINT_SCRIPT` if present — +each selects which script in the pinned repo is used). `WORK_DIR` is +local plumbing only and is not part of the trust-bearing config. +`CHILD_ENV_FILE` (and any env it supplies) does not change the bytes that +run; if used, audit it as runtime deployment configuration, not as +source. + +In default mode there are no `INSTALL_CMD` / `RUN_CMD` strings to audit — +the entry point is the fixed-path `entrypoint.sh` in the workload repo, +which is covered by source provenance of the pinned commit. In advanced +mode (`RUN_CMD` present), also read `RUN_CMD` and any `INSTALL_CMD` and +audit them as trust-bearing deployment config: they are not part of the +upstream repo at `COMMIT_SHA` and so are not covered by its source +provenance. + +```sh +git -C rev-parse --verify +``` + +Confirm the upstream repo at `REPO_URL` contains `COMMIT_SHA`, and review +the workload at that commit, including `/entrypoint.sh` in +default mode. This is the code that actually serves traffic. + +### 5. Spot-check runtime logs + +```sh +phala logs --cvm-id -n 200 +``` + +Default-mode output should include these lines (the launcher logs `mode` +during config summary, then the checkout/verify lines, then the `exec` +line, so they appear in this order): + +``` +[trusted-workload-launcher] mode: default (workload repo entrypoint.sh) +[trusted-workload-launcher] checking out +[trusted-workload-launcher] HEAD verified: +[trusted-workload-launcher] exec in [/]: bash entrypoint.sh +``` + +Advanced mode logs `mode: advanced (RUN_CMD)` early on, and the last +`exec in ...:` line shows the explicit `RUN_CMD` instead of +`bash entrypoint.sh`. Either way these lines show the launcher reached +the post-checkout state. They are not signed, so they don't replace +steps 1–4 — they corroborate. + +A workload that needs signed runtime evidence should produce its own +attested output (see [Limitations](#limitations)). + +## Reference: production smoke transcript + +A real verification of this example was exercised against production +Phala on 2026-05-11 using the recommended compose-mounted-config path. +The pinned upstream (`octocat/Hello-World`) does not host a +`entrypoint.sh`, so the smoke used **advanced mode** to set `RUN_CMD` +inline; default-mode behavior is covered by the launcher's own test +suite. The compose-hash binding it demonstrates is identical: + +| Field | Value | +| --- | --- | +| Launcher image | `docker.io/h4x3rotab/trusted-workload-launcher-smoke@sha256:0d3f2dbda5e6ae9513ea4e8e69dcbc87c1f3af29744f0e36b9814685e5739866` | +| Compose pattern | inline `configs:` with `content:` block carrying the launcher config | +| Workload repo | `https://github.com/octocat/Hello-World.git` | +| Pinned commit | `7fd1a60b01f91b314f59955a4e4d4e80d8edf11d` | +| CVM name | `twl-cfg-smoke-20260511-180207` (deleted post-verification) | +| App ID | `app_5696a018cb75b2beadb3b44e9a379058ca2ed6c3` | +| `compose-hash` (imr 3) | `995f0e566f6e14382dedfff53203eebbd729b7e0307724df0e60c6e4d1d2b752` | +| `sha256(app_compose_json)` | `995f0e566f6e14382dedfff53203eebbd729b7e0307724df0e60c6e4d1d2b752` — matches | + +The match between the `compose-hash` event in `tcb_info.event_log` and +the SHA-256 of `tcb_info.app_compose` is the binding the recommended path +relies on: change the compose (image reference or inline config bytes), +get a different attestation. + +`phala ps --cvm-id ` showed the running container's image was exactly +the expected launcher digest. `phala logs --cvm-id ` showed: + +``` +[trusted-workload-launcher] checking out 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d +[trusted-workload-launcher] HEAD verified: 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d +[trusted-workload-launcher] exec in /var/lib/trusted-workload-launcher/hello: ... +TWL_PINNED_HEAD=7fd1a60b01f91b314f59955a4e4d4e80d8edf11d +TWL_README_BYTES=13 +TWL_READY +``` + +`TWL_PINNED_HEAD` is from `git rev-parse HEAD` evaluated *inside the TEE +container* by the workload's `RUN_CMD`, so it is independent +corroboration that the bytes running are the pinned commit. + +## Limitations + +* **No receipt signing in the launcher.** The launcher fetches and execs + code; it does not sign its own outputs. Workload identity for + individual responses must be implemented by the workload itself (for + example via an in-TEE signing key released by dstack KMS). +* **No per-response workload identity key.** A relying party cannot ask + "is this response from the workload at `COMMIT_SHA`?" by checking a + signature the launcher produced. Identity here means "is the CVM + measured as running this image+config?" — a deployment-level identity, + not a per-response identity. +* **Runtime logs are not signed.** Logs are useful for forensics and + smoke testing but cannot be the trust root for a remote verifier. +* **Generic image digest alone does not bind the workload pin.** The + compose hash (compose-mounted path) or derived-image digest (alternative + path) is what binds them. +* **Sigstore attestation ≠ reproducibility.** Verifying the Sigstore + attestation tells you the image digest was produced by a specific + GitHub Actions workflow run from a specific commit. The release + process does not guarantee bit-for-bit rebuilds. +* **Trust in the upstream Git host.** The launcher verifies the + `COMMIT_SHA` it actually checked out, but it does not enforce which + Git host serves it. `REPO_URL` is part of the attested config; review + and trust that URL together with the rest of the config. diff --git a/trusted-workload-launcher/bin/trusted-workload-launcher b/trusted-workload-launcher/bin/trusted-workload-launcher new file mode 100755 index 0000000..a1f6364 --- /dev/null +++ b/trusted-workload-launcher/bin/trusted-workload-launcher @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# trusted-workload-launcher: minimal launcher for a workload pinned at a +# specific upstream Git commit. +# +# Reads one env-file config, clones the configured repo, hard-pins to the +# requested commit SHA, then hands control to the workload. +# +# Default mode (recommended): the workload repo ships an entry point at the +# path named by ENTRYPOINT_SCRIPT (default 'entrypoint.sh'), under REPO_SUBDIR +# if set else the repo root. The launcher runs it with 'bash