From 82b45dc453ebb9a8eae8b3d6b669ad8976818ec6 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Mon, 11 May 2026 09:12:19 +0000 Subject: [PATCH 01/13] Add trusted workload launcher example --- .../trusted-workload-launcher-release.yml | 92 ++++ README.md | 3 +- trusted-workload-launcher/.dockerignore | 5 + trusted-workload-launcher/README.md | 297 +++++++++++++ trusted-workload-launcher/VERIFY.md | 260 +++++++++++ .../bin/trusted-workload-launcher | 244 +++++++++++ trusted-workload-launcher/docker/Dockerfile | 19 + .../examples/web-app.conf | 39 ++ trusted-workload-launcher/tests/run-tests.sh | 407 ++++++++++++++++++ 9 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/trusted-workload-launcher-release.yml create mode 100644 trusted-workload-launcher/.dockerignore create mode 100644 trusted-workload-launcher/README.md create mode 100644 trusted-workload-launcher/VERIFY.md create mode 100755 trusted-workload-launcher/bin/trusted-workload-launcher create mode 100644 trusted-workload-launcher/docker/Dockerfile create mode 100644 trusted-workload-launcher/examples/web-app.conf create mode 100755 trusted-workload-launcher/tests/run-tests.sh 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..1928ead 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) | Tiny auditable launcher image that fetches a workload at a full upstream Git commit SHA — opposite trust posture to `launcher/` (no auto-update). The launcher image digest attests the launcher itself; the workload pin lives in a config file that must be attested via dstack `compose_hash`/`config_id` or baked into a derived image | | [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..6b408bb --- /dev/null +++ b/trusted-workload-launcher/README.md @@ -0,0 +1,297 @@ +# 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 `exec`s the configured run +command — 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. + +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, + reproducibly built; published by the release + workflow with a Sigstore attestation) + +launcher config file ──► workload pin + (REPO_URL + full COMMIT_SHA U; selects which + upstream commit gets fetched and exec()d) + + ──► workload running inside the TEE + = workload repo at commit U +``` + +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 a reproducible + build of this directory at commit `L`. +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; the only values executed as shell are +`INSTALL_CMD` and `RUN_CMD`, which are documented to be intentionally +executed. + +## 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`. | +| `INSTALL_CMD` | Shell command run inside the checkout (in `REPO_SUBDIR` if set). Pass `INSTALL_CMD=` to explicitly skip the install step. The key must still be present. | +| `RUN_CMD` | Shell command `exec`d after the install step. Because `exec` is used, signals reach the child program directly. | + +### Optional + +| Key | Meaning | +| --- | --- | +| `REPO_SUBDIR` | Relative directory inside the repo to `cd` into before `INSTALL_CMD` and `RUN_CMD`. Must not be absolute and must not contain `..`. | +| `CHILD_ENV_FILE` | Path to a separate env file. Each `KEY=VALUE` line is `export`ed into the environment seen by `INSTALL_CMD` and `RUN_CMD`. The file is parsed line-by-line just like the main config (not sourced). | + +### 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` anything beyond `INSTALL_CMD` / + `RUN_CMD`, which are executed via `bash -c`. + +## Example + +See [`examples/web-app.conf`](./examples/web-app.conf). Adapt `REPO_URL`, +`COMMIT_SHA`, `INSTALL_CMD`, and `RUN_CMD` for your workload. + +```sh +./bin/trusted-workload-launcher ./examples/web-app.conf +``` + +The launcher logs the resolved repo, commit, workdir, and commands at +startup, then logs the verified `HEAD` after checkout, before invoking the +install and run steps. + +## 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. + +### 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 + restart: unless-stopped + +volumes: + workload-checkout: +``` + +### Production option A: 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 + 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 + INSTALL_CMD=npm ci --omit=dev + RUN_CMD=node server.js + +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, and the +dstack attestation over the image digest is sufficient. + +## 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..8b68f7b --- /dev/null +++ b/trusted-workload-launcher/VERIFY.md @@ -0,0 +1,260 @@ +# Verifying a trusted-workload-launcher deployment + +How a relying party verifies that a dstack CVM is running the +`trusted-workload-launcher` and that the workload commit executed inside the +TEE is the one they audited. + +This is a verification guide for the launcher example. It is not a TIP +receipt service. See [Limitations](#limitations) for what the launcher +deliberately does **not** provide. + +## Goal (in TIP terms) + +A verifier wants to: + +1. **Establish workload identity.** Bind a hardware-attested dstack TEE to a + specific image identity (launcher image digest) and a specific config + identity (the `REPO_URL` + `COMMIT_SHA` + commands the launcher will + execute). +2. **Verify evidence of execution.** Confirm the running CVM is a genuine + dstack TEE (TDX quote, dstack measurements) and that its measured image + and attested config match the expected identity from step 1. +3. **Link derived work to that identity.** The "derived work" produced by + this deployment is the workload code at the pinned upstream commit — + bind that commit to the identity so a reviewer can audit exactly what + ran. + +The launcher does the third step *deterministically*: given the same image +identity and config identity, the same `COMMIT_SHA` always ends up on +`HEAD`, or the launcher refuses to start. Steps 1 and 2 are dstack +attestation; this document explains how to chain them together. + +## What is being verified + +| Object | Identity is | Attested by | +| --- | --- | --- | +| Launcher implementation | `trusted-workload-launcher` OCI image digest | dstack TEE measurement of the running image | +| Launcher config — `REPO_URL`, `COMMIT_SHA`, `INSTALL_CMD`, `RUN_CMD`, optional `REPO_SUBDIR` / `CHILD_ENV_FILE` / `WORK_DIR` | bytes of the config file the launcher loads at startup | dstack `compose_hash` / `config_id`, **or** the digest of a derived image that bakes the config in | +| Workload code | upstream Git repo at the full SHA `COMMIT_SHA` | the launcher's `git checkout --detach $SHA` + `git rev-parse HEAD` reverification, plus the upstream Git host serving that commit | + +Runtime evidence — `HEAD verified: ` in container logs, workload +self-checks, etc. — is **corroborating only**. The trust anchor is the +launcher image digest + attested config; logs are not signed receipts. + +## Two verification paths + +The verifier picks the path that matches how the deployment was packaged. + +### Path A — generic launcher image + dstack-attested compose + +The deployed CVM runs the generic launcher image, with the config carried +in a compose file (e.g. via compose `configs:` content). dstack measures +the compose into the attested app config. + +``` +dstack attestation ──pins──► launcher image digest (= trusted-workload-launcher@sha256:...) + compose_hash / config_id (= a specific compose, including the config bytes) + │ + └─► REPO_URL, COMMIT_SHA, ... +``` + +A verifier checks both: the image digest *and* the compose hash. Either +mismatch invalidates the deployment. + +### Path B — derived image with config baked in + +A small downstream image is built `FROM trusted-workload-launcher@sha256:...` +that `COPY`s the config in. Its image digest covers both the launcher and +the config bytes. + +``` +dstack attestation ──pins──► derived image digest + ├── FROM trusted-workload-launcher@sha256:... (launcher identity) + └── COPY config.conf (config identity) + │ + └─► REPO_URL, COMMIT_SHA, ... +``` + +A verifier needs only the derived image digest; the launcher digest and +the pin both follow from it. (This is the path used in the production +smoke test below.) + +## Verifier checklist + +The following commands assume the Phala CLI (`phala`) authenticated against +the workspace that owns the deployment. The CVM identifier can be a UUID, +`app_id`, instance ID, or name. + +### 1. Get the attestation + +```sh +phala cvms attestation --cvm-id --json > attestation.json +``` + +The JSON contains the dstack/TDX quote and platform evidence the CVM is +willing to expose. A verifier feeds it into a dstack/TDX quote verifier to +confirm the platform identity, signing certs, and TCB. + +`phala cvms attestation` (no `--json`) prints a human summary. The exact +raw-quote extraction shape depends on dstack version; use the JSON output +as the authoritative source. + +### 2. Verify hardware/platform evidence + +Run the dstack-side verifier (or the Phala Cloud trusted endpoint, as the +lite path) against `attestation.json` to confirm: + +* The TDX quote signs over dstack's measurement of the running image. +* dstack's measurements (`MRTD`, RTMR0–3, `compose_hash`, `instance_id`, + app contract) are consistent with the platform identity certificates. + +Phala Cloud's dashboard for the app (e.g. +`https://cloud.phala.com/dashboard/cvms/`) renders the parsed +attestation for cross-checking. + +### 3. Compare the deployed image digest to what you expect + +```sh +phala ps --cvm-id --json | jq -r '.containers[] | .image' +``` + +* **Path A**: the container image should be the generic launcher pinned by + digest, e.g. `docker.io//trusted-workload-launcher@sha256:`. Then + also check the compose hash: + ```sh + phala runtime-config --json | jq -r '.compose_hash // .data.compose_hash' + ``` + and compare it against the hash of the compose file you audited. +* **Path B**: the container image should be the derived image, pinned by + digest. Single comparison; no separate compose hash needed. + +If the running image digest doesn't match, stop — the deployment is not +what you audited. + +### 4. Verify launcher source provenance + +```sh +git -C log -1 --format=%H -- trusted-workload-launcher +``` + +Check out `dstack-examples` at the commit you audited and inspect: + +* `trusted-workload-launcher/bin/trusted-workload-launcher` — the audited + script (no `eval` / `source`, full-SHA only, `git rev-parse` reverify). +* `trusted-workload-launcher/docker/Dockerfile` — base pinned by manifest + digest. + +If the release process is reproducible (e.g. via the +`trusted-workload-launcher-release.yml` workflow that publishes a Sigstore +attestation), rebuild and confirm the resulting digest matches the one +from step 3. Otherwise, treat the published image digest + Sigstore +attestation as the chain of custody from this directory at `L` to the +deployed bytes. + +### 5. Verify config provenance and extract `COMMIT_SHA` + +* **Path A**: extract the config bytes from the compose file you audited + (the same one whose hash you checked in step 3). The relevant fields are + `REPO_URL`, `COMMIT_SHA`, optional `REPO_SUBDIR`, `INSTALL_CMD`, + `RUN_CMD`. Confirm the compose hash you compared earlier was over + exactly these bytes. +* **Path B**: re-build the derived image locally from the same + `Dockerfile`, base launcher digest, and config file you audited, and + confirm the resulting digest matches the one from step 3. The config + file in your audit is the same one inside the deployed image. + +Either way, you now have the authoritative `COMMIT_SHA`. + +### 6. Audit the workload commit + +```sh +git -C rev-parse --verify +``` + +Confirm the upstream repo at `REPO_URL` contains `COMMIT_SHA`, then review +the workload at that commit. This is the code that actually serves +traffic. + +### 7. Use runtime logs as corroboration only + +```sh +phala logs --cvm-id -n 200 +``` + +Expected lines: + +``` +[trusted-workload-launcher] checking out +[trusted-workload-launcher] HEAD verified: +[trusted-workload-launcher] exec in [/]: +``` + +These confirm the launcher reached the post-checkout state. They are +**not signed**, so they don't replace steps 1–6 — they corroborate them. +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. Summary: + +| Field | Value | +| --- | --- | +| Path | B (derived image with config baked in) | +| Launcher base | `docker.io/h4x3rotab/trusted-workload-launcher-smoke@sha256:a88a1052279f028cc0de7414ddb3ab439df0cad622abf36fed1195cf4fd3c5ad` | +| Derived image | `docker.io/h4x3rotab/trusted-workload-launcher-smoke@sha256:6c508c15c45c8aacbbbfab3754724ef9ef104a67e1c53a9c35b50be47e86433e` | +| Workload repo | `https://github.com/octocat/Hello-World.git` | +| Pinned commit | `7fd1a60b01f91b314f59955a4e4d4e80d8edf11d` (master) | +| CVM name | `twl-smoke-20260511-091916` (deleted post-verification) | +| App ID | `app_2a242c979a76009770a88908df0dc6907aea37b8` | + +`phala ps --cvm-id ` showed the running container's image was exactly +the expected derived image 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 +``` + +The post-`exec` `TWL_PINNED_HEAD` line 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. `TWL_README_BYTES=13` matches Hello-World's README byte count. + +(Step 1 — TDX quote verification — was not part of this smoke run; the +CVM was deleted before a full quote was extracted. The shape of step 1 is +documented in the dstack verifier docs; the CLI command in this guide is +the authoritative entry point.) + +## Limitations + +* **No TIP receipt signing in the launcher.** The launcher fetches and + execs code; it does not produce signed receipts over its own outputs. + Workload identity for *individual responses* must be implemented by the + workload itself (e.g. an in-TEE signing key released by dstack KMS). +* **No 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 config.** Path A requires a + separate compose-hash check; Path B folds the config into a single + image digest. Do not assume a generic launcher image digest implies a + workload pin. +* **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; the + verifier reviews and trusts that URL together with the rest of the + config. + +For workloads that need TIP receipt signing or a per-response workload +identity, build that on top of this launcher (or alongside it) — the +launcher's job stops at "the bytes running here are the bytes at +`COMMIT_SHA`." diff --git a/trusted-workload-launcher/bin/trusted-workload-launcher b/trusted-workload-launcher/bin/trusted-workload-launcher new file mode 100755 index 0000000..0c9d1e1 --- /dev/null +++ b/trusted-workload-launcher/bin/trusted-workload-launcher @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# trusted-workload-launcher — minimal, auditable 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, runs the configured install step inside the checkout, +# then exec()s the configured run command so signals reach the child program. +# +# The launcher is intentionally tiny: it knows nothing about the workload it +# runs. Its only job is to make the bytes that execute correspond exactly to +# the bytes at a specific commit in a specific upstream repo, so that a +# verifier who knows the launcher image digest can derive what code ran. + +set -euo pipefail + +PROG=$(basename "$0") + +log() { printf '[%s] %s\n' "$PROG" "$*" >&2; } +die() { printf '[%s] error: %s\n' "$PROG" "$*" >&2; exit 1; } + +usage() { + cat >&2 < + +The config file is a line-oriented KEY=VALUE env file. It is parsed, NOT +sourced; the launcher does not eval untrusted config beyond the two values +INSTALL_CMD and RUN_CMD, which are intentionally executed via 'bash -c'. + +Required keys: + REPO_URL Git URL of the pinned repo (https://... or git@...). + COMMIT_SHA Full 40-hex (SHA-1) or 64-hex (SHA-256) commit hash. + Branches, tags, and short SHAs are rejected. + WORK_DIR Local directory used as the checkout. Created if missing. + Reused across runs if it already contains a clone of REPO_URL. + INSTALL_CMD Shell command to run inside the checkout (after cd into + REPO_SUBDIR if set). Pass an empty string to skip the install + step; the key must still be present for explicitness. + RUN_CMD Shell command to exec inside the checkout. exec() ensures the + child receives signals directly. + +Optional keys: + REPO_SUBDIR Path inside the repo to cd into before running INSTALL_CMD and + RUN_CMD. Defaults to the repo root. + CHILD_ENV_FILE Path to a separate KEY=VALUE env file. Each key listed there + is exported into the environment of INSTALL_CMD and RUN_CMD. + +Lines starting with '#' and blank lines are ignored. Values may be wrapped in +matching single or double quotes; one layer of surrounding quotes is stripped. +Unknown keys are rejected. +EOF +} + +if [[ $# -ne 1 ]]; then + usage + exit 2 +fi + +case ${1:-} in + -h|--help) usage; exit 0 ;; +esac + +CONFIG_FILE=$1 + +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "required tool not found in PATH: $1" +} + +require_tool git + +# Parse the config file line-by-line. We never source it, so a config cannot +# inject arbitrary commands during parsing — INSTALL_CMD and RUN_CMD are the +# only values that are later executed, and they go through 'bash -c'. +parse_config() { + local config_file=$1 + [[ -f $config_file ]] || die "config file not found: $config_file" + + CFG_REPO_URL= + CFG_COMMIT_SHA= + CFG_WORK_DIR= + CFG_REPO_SUBDIR= + CFG_CHILD_ENV_FILE= + CFG_INSTALL_CMD= + CFG_RUN_CMD= + CFG_INSTALL_CMD_SET=0 + CFG_RUN_CMD_SET=0 + + local line key val lineno=0 trimmed + while IFS= read -r line || [[ -n $line ]]; do + lineno=$((lineno + 1)) + trimmed=${line#"${line%%[![:space:]]*}"} + [[ -z $trimmed ]] && continue + [[ ${trimmed:0:1} == '#' ]] && continue + if [[ ! $trimmed =~ ^([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then + die "config parse error at $config_file:$lineno: $trimmed" + fi + key=${BASH_REMATCH[1]} + val=${BASH_REMATCH[2]} + if [[ ${#val} -ge 2 ]]; then + local first=${val:0:1} last=${val: -1} + if [[ ($first == '"' && $last == '"') || ($first == "'" && $last == "'") ]]; then + val=${val:1:${#val}-2} + fi + fi + case $key in + REPO_URL) CFG_REPO_URL=$val ;; + COMMIT_SHA) CFG_COMMIT_SHA=$val ;; + WORK_DIR) CFG_WORK_DIR=$val ;; + REPO_SUBDIR) CFG_REPO_SUBDIR=$val ;; + CHILD_ENV_FILE) CFG_CHILD_ENV_FILE=$val ;; + INSTALL_CMD) CFG_INSTALL_CMD=$val; CFG_INSTALL_CMD_SET=1 ;; + RUN_CMD) CFG_RUN_CMD=$val; CFG_RUN_CMD_SET=1 ;; + *) die "unknown config key '$key' at $config_file:$lineno" ;; + esac + done < "$config_file" +} + +validate_config() { + [[ -n $CFG_REPO_URL ]] || die "missing required config key: REPO_URL" + [[ -n $CFG_COMMIT_SHA ]] || die "missing required config key: COMMIT_SHA" + [[ -n $CFG_WORK_DIR ]] || die "missing required config key: WORK_DIR" + [[ $CFG_INSTALL_CMD_SET -eq 1 ]] || die "missing required config key: INSTALL_CMD (use INSTALL_CMD= to skip)" + [[ $CFG_RUN_CMD_SET -eq 1 ]] || die "missing required config key: RUN_CMD" + [[ -n $CFG_RUN_CMD ]] || die "RUN_CMD must not be empty" + + # Refuse anything that isn't a full commit hash. Branches, tags, and short + # SHAs are rejected because they let upstream rewrites silently change which + # bytes get executed inside the TEE. + if [[ ! $CFG_COMMIT_SHA =~ ^[0-9a-f]{40}$ && ! $CFG_COMMIT_SHA =~ ^[0-9a-f]{64}$ ]]; then + die "COMMIT_SHA must be a full 40-hex SHA-1 or 64-hex SHA-256 hash (got: $CFG_COMMIT_SHA)" + fi + + if [[ -n $CFG_REPO_SUBDIR ]]; then + case $CFG_REPO_SUBDIR in + /*) die "REPO_SUBDIR must be a relative path inside the repo (got: $CFG_REPO_SUBDIR)" ;; + *..*) die "REPO_SUBDIR must not contain '..' (got: $CFG_REPO_SUBDIR)" ;; + esac + fi +} + +ensure_repo_at_commit() { + local work_dir=$CFG_WORK_DIR + local url=$CFG_REPO_URL + local sha=$CFG_COMMIT_SHA + + mkdir -p "$work_dir" + cd "$work_dir" + + if [[ -d .git ]]; then + local current_url + current_url=$(git config --get remote.origin.url 2>/dev/null || true) + if [[ -n $current_url && $current_url != "$url" ]]; then + die "existing checkout at $work_dir has origin '$current_url' but config wants '$url'" + fi + log "reusing checkout at $work_dir; fetching origin" + git fetch --tags --prune origin + else + if [[ -n "$(ls -A "$work_dir")" ]]; then + die "WORK_DIR '$work_dir' is not empty and is not a git checkout; refusing to clone over it" + fi + log "cloning $url into $work_dir" + git clone "$url" . + fi + + log "checking out $sha" + if ! git -c advice.detachedHead=false checkout --detach "$sha" 2>/dev/null; then + die "git checkout failed for commit $sha (commit not present in $url? short SHA disallowed)" + fi + + local head + head=$(git rev-parse --verify HEAD) + local sha_lc=${sha,,} + local head_lc=${head,,} + if [[ $head_lc != "$sha_lc" ]]; then + die "HEAD after checkout is $head, expected $sha" + fi + log "HEAD verified: $head" +} + +apply_child_env() { + local env_file=$1 + [[ -f $env_file ]] || die "CHILD_ENV_FILE not found: $env_file" + local line key val lineno=0 trimmed + while IFS= read -r line || [[ -n $line ]]; do + lineno=$((lineno + 1)) + trimmed=${line#"${line%%[![:space:]]*}"} + [[ -z $trimmed ]] && continue + [[ ${trimmed:0:1} == '#' ]] && continue + if [[ ! $trimmed =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + die "child env parse error at $env_file:$lineno: $trimmed" + fi + key=${BASH_REMATCH[1]} + val=${BASH_REMATCH[2]} + if [[ ${#val} -ge 2 ]]; then + local first=${val:0:1} last=${val: -1} + if [[ ($first == '"' && $last == '"') || ($first == "'" && $last == "'") ]]; then + val=${val:1:${#val}-2} + fi + fi + export "$key=$val" + done < "$env_file" +} + +main() { + parse_config "$CONFIG_FILE" + validate_config + + log "config: $CONFIG_FILE" + log "repo: $CFG_REPO_URL" + log "commit: $CFG_COMMIT_SHA" + log "workdir: $CFG_WORK_DIR" + if [[ -n $CFG_REPO_SUBDIR ]]; then + log "subdir: $CFG_REPO_SUBDIR" + fi + log "install: ${CFG_INSTALL_CMD:-}" + log "run: $CFG_RUN_CMD" + if [[ -n $CFG_CHILD_ENV_FILE ]]; then + log "envfile: $CFG_CHILD_ENV_FILE" + fi + + ensure_repo_at_commit + + local target=$CFG_WORK_DIR + if [[ -n $CFG_REPO_SUBDIR ]]; then + target=$CFG_WORK_DIR/$CFG_REPO_SUBDIR + [[ -d $target ]] || die "REPO_SUBDIR '$CFG_REPO_SUBDIR' does not exist under $CFG_WORK_DIR after checkout" + fi + cd "$target" + + if [[ -n $CFG_CHILD_ENV_FILE ]]; then + apply_child_env "$CFG_CHILD_ENV_FILE" + fi + + if [[ -n $CFG_INSTALL_CMD ]]; then + log "running install in $target" + bash -c "$CFG_INSTALL_CMD" + else + log "INSTALL_CMD is empty; skipping install step" + fi + + log "exec in $target: $CFG_RUN_CMD" + exec bash -c "$CFG_RUN_CMD" +} + +main "$@" diff --git a/trusted-workload-launcher/docker/Dockerfile b/trusted-workload-launcher/docker/Dockerfile new file mode 100644 index 0000000..e820a23 --- /dev/null +++ b/trusted-workload-launcher/docker/Dockerfile @@ -0,0 +1,19 @@ +# Ubuntu 24.04 (noble) multi-arch manifest digest, resolved on 2026-05-11. +# Bump the tag and digest together when refreshing the base. +FROM ubuntu:24.04@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + coreutils \ + git \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +COPY bin/trusted-workload-launcher /usr/local/bin/trusted-workload-launcher +RUN chmod 0755 /usr/local/bin/trusted-workload-launcher + +ENTRYPOINT ["trusted-workload-launcher"] diff --git a/trusted-workload-launcher/examples/web-app.conf b/trusted-workload-launcher/examples/web-app.conf new file mode 100644 index 0000000..0994dfb --- /dev/null +++ b/trusted-workload-launcher/examples/web-app.conf @@ -0,0 +1,39 @@ +# Example: pin a small web app at a specific upstream commit and run it +# under the launcher. +# +# Replace COMMIT_SHA with the full SHA you want this launcher image to bind +# to. The launcher refuses to start unless COMMIT_SHA is a full 40-hex SHA-1 +# or 64-hex SHA-256. Branches, tags, and short SHAs are rejected. +# +# REPO_URL must be the public canonical URL of the workload repository. When +# the launcher image is built reproducibly, a verifier checks: +# +# image digest -> launcher repo at commit L +# -> this config file +# -> upstream workload repo at COMMIT_SHA +# +# so the launcher and its config together pin exactly which workload code +# runs inside the dstack TEE. + +REPO_URL=https://github.com/example-org/example-web-app.git +COMMIT_SHA=0000000000000000000000000000000000000000 +WORK_DIR=/var/lib/trusted-workload-launcher/example-web-app + +# Build step. The launcher does not know or care what your workload is; drop +# in whatever command produces a runnable artifact inside the checkout. +INSTALL_CMD="npm ci --omit=dev" + +# Long-running command. The launcher exec()s this so the workload receives +# signals (SIGTERM from `docker stop`, etc.) directly. +RUN_CMD="node server.js" + +# Optional: cd into a subdirectory of the upstream repo before install/run. +# Useful if the workload lives in a monorepo subpath. Absolute paths and +# paths containing '..' are rejected. +# REPO_SUBDIR=apps/web + +# Optional: a second env file whose KEY=VALUE lines are exported into the +# environment of INSTALL_CMD and RUN_CMD. Use this for runtime configuration +# (listen address, secrets, etc.) — keeping it separate from the pin config +# means config-only edits do not perturb the trust-bearing fields above. +# CHILD_ENV_FILE=/etc/trusted-workload-launcher/web-app.env diff --git a/trusted-workload-launcher/tests/run-tests.sh b/trusted-workload-launcher/tests/run-tests.sh new file mode 100755 index 0000000..86f60d2 --- /dev/null +++ b/trusted-workload-launcher/tests/run-tests.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 # test_* functions are dispatched indirectly via run_case "$@" +# shellcheck disable=SC2016 # we intentionally grep for literal GitHub Actions ${{ ... }} expressions +# Integration tests for bin/trusted-workload-launcher. +# +# Builds a throwaway local git repo so the tests do not hit the network, +# pins the launcher to specific commits in that repo, and asserts that the +# launcher checks out the right commit, refuses bad inputs, and propagates +# the child env file. +# +# Requires: bash, git, mktemp. + +set -u + +THIS=$(readlink -f "$0" 2>/dev/null || realpath "$0") +TEST_DIR=$(dirname "$THIS") +ROOT=$(dirname "$TEST_DIR") +REPO_ROOT=$(dirname "$ROOT") +LAUNCHER=$ROOT/bin/trusted-workload-launcher + +[[ -x $LAUNCHER ]] || { echo "launcher not executable: $LAUNCHER" >&2; exit 2; } + +TMPROOT=$(mktemp -d -t trusted-workload-launcher-tests-XXXXXX) +trap 'rm -rf "$TMPROOT"' EXIT + +PASS=0 +FAIL=0 +FAILED_NAMES=() + +run_case() { + local name=$1; shift + local out=$TMPROOT/${name//[^A-Za-z0-9_-]/_}.out + local err=$TMPROOT/${name//[^A-Za-z0-9_-]/_}.err + if ( "$@" ) >"$out" 2>"$err"; then + printf ' PASS %s\n' "$name" + PASS=$((PASS + 1)) + else + printf ' FAIL %s\n' "$name" + printf ' ── stdout ──\n' + sed 's/^/ /' "$out" + printf ' ── stderr ──\n' + sed 's/^/ /' "$err" + FAIL=$((FAIL + 1)) + FAILED_NAMES+=("$name") + fi +} + +# Build a fixture git repo with three commits: +# c0: initial empty +# c1: adds sub/run.sh and greeting.txt <-- this is the PIN commit +# c2: adds tip.txt <-- "future" advance +setup_fixture_repo() { + local repo=$1 + mkdir -p "$repo" + git -C "$repo" init -q -b main + git -C "$repo" config user.email test@example.invalid + git -C "$repo" config user.name "Test Fixture" + git -C "$repo" commit -q --allow-empty -m "c0 initial" + + mkdir -p "$repo/sub" + cat > "$repo/sub/run.sh" <<'SH' +#!/usr/bin/env bash +set -u +: "${MARKER_FILE:?MARKER_FILE not set}" +{ + printf 'cwd=%s\n' "$PWD" + printf 'head=%s\n' "$(git rev-parse HEAD)" + printf 'greeting=%s\n' "$(cat ../greeting.txt 2>/dev/null || echo MISSING)" + printf 'child_env_extra=%s\n' "${CHILD_ENV_EXTRA-UNSET}" +} > "$MARKER_FILE" +SH + chmod +x "$repo/sub/run.sh" + echo "hello" > "$repo/greeting.txt" + git -C "$repo" add sub/run.sh greeting.txt + git -C "$repo" commit -q -m "c1 add run.sh and greeting" + + echo "tip" > "$repo/tip.txt" + git -C "$repo" add tip.txt + git -C "$repo" commit -q -m "c2 add tip.txt" +} + +FIXTURE=$TMPROOT/fixture-repo +setup_fixture_repo "$FIXTURE" + +PIN_SHA=$(git -C "$FIXTURE" rev-parse HEAD~1) # c1, the run.sh commit +TIP_SHA=$(git -C "$FIXTURE" rev-parse HEAD) # c2 +# 40 zeros — syntactically a valid SHA, semantically not in this repo. +BOGUS_SHA=0000000000000000000000000000000000000000 + +# ────────────────────────────────────────────────────────────────────────── +# Test cases +# ────────────────────────────────────────────────────────────────────────── + +test_happy_pinning() { + local work=$TMPROOT/work-happy + local marker=$TMPROOT/marker-happy.txt + local conf=$TMPROOT/conf-happy.env + cat > "$conf" <&2; return 1; } + grep -q "head=$PIN_SHA" "$marker" || { echo "head not pinned to $PIN_SHA" >&2; cat "$marker" >&2; return 1; } + grep -q "greeting=hello" "$marker" || { echo "expected greeting=hello" >&2; cat "$marker" >&2; return 1; } + # tip.txt was added in c2 — it must not be present when pinned to c1. + [[ ! -e $work/tip.txt ]] || { echo "tip.txt leaked through pinning" >&2; return 1; } + return 0 +} + +test_rerun_advance_pin() { + local work=$TMPROOT/work-advance + local marker=$TMPROOT/marker-advance.txt + local conf1=$TMPROOT/conf-advance-1.env + local conf2=$TMPROOT/conf-advance-2.env + + cat > "$conf1" <&2; return 1; } + + cat > "$conf2" <&2; cat "$marker" >&2; return 1; } + [[ -e $work/tip.txt ]] || { echo "tip.txt missing after advance" >&2; return 1; } + return 0 +} + +test_bogus_sha_fails() { + local work=$TMPROOT/work-bogus + local conf=$TMPROOT/conf-bogus.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_branch_name_rejected() { + local conf=$TMPROOT/conf-branch.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_tag_name_rejected() { + # Create a tag in the fixture and confirm the launcher refuses it as a SHA. + git -C "$FIXTURE" tag -f v0 "$PIN_SHA" >/dev/null + local conf=$TMPROOT/conf-tag.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_short_sha_rejected() { + local short=${PIN_SHA:0:12} + local conf=$TMPROOT/conf-short.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_missing_required_field() { + local conf=$TMPROOT/conf-missing.env + # COMMIT_SHA is intentionally absent + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_unknown_key_rejected() { + local conf=$TMPROOT/conf-unknown.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_repo_subdir_escape_rejected() { + local conf=$TMPROOT/conf-escape.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_origin_mismatch_rejected() { + # Pre-seed WORK_DIR with a clone whose origin URL doesn't match REPO_URL. + local other_repo=$TMPROOT/other-repo + mkdir -p "$other_repo" + git -C "$other_repo" init -q -b main + git -C "$other_repo" config user.email a@b.c + git -C "$other_repo" config user.name X + git -C "$other_repo" commit -q --allow-empty -m c0 + + local work=$TMPROOT/work-mismatch + mkdir -p "$work" + git clone -q "$other_repo" "$work" + + local conf=$TMPROOT/conf-mismatch.env + cat > "$conf" <&2 + return 1 + fi + return 0 +} + +test_child_env_file() { + local work=$TMPROOT/work-env + local marker=$TMPROOT/marker-env.txt + local envfile=$TMPROOT/workload.env + local conf=$TMPROOT/conf-env.env + cat > "$envfile" < "$conf" <&2; cat "$marker" >&2; return 1; } + return 0 +} + +test_install_runs_before_run() { + # Use INSTALL_CMD to drop a marker; RUN_CMD asserts the file is there. + local work=$TMPROOT/work-install + local installed=$TMPROOT/installed.flag + local conf=$TMPROOT/conf-install.env + cat > "$conf" </dev/null +} + +test_release_workflow_attests_image_digest() { + local workflow=$REPO_ROOT/.github/workflows/trusted-workload-launcher-release.yml + [[ -f $workflow ]] || { echo "missing release workflow: $workflow" >&2; return 1; } + + grep -q "attestations: write" "$workflow" || { echo "workflow missing attestations permission" >&2; return 1; } + grep -q "id-token: write" "$workflow" || { echo "workflow missing OIDC permission" >&2; return 1; } + grep -q "actions/attest-build-provenance@v1" "$workflow" || { echo "workflow does not generate GitHub artifact attestation" >&2; return 1; } + grep -q 'subject-digest: ${{ steps.build-and-push.outputs.digest }}' "$workflow" || { echo "attestation is not bound to build-push digest" >&2; return 1; } + grep -q "push-to-registry: true" "$workflow" || { echo "attestation is not pushed to registry" >&2; return 1; } + grep -q 'search.sigstore.dev/?hash=${{ steps.build-and-push.outputs.digest }}' "$workflow" || { echo "release does not annotate Sigstore digest link" >&2; return 1; } + grep -q 'docker.io/${{ vars.DOCKERHUB_ORG }}/trusted-workload-launcher' "$workflow" || { echo "image not published under dstack DOCKERHUB_ORG namespace" >&2; return 1; } + grep -q "trusted-workload-launcher-v" "$workflow" || { echo "workflow not gated on trusted-workload-launcher-v* tag prefix" >&2; return 1; } + + local test_line build_line + test_line=$(grep -n "Run launcher tests" "$workflow" | cut -d: -f1 | head -1) + build_line=$(grep -n "Build and push Docker image" "$workflow" | cut -d: -f1 | head -1) + [[ -n $test_line && -n $build_line ]] || { echo "workflow missing test or build step" >&2; return 1; } + (( test_line < build_line )) || { echo "launcher tests must run before image build" >&2; return 1; } +} + +test_dockerfile_runtime_is_minimal_launcher() { + local dockerfile=$ROOT/docker/Dockerfile + [[ -f $dockerfile ]] || { echo "missing docker/Dockerfile" >&2; return 1; } + + grep -Eq "^FROM ubuntu:24\.04@sha256:[0-9a-f]{64}\$" "$dockerfile" || { echo "Dockerfile must pin the Ubuntu 24.04 base by digest (FROM ubuntu:24.04@sha256:<64hex>)" >&2; return 1; } + grep -q "bash" "$dockerfile" || { echo "Dockerfile missing bash dependency" >&2; return 1; } + grep -q "git" "$dockerfile" || { echo "Dockerfile missing git dependency" >&2; return 1; } + grep -q "COPY bin/trusted-workload-launcher /usr/local/bin/trusted-workload-launcher" "$dockerfile" || { echo "Dockerfile does not copy only the launcher script" >&2; return 1; } + grep -q 'ENTRYPOINT \["trusted-workload-launcher"\]' "$dockerfile" || { echo "Dockerfile entrypoint is not trusted-workload-launcher" >&2; return 1; } +} + +test_verify_doc_present_and_linked() { + local verify=$ROOT/VERIFY.md + local readme=$ROOT/README.md + [[ -f $verify ]] || { echo "missing VERIFY.md at $verify" >&2; return 1; } + grep -q 'VERIFY.md' "$readme" || { echo "README does not link VERIFY.md" >&2; return 1; } +} + +# ────────────────────────────────────────────────────────────────────────── +# Run all cases +# ────────────────────────────────────────────────────────────────────────── + +echo "trusted-workload-launcher tests" +echo " launcher: $LAUNCHER" +echo " tmproot: $TMPROOT" +echo + +run_case "happy_pinning_runs_pinned_commit" test_happy_pinning +run_case "rerun_advances_pin" test_rerun_advance_pin +run_case "bogus_sha_fails" test_bogus_sha_fails +run_case "branch_name_rejected" test_branch_name_rejected +run_case "tag_name_rejected" test_tag_name_rejected +run_case "short_sha_rejected" test_short_sha_rejected +run_case "missing_required_field" test_missing_required_field +run_case "unknown_key_rejected" test_unknown_key_rejected +run_case "repo_subdir_escape_rejected" test_repo_subdir_escape_rejected +run_case "origin_mismatch_rejected" test_origin_mismatch_rejected +run_case "child_env_file_passes_through" test_child_env_file +run_case "install_runs_before_run" test_install_runs_before_run +run_case "help_flag" test_help_flag +run_case "release_workflow_attests_image_digest" test_release_workflow_attests_image_digest +run_case "dockerfile_runtime_is_minimal_launcher" test_dockerfile_runtime_is_minimal_launcher +run_case "verify_doc_present_and_linked" test_verify_doc_present_and_linked + +echo +printf 'Passed: %d Failed: %d\n' "$PASS" "$FAIL" +if (( FAIL > 0 )); then + printf 'Failed cases: %s\n' "${FAILED_NAMES[*]}" + exit 1 +fi +exit 0 From 3c44cce24675223687faf043fc40a7fa1a398a8a Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Mon, 11 May 2026 18:05:57 +0000 Subject: [PATCH 02/13] trusted-workload-launcher: address PR #97 review - Shorten root README Details row - Document line-oriented config and call-a-script pattern for multi-line - Drop non-ASCII (em-dash) from launcher script comments and log line - Rewrite VERIFY.md: lead with 5-step quick path, add Mermaid diagrams, drop TIP terminology, promote compose-mounted config as the recommended production path, rewrite source provenance around Sigstore attestation (not bit-for-bit reproducibility), and re-anchor the smoke transcript on compose-hash <-> sha256(app_compose) binding from a real production Phala smoke (CVM twl-cfg-smoke-20260511-180207, deleted after capture). --- README.md | 2 +- trusted-workload-launcher/README.md | 17 + trusted-workload-launcher/VERIFY.md | 340 +++++++++--------- .../bin/trusted-workload-launcher | 8 +- 4 files changed, 193 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index 1928ead..ed93585 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Implementation details and infrastructure patterns. | Example | Description | |---------|-------------| | [launcher](./launcher) | Generic launcher pattern for Docker Compose apps (auto-update) | -| [trusted-workload-launcher](./trusted-workload-launcher) | Tiny auditable launcher image that fetches a workload at a full upstream Git commit SHA — opposite trust posture to `launcher/` (no auto-update). The launcher image digest attests the launcher itself; the workload pin lives in a config file that must be attested via dstack `compose_hash`/`config_id` or baked into a derived image | +| [trusted-workload-launcher](./trusted-workload-launcher) | Auditable launcher that pins a workload to a full upstream Git commit SHA (no auto-update). | | [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/README.md b/trusted-workload-launcher/README.md index 6b408bb..4ab0c7e 100644 --- a/trusted-workload-launcher/README.md +++ b/trusted-workload-launcher/README.md @@ -126,6 +126,23 @@ shell expansion in the parse step. | `REPO_SUBDIR` | Relative directory inside the repo to `cd` into before `INSTALL_CMD` and `RUN_CMD`. Must not be absolute and must not contain `..`. | | `CHILD_ENV_FILE` | Path to a separate env file. Each `KEY=VALUE` line is `export`ed into the environment seen by `INSTALL_CMD` and `RUN_CMD`. The file is parsed line-by-line just like the main config (not sourced). | +### Multi-line install or run logic + +`INSTALL_CMD` and `RUN_CMD` are single-line shell strings — the config is +line-oriented and the launcher does not implement multi-line value parsing. +This is intentional: a config that "almost" parses a multi-line script is a +trust hazard. If you need more than one command, put a script in the +workload repo at the pinned commit (e.g. `scripts/install.sh`, +`scripts/run.sh`) and call it: + +``` +INSTALL_CMD=./scripts/install.sh +RUN_CMD=./scripts/run.sh +``` + +The script then lives at the same pinned `COMMIT_SHA` as the workload, so +its bytes are covered by the same audit. + ### What the launcher will and will not do * Will: clone fresh if `WORK_DIR` is empty; reuse the existing clone otherwise diff --git a/trusted-workload-launcher/VERIFY.md b/trusted-workload-launcher/VERIFY.md index 8b68f7b..1748b97 100644 --- a/trusted-workload-launcher/VERIFY.md +++ b/trusted-workload-launcher/VERIFY.md @@ -1,187 +1,191 @@ # Verifying a trusted-workload-launcher deployment -How a relying party verifies that a dstack CVM is running the +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. -This is a verification guide for the launcher example. It is not a TIP -receipt service. See [Limitations](#limitations) for what the launcher -deliberately does **not** provide. +## Quick path (5 steps) -## Goal (in TIP terms) - -A verifier wants to: - -1. **Establish workload identity.** Bind a hardware-attested dstack TEE to a - specific image identity (launcher image digest) and a specific config - identity (the `REPO_URL` + `COMMIT_SHA` + commands the launcher will - execute). -2. **Verify evidence of execution.** Confirm the running CVM is a genuine - dstack TEE (TDX quote, dstack measurements) and that its measured image - and attested config match the expected identity from step 1. -3. **Link derived work to that identity.** The "derived work" produced by - this deployment is the workload code at the pinned upstream commit — - bind that commit to the identity so a reviewer can audit exactly what - ran. - -The launcher does the third step *deterministically*: given the same image -identity and config identity, the same `COMMIT_SHA` always ends up on -`HEAD`, or the launcher refuses to start. Steps 1 and 2 are dstack -attestation; this document explains how to chain them together. - -## What is being verified - -| Object | Identity is | Attested by | -| --- | --- | --- | -| Launcher implementation | `trusted-workload-launcher` OCI image digest | dstack TEE measurement of the running image | -| Launcher config — `REPO_URL`, `COMMIT_SHA`, `INSTALL_CMD`, `RUN_CMD`, optional `REPO_SUBDIR` / `CHILD_ENV_FILE` / `WORK_DIR` | bytes of the config file the launcher loads at startup | dstack `compose_hash` / `config_id`, **or** the digest of a derived image that bakes the config in | -| Workload code | upstream Git repo at the full SHA `COMMIT_SHA` | the launcher's `git checkout --detach $SHA` + `git rev-parse HEAD` reverification, plus the upstream Git host serving that commit | - -Runtime evidence — `HEAD verified: ` in container logs, workload -self-checks, etc. — is **corroborating only**. The trust anchor is the -launcher image digest + attested config; logs are not signed receipts. - -## Two verification paths - -The verifier picks the path that matches how the deployment was packaged. - -### Path A — generic launcher image + dstack-attested compose - -The deployed CVM runs the generic launcher image, with the config carried -in a compose file (e.g. via compose `configs:` content). dstack measures -the compose into the attested app config. +For a verifier who already trusts dstack's attestation tooling, the whole +chain comes down to: +```mermaid +flowchart LR + A[dstack attestation] --> B[image digest
+ compose hash] + B --> C[Sigstore attestation
for launcher image] + C --> D[GitHub workflow
+ repo + commit] + B --> E[config bytes
+ COMMIT_SHA] + E --> F[upstream repo
at COMMIT_SHA] ``` -dstack attestation ──pins──► launcher image digest (= trusted-workload-launcher@sha256:...) - compose_hash / config_id (= a specific compose, including the config bytes) - │ - └─► REPO_URL, COMMIT_SHA, ... + +1. **Pull the dstack attestation** for the CVM with + `phala cvms attestation --cvm-id --json`, and verify the TDX quote + with the dstack verifier (or trust Phala Cloud's verifier for the lite + path). +2. **Read the attested compose** out of the quote. The launcher image + digest and the inline `configs:` block containing `REPO_URL` and + `COMMIT_SHA` both live there; both are covered by the dstack + `compose-hash` measurement. +3. **Verify launcher image provenance.** Check the Sigstore attestation on + the image digest: it must be signed by the + `Dstack-TEE/dstack-examples` GitHub Actions workflow that produced it, + from a known repo, ref, and commit. +4. **Confirm the pinned workload commit** by checking out the upstream + repo at `COMMIT_SHA` and reviewing it. +5. **Spot-check runtime logs** — `phala logs --cvm-id ` should show + `HEAD verified: `. Logs are corroborating only; the trust + root is steps 1–4. + +If all five line up, the bytes executing in the TEE are exactly the +upstream commit you audited, produced by an audited launcher. + +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
RUN_CMD] -.inline configs:.-> CMP + CMP[docker-compose.yml] --> CH[compose-hash
= sha256 app_compose] + CH --> Q[dstack attestation
TDX quote] ``` -A verifier checks both: the image digest *and* the compose hash. Either -mismatch invalidates the deployment. +The compose YAML references the generic launcher image by digest and +provides the launcher's config via a compose `configs:` block (with +`content:` inline). 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. -### Path B — derived image with config baked in +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. -A small downstream image is built `FROM trusted-workload-launcher@sha256:...` -that `COPY`s the config in. Its image digest covers both the launcher and -the config bytes. +### 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] ``` -dstack attestation ──pins──► derived image digest - ├── FROM trusted-workload-launcher@sha256:... (launcher identity) - └── COPY config.conf (config identity) - │ - └─► REPO_URL, COMMIT_SHA, ... -``` -A verifier needs only the derived image digest; the launcher digest and -the pin both follow from it. (This is the path used in the production -smoke test below.) +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. -## Verifier checklist +## Step-by-step verifier checklist -The following commands assume the Phala CLI (`phala`) authenticated against -the workspace that owns the deployment. The CVM identifier can be a UUID, -`app_id`, instance ID, or name. +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. Get the attestation +### 1. Fetch and verify the dstack attestation ```sh phala cvms attestation --cvm-id --json > attestation.json ``` -The JSON contains the dstack/TDX quote and platform evidence the CVM is -willing to expose. A verifier feeds it into a dstack/TDX quote verifier to -confirm the platform identity, signing certs, and TCB. +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: -`phala cvms attestation` (no `--json`) prints a human summary. The exact -raw-quote extraction shape depends on dstack version; use the JSON output -as the authoritative source. +* The quote signs over dstack's measurements with a valid Intel TDX + signing chain. +* The measurements are consistent with the running platform identity. -### 2. Verify hardware/platform evidence +### 2. Read image digest and compose hash from the attestation -Run the dstack-side verifier (or the Phala Cloud trusted endpoint, as the -lite path) against `attestation.json` to confirm: +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. -* The TDX quote signs over dstack's measurement of the running image. -* dstack's measurements (`MRTD`, RTMR0–3, `compose_hash`, `instance_id`, - app contract) are consistent with the platform identity certificates. - -Phala Cloud's dashboard for the app (e.g. -`https://cloud.phala.com/dashboard/cvms/`) renders the parsed -attestation for cross-checking. +```sh +jq -r '.tcb_info.app_compose' attestation.json | sha256sum +jq -r '.tcb_info.event_log[] | select(.event=="compose-hash") | .event_payload' attestation.json +``` -### 3. Compare the deployed image digest to what you expect +The two hex strings must match. Then parse the compose and pull the +launcher image reference plus the inline `configs:` block: ```sh -phala ps --cvm-id --json | jq -r '.containers[] | .image' +jq -r '.tcb_info.app_compose' attestation.json \ + | jq -r '.docker_compose_file' ``` -* **Path A**: the container image should be the generic launcher pinned by - digest, e.g. `docker.io//trusted-workload-launcher@sha256:`. Then - also check the compose hash: - ```sh - phala runtime-config --json | jq -r '.compose_hash // .data.compose_hash' - ``` - and compare it against the hash of the compose file you audited. -* **Path B**: the container image should be the derived image, pinned by - digest. Single comparison; no separate compose hash needed. +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. -If the running image digest doesn't match, stop — the deployment is not -what you audited. +### 3. Verify launcher image provenance via Sigstore -### 4. Verify launcher source provenance +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 -git -C log -1 --format=%H -- trusted-workload-launcher +gh attestation verify \ + --owner Dstack-TEE \ + oci://docker.io//trusted-workload-launcher@sha256: ``` -Check out `dstack-examples` at the commit you audited and inspect: - -* `trusted-workload-launcher/bin/trusted-workload-launcher` — the audited - script (no `eval` / `source`, full-SHA only, `git rev-parse` reverify). -* `trusted-workload-launcher/docker/Dockerfile` — base pinned by manifest - digest. +or equivalently with `cosign verify-attestation` against +`https://search.sigstore.dev/?hash=sha256:`. Confirm: -If the release process is reproducible (e.g. via the -`trusted-workload-launcher-release.yml` workflow that publishes a Sigstore -attestation), rebuild and confirm the resulting digest matches the one -from step 3. Otherwise, treat the published image digest + Sigstore -attestation as the chain of custody from this directory at `L` to the -deployed bytes. +* 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. -### 5. Verify config provenance and extract `COMMIT_SHA` +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. -* **Path A**: extract the config bytes from the compose file you audited - (the same one whose hash you checked in step 3). The relevant fields are - `REPO_URL`, `COMMIT_SHA`, optional `REPO_SUBDIR`, `INSTALL_CMD`, - `RUN_CMD`. Confirm the compose hash you compared earlier was over - exactly these bytes. -* **Path B**: re-build the derived image locally from the same - `Dockerfile`, base launcher digest, and config file you audited, and - confirm the resulting digest matches the one from step 3. The config - file in your audit is the same one inside the deployed image. +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. -Either way, you now have the authoritative `COMMIT_SHA`. +### 4. Extract and audit the workload pin -### 6. Audit the workload commit +Parse the `configs:` content from step 2 and read `REPO_URL`, +`COMMIT_SHA`, `INSTALL_CMD`, `RUN_CMD` (and optional `REPO_SUBDIR` / +`CHILD_ENV_FILE`). Then: ```sh git -C rev-parse --verify ``` -Confirm the upstream repo at `REPO_URL` contains `COMMIT_SHA`, then review +Confirm the upstream repo at `REPO_URL` contains `COMMIT_SHA`, and review the workload at that commit. This is the code that actually serves traffic. -### 7. Use runtime logs as corroboration only +### 5. Spot-check runtime logs ```sh phala logs --cvm-id -n 200 ``` -Expected lines: +Expected: ``` [trusted-workload-launcher] checking out @@ -189,28 +193,35 @@ Expected lines: [trusted-workload-launcher] exec in [/]: ``` -These confirm the launcher reached the post-checkout state. They are -**not signed**, so they don't replace steps 1–6 — they corroborate them. +These 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. Summary: +Phala on 2026-05-11 using the recommended compose-mounted-config path: | Field | Value | | --- | --- | -| Path | B (derived image with config baked in) | -| Launcher base | `docker.io/h4x3rotab/trusted-workload-launcher-smoke@sha256:a88a1052279f028cc0de7414ddb3ab439df0cad622abf36fed1195cf4fd3c5ad` | -| Derived image | `docker.io/h4x3rotab/trusted-workload-launcher-smoke@sha256:6c508c15c45c8aacbbbfab3754724ef9ef104a67e1c53a9c35b50be47e86433e` | +| 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` (master) | -| CVM name | `twl-smoke-20260511-091916` (deleted post-verification) | -| App ID | `app_2a242c979a76009770a88908df0dc6907aea37b8` | +| 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 derived image digest. `phala logs --cvm-id ` showed: +the expected launcher digest. `phala logs --cvm-id ` showed: ``` [trusted-workload-launcher] checking out 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d @@ -221,40 +232,31 @@ TWL_README_BYTES=13 TWL_READY ``` -The post-`exec` `TWL_PINNED_HEAD` line 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. `TWL_README_BYTES=13` matches Hello-World's README byte count. - -(Step 1 — TDX quote verification — was not part of this smoke run; the -CVM was deleted before a full quote was extracted. The shape of step 1 is -documented in the dstack verifier docs; the CLI command in this guide is -the authoritative entry point.) +`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 TIP receipt signing in the launcher.** The launcher fetches and - execs code; it does not produce signed receipts over its own outputs. - Workload identity for *individual responses* must be implemented by the - workload itself (e.g. an in-TEE signing key released by dstack KMS). -* **No 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. +* **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 config.** Path A requires a - separate compose-hash check; Path B folds the config into a single - image digest. Do not assume a generic launcher image digest implies a - workload pin. +* **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; the - verifier reviews and trusts that URL together with the rest of the - config. - -For workloads that need TIP receipt signing or a per-response workload -identity, build that on top of this launcher (or alongside it) — the -launcher's job stops at "the bytes running here are the bytes at -`COMMIT_SHA`." + `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 index 0c9d1e1..5388e53 100755 --- a/trusted-workload-launcher/bin/trusted-workload-launcher +++ b/trusted-workload-launcher/bin/trusted-workload-launcher @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# trusted-workload-launcher — minimal, auditable launcher for a workload pinned -# at a specific upstream Git commit. +# 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, runs the configured install step inside the checkout, @@ -68,7 +68,7 @@ require_tool() { require_tool git # Parse the config file line-by-line. We never source it, so a config cannot -# inject arbitrary commands during parsing — INSTALL_CMD and RUN_CMD are the +# inject arbitrary commands during parsing. INSTALL_CMD and RUN_CMD are the # only values that are later executed, and they go through 'bash -c'. parse_config() { local config_file=$1 @@ -211,7 +211,7 @@ main() { if [[ -n $CFG_REPO_SUBDIR ]]; then log "subdir: $CFG_REPO_SUBDIR" fi - log "install: ${CFG_INSTALL_CMD:-}" + log "install: ${CFG_INSTALL_CMD:-}" log "run: $CFG_RUN_CMD" if [[ -n $CFG_CHILD_ENV_FILE ]]; then log "envfile: $CFG_CHILD_ENV_FILE" From 68cb65886d06dd233be95d169db648b39e4d7805 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Mon, 11 May 2026 18:07:12 +0000 Subject: [PATCH 03/13] trusted-workload-launcher: align reproducibility wording Drop "reproducibly built" claims outside VERIFY.md. The release workflow publishes a Sigstore build-provenance attestation that binds the image digest to a specific GitHub workflow run / repo / ref / SHA; that is a signed chain of custody, not a guarantee of bit-for-bit reproducibility. Updated README.md trust-model diagram and verifier list, and the examples/web-app.conf header. --- trusted-workload-launcher/README.md | 16 +++++++++++----- trusted-workload-launcher/examples/web-app.conf | 16 +++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/trusted-workload-launcher/README.md b/trusted-workload-launcher/README.md index 4ab0c7e..5dccae2 100644 --- a/trusted-workload-launcher/README.md +++ b/trusted-workload-launcher/README.md @@ -47,9 +47,12 @@ pinned workload commit, see [`VERIFY.md`](./VERIFY.md). ``` launcher image digest ──► launcher implementation identity - (this directory at commit L, - reproducibly built; published by the release - workflow with a Sigstore attestation) + (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 @@ -75,8 +78,11 @@ attestation: 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 a reproducible - build of this directory at commit `L`. +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. diff --git a/trusted-workload-launcher/examples/web-app.conf b/trusted-workload-launcher/examples/web-app.conf index 0994dfb..0569d06 100644 --- a/trusted-workload-launcher/examples/web-app.conf +++ b/trusted-workload-launcher/examples/web-app.conf @@ -5,15 +5,17 @@ # to. The launcher refuses to start unless COMMIT_SHA is a full 40-hex SHA-1 # or 64-hex SHA-256. Branches, tags, and short SHAs are rejected. # -# REPO_URL must be the public canonical URL of the workload repository. When -# the launcher image is built reproducibly, a verifier checks: +# REPO_URL must be the public canonical URL of the workload repository. +# A verifier checks: # -# image digest -> launcher repo at commit L -# -> this config file -# -> upstream workload repo at COMMIT_SHA +# image digest -> launcher repo at commit L +# (via the Sigstore build-provenance attestation +# on the published image digest) +# attested config -> REPO_URL + COMMIT_SHA +# (via dstack compose-hash, or a derived image digest) +# upstream repo -> workload bytes at COMMIT_SHA # -# so the launcher and its config together pin exactly which workload code -# runs inside the dstack TEE. +# Together these pin exactly which workload code runs inside the dstack TEE. REPO_URL=https://github.com/example-org/example-web-app.git COMMIT_SHA=0000000000000000000000000000000000000000 From b0478b6bba7c72aebcbaf55c9a1baa3c07bf6fc0 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Mon, 11 May 2026 18:41:34 +0000 Subject: [PATCH 04/13] trusted-workload-launcher: default to repo-owned tee-launch.sh Make INSTALL_CMD and RUN_CMD optional and add a recommended default mode: when neither is set the launcher runs 'bash tee-launch.sh' from the pinned commit (under REPO_SUBDIR if set, else repo root). The script's bytes are covered by source provenance of COMMIT_SHA, so the trust- bearing config in default mode collapses to REPO_URL + COMMIT_SHA. The existing RUN_CMD path is preserved as an advanced mode for repos that cannot host their own entry script; INSTALL_CMD remains optional and must accompany RUN_CMD. Aligned changes: - bin/trusted-workload-launcher: validate INSTALL_CMD requires RUN_CMD, drop "INSTALL_CMD must be set" and "RUN_CMD required" gates, add the default-mode branch that requires ./tee-launch.sh in target. No exec bit required (we invoke 'bash tee-launch.sh'). Refreshed top comment and parse_config comment to reflect the new model. - tests/run-tests.sh: extended fixture repo with c3 (adds sub/tee-launch.sh, intentionally non-executable), added default_mode_happy, default_mode_missing_script_fails, and install_cmd_without_run_cmd_fails. - README.md, examples/web-app.conf: lead with default mode; advanced mode explicitly scoped to "workload repo cannot host its own entry script"; clarified that no executable bit is required. - VERIFY.md: quick path shortened to 4 steps for default mode, with an explicit one-line note that advanced mode adds the command audit; step 4 covers tee-launch.sh under source provenance and step 5 shows the default-mode log lines. Updated the recommended-path Mermaid diagram. Smoke transcript annotated that the prior production run used advanced mode because Hello-World ships no tee-launch.sh, and that the compose-hash binding it demonstrates is identical. - Root README: Details-table description compressed to a true one-liner ("Run a pinned Git commit in a TEE."). --- README.md | 2 +- trusted-workload-launcher/README.md | 98 ++++++++++------- trusted-workload-launcher/VERIFY.md | 101 +++++++++++------- .../bin/trusted-workload-launcher | 92 +++++++++++----- .../examples/web-app.conf | 54 +++++----- trusted-workload-launcher/tests/run-tests.sh | 85 ++++++++++++++- 6 files changed, 294 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index ed93585..edb97ef 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Implementation details and infrastructure patterns. | Example | Description | |---------|-------------| | [launcher](./launcher) | Generic launcher pattern for Docker Compose apps (auto-update) | -| [trusted-workload-launcher](./trusted-workload-launcher) | Auditable launcher that pins a workload to a full upstream Git commit SHA (no 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/README.md b/trusted-workload-launcher/README.md index 5dccae2..1ef2513 100644 --- a/trusted-workload-launcher/README.md +++ b/trusted-workload-launcher/README.md @@ -1,9 +1,9 @@ # trusted-workload-launcher -A minimal, auditable launcher image for dstack: given a config file that +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 `exec`s the configured run -command — with no fallback to branches, tags, or short SHAs. +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 @@ -13,6 +13,14 @@ 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 the +fixed path `tee-launch.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 only audits two things: the launcher +image's identity, and the `REPO_URL` + `COMMIT_SHA` pair 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)). @@ -56,10 +64,11 @@ launcher image digest ──► launcher implementation identity launcher config file ──► workload pin (REPO_URL + full COMMIT_SHA U; selects which - upstream commit gets fetched and exec()d) + upstream commit gets fetched and run) ──► workload running inside the TEE - = workload repo at commit U + = workload repo at commit U, + starting from its tee-launch.sh ``` The published launcher image is a **generic** runner: the same image digest @@ -103,17 +112,18 @@ 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; the only values executed as shell are -`INSTALL_CMD` and `RUN_CMD`, which are documented to be intentionally -executed. +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 `tee-launch.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. +rejected. The config is parsed, not sourced — no command substitution and +no shell expansion in the parse step. ### Required @@ -122,32 +132,39 @@ shell expansion in the parse step. | `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`. | -| `INSTALL_CMD` | Shell command run inside the checkout (in `REPO_SUBDIR` if set). Pass `INSTALL_CMD=` to explicitly skip the install step. The key must still be present. | -| `RUN_CMD` | Shell command `exec`d after the install step. Because `exec` is used, signals reach the child program directly. | ### Optional | Key | Meaning | | --- | --- | -| `REPO_SUBDIR` | Relative directory inside the repo to `cd` into before `INSTALL_CMD` and `RUN_CMD`. Must not be absolute and must not contain `..`. | -| `CHILD_ENV_FILE` | Path to a separate env file. Each `KEY=VALUE` line is `export`ed into the environment seen by `INSTALL_CMD` and `RUN_CMD`. The file is parsed line-by-line just like the main config (not sourced). | - -### Multi-line install or run logic - -`INSTALL_CMD` and `RUN_CMD` are single-line shell strings — the config is -line-oriented and the launcher does not implement multi-line value parsing. -This is intentional: a config that "almost" parses a multi-line script is a -trust hazard. If you need more than one command, put a script in the -workload repo at the pinned commit (e.g. `scripts/install.sh`, -`scripts/run.sh`) and call it: - -``` -INSTALL_CMD=./scripts/install.sh -RUN_CMD=./scripts/run.sh -``` - -The script then lives at the same pinned `COMMIT_SHA` as the workload, so -its bytes are covered by the same audit. +| `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 `..`. | +| `CHILD_ENV_FILE` | Path to a separate env file. Each `KEY=VALUE` line is `export`ed into the environment seen by `tee-launch.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 `tee-launch.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: `tee-launch.sh` in the workload repo + +Recommended for every workload you control. The workload repo provides a +bash script at the fixed path `tee-launch.sh` (at the repo root, or at +`REPO_SUBDIR/tee-launch.sh` if `REPO_SUBDIR` is set). The launcher runs it +with `bash tee-launch.sh` after checkout — **no executable bit is +required**. All install/build/run logic lives in that script; the launcher +config carries only `REPO_URL` + `COMMIT_SHA` (+ local `WORK_DIR`). + +Because the script'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 +`tee-launch.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 @@ -159,21 +176,24 @@ its bytes are covered by the same audit. 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` anything beyond `INSTALL_CMD` / - `RUN_CMD`, which are executed via `bash -c`. +* Will not: source the config or `eval` config values. In default mode the + launcher executes `bash tee-launch.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`, `INSTALL_CMD`, and `RUN_CMD` for your workload. +`COMMIT_SHA`, and (if you need it) `REPO_SUBDIR` for your workload, and +make sure the workload repo has a `tee-launch.sh` at the pinned commit. ```sh ./bin/trusted-workload-launcher ./examples/web-app.conf ``` -The launcher logs the resolved repo, commit, workdir, and commands at -startup, then logs the verified `HEAD` after checkout, before invoking the -install and run steps. +The launcher logs the resolved repo, commit, workdir, and selected mode at +startup, then logs the verified `HEAD` after checkout, before handing +control to `tee-launch.sh` (or `INSTALL_CMD` / `RUN_CMD` in advanced mode). ## Deploying with dstack @@ -227,8 +247,6 @@ configs: REPO_URL=https://github.com/example-org/example-web-app.git COMMIT_SHA= WORK_DIR=/var/lib/trusted-workload-launcher/example-web-app - INSTALL_CMD=npm ci --omit=dev - RUN_CMD=node server.js volumes: workload-checkout: diff --git a/trusted-workload-launcher/VERIFY.md b/trusted-workload-launcher/VERIFY.md index 1748b97..2941ec9 100644 --- a/trusted-workload-launcher/VERIFY.md +++ b/trusted-workload-launcher/VERIFY.md @@ -4,41 +4,47 @@ 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 (5 steps) +## Quick path (default mode, 4 steps) -For a verifier who already trusts dstack's attestation tooling, the whole -chain comes down to: +In default mode the workload repo provides its own `tee-launch.sh` at the +pinned commit, so the trust-bearing config is just `REPO_URL + COMMIT_SHA` +and the install/run command chain disappears from the verifier's checklist. +The whole chain is: ```mermaid flowchart LR - A[dstack attestation] --> B[image digest
+ compose hash] - B --> C[Sigstore attestation
for launcher image] - C --> D[GitHub workflow
+ repo + commit] - B --> E[config bytes
+ COMMIT_SHA] - E --> F[upstream repo
at COMMIT_SHA] + A[dstack attestation] --> B[launcher image digest
+ REPO_URL
+ COMMIT_SHA] + B --> C[Sigstore attestation
for launcher image digest
= dstack-examples@ref/SHA] + B --> D[upstream repo at COMMIT_SHA
incl. tee-launch.sh] ``` -1. **Pull the dstack attestation** for the CVM with - `phala cvms attestation --cvm-id --json`, and verify the TDX quote - with the dstack verifier (or trust Phala Cloud's verifier for the lite - path). -2. **Read the attested compose** out of the quote. The launcher image - digest and the inline `configs:` block containing `REPO_URL` and - `COMMIT_SHA` both live there; both are covered by the dstack - `compose-hash` measurement. -3. **Verify launcher image provenance.** Check the Sigstore attestation on - the image digest: it must be signed by the - `Dstack-TEE/dstack-examples` GitHub Actions workflow that produced it, - from a known repo, ref, and commit. -4. **Confirm the pinned workload commit** by checking out the upstream - repo at `COMMIT_SHA` and reviewing it. -5. **Spot-check runtime logs** — `phala logs --cvm-id ` should show - `HEAD verified: `. Logs are corroborating only; the trust - root is steps 1–4. - -If all five line up, the bytes executing in the TEE are exactly the +1. **Verify the dstack attestation.** + `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). Read out the deployed launcher image digest and the attested + `REPO_URL` + `COMMIT_SHA`. +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 `tee-launch.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 tee-launch.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 `tee-launch.sh`, +> those strings are trust-bearing config: read them from the attested +> compose in step 1 and audit them as if they were source code at the +> pinned commit. 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. @@ -56,16 +62,18 @@ downstream image; the image digest then covers both launcher and pin. ```mermaid flowchart LR L[launcher image
@sha256:<L>] --> CMP - P[config bytes
REPO_URL
COMMIT_SHA
RUN_CMD] -.inline configs:.-> 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). 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. +`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 @@ -167,17 +175,21 @@ evidence of tampering. ### 4. Extract and audit the workload pin -Parse the `configs:` content from step 2 and read `REPO_URL`, -`COMMIT_SHA`, `INSTALL_CMD`, `RUN_CMD` (and optional `REPO_SUBDIR` / -`CHILD_ENV_FILE`). Then: +Parse the `configs:` content from step 2 and read `REPO_URL` and +`COMMIT_SHA` (plus optional `REPO_SUBDIR` / `CHILD_ENV_FILE`). In default +mode there are no `INSTALL_CMD` / `RUN_CMD` strings to audit — the entry +point is the fixed-path `tee-launch.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 treat +them as trust-bearing config. ```sh git -C rev-parse --verify ``` Confirm the upstream repo at `REPO_URL` contains `COMMIT_SHA`, and review -the workload at that commit. This is the code that actually serves -traffic. +the workload at that commit, including `tee-launch.sh` in default mode. +This is the code that actually serves traffic. ### 5. Spot-check runtime logs @@ -185,16 +197,19 @@ traffic. phala logs --cvm-id -n 200 ``` -Expected: +Default mode expected: ``` [trusted-workload-launcher] checking out [trusted-workload-launcher] HEAD verified: -[trusted-workload-launcher] exec in [/]: +[trusted-workload-launcher] mode: default (workload repo tee-launch.sh) +[trusted-workload-launcher] exec in [/]: bash tee-launch.sh ``` -These show the launcher reached the post-checkout state. They are not -signed, so they don't replace steps 1–4 — they corroborate. +Advanced mode shows the explicit `RUN_CMD` instead of `bash tee-launch.sh` +on the last line. 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)). @@ -202,7 +217,11 @@ 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: +Phala on 2026-05-11 using the recommended compose-mounted-config path. +The pinned upstream (`octocat/Hello-World`) does not host a +`tee-launch.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 | | --- | --- | diff --git a/trusted-workload-launcher/bin/trusted-workload-launcher b/trusted-workload-launcher/bin/trusted-workload-launcher index 5388e53..aee120a 100755 --- a/trusted-workload-launcher/bin/trusted-workload-launcher +++ b/trusted-workload-launcher/bin/trusted-workload-launcher @@ -3,8 +3,16 @@ # specific upstream Git commit. # # Reads one env-file config, clones the configured repo, hard-pins to the -# requested commit SHA, runs the configured install step inside the checkout, -# then exec()s the configured run command so signals reach the child program. +# requested commit SHA, then hands control to the workload. +# +# Default mode (recommended): the workload repo ships an entry point at the +# fixed path 'tee-launch.sh' (under REPO_SUBDIR if set, else repo root). The +# launcher runs it with 'bash tee-launch.sh' after checkout, so no install +# or run command is part of the launcher's trust-bearing config. +# +# Advanced mode: setting RUN_CMD in the config overrides the default entry +# point with a custom command; INSTALL_CMD may optionally precede it. Use +# this only when the workload repo cannot host its own tee-launch.sh. # # The launcher is intentionally tiny: it knows nothing about the workload it # runs. Its only job is to make the bytes that execute correspond exactly to @@ -23,8 +31,8 @@ usage() { Usage: $PROG The config file is a line-oriented KEY=VALUE env file. It is parsed, NOT -sourced; the launcher does not eval untrusted config beyond the two values -INSTALL_CMD and RUN_CMD, which are intentionally executed via 'bash -c'. +sourced; the launcher only executes (via 'bash -c' / 'bash