diff --git a/.github/workflows/git-launcher-release.yml b/.github/workflows/git-launcher-release.yml new file mode 100644 index 0000000..174d9d9 --- /dev/null +++ b/.github/workflows/git-launcher-release.yml @@ -0,0 +1,92 @@ +name: git-launcher Release +on: + workflow_dispatch: {} + push: + tags: + - 'git-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 }}/git-launcher + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse version from tag + run: | + VERSION=${GITHUB_REF#refs/tags/git-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: git-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: git-launcher + file: git-launcher/docker/Dockerfile + push: true + tags: docker.io/${{ vars.DOCKERHUB_ORG }}/git-launcher:${{ env.VERSION }} + platforms: linux/amd64 + labels: | + org.opencontainers.image.title=git-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 }}/git-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 "## git-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: | + ## git-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/.github/workflows/reproducible-build.yml b/.github/workflows/reproducible-build.yml index 3b0218d..3750414 100644 --- a/.github/workflows/reproducible-build.yml +++ b/.github/workflows/reproducible-build.yml @@ -2,6 +2,11 @@ name: Reproducible Build on: push: + # Run this tutorial workflow for branch pushes only. Without an explicit + # branch filter, release tag pushes from unrelated examples can evaluate + # this push trigger too. + branches: + - '**' paths: - 'tutorial/01a-reproducible-builds/**' pull_request: diff --git a/README.md b/README.md index 4e6e5e5..f9a7a22 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) | +| [git-launcher](./git-launcher) | Bind TEE workload code provenance to an attested Git commit pin. | | [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/git-launcher/.dockerignore b/git-launcher/.dockerignore new file mode 100644 index 0000000..b2266cf --- /dev/null +++ b/git-launcher/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +tests +examples +README.md diff --git a/git-launcher/README.md b/git-launcher/README.md new file mode 100644 index 0000000..aa356fa --- /dev/null +++ b/git-launcher/README.md @@ -0,0 +1,329 @@ +# git-launcher + +`git-launcher` runs a workload from one exact Git commit inside a dstack +container. You give it a config file with a Git repository URL, a full commit +SHA, and a checkout directory. It fetches that commit, verifies that `HEAD` is +exactly the commit you configured, and then hands control to the workload's +entry script. + +Use it when you want a generic launcher image whose image digest is stable and +auditable, while the workload identity comes from an attested config file. +The launcher does not make arbitrary code trustworthy by itself. It makes the +question "which bytes ran in the TEE?" answerable. + +## What it pins + +`git-launcher` has two trust inputs: + +| Input | What it identifies | How a verifier checks it | +| --- | --- | --- | +| Launcher image digest | The launcher implementation in this directory | Verify the published image digest and its Sigstore build-provenance attestation | +| Launcher config | The workload repo, commit, and selected entry script | Verify the config through dstack `compose_hash` / `config_id`, or through a derived image digest | + +The launcher image is generic. The same image digest can run different +workloads if the attested config changes. A production verifier must therefore +check both the image digest and the config. + +For the full verifier procedure, see [Verifying a git-launcher deployment](./VERIFY.md). + +## When to use it + +Use `git-launcher` when: + +- You need to deploy a workload from a specific Git commit, not a branch or tag. +- You want the launcher image to stay small enough to audit directly. +- You want workload install, build, and run logic to live in the workload repo. +- You can attest the launcher config with dstack compose measurements or a + derived image digest. + +Do not use it when: + +- You want automatic updates from a branch or tag. Use [`launcher/`](../launcher) + for that pattern. +- The workload cannot tolerate a clean checkout on every boot. +- You need per-response cryptographic identity from the workload. The launcher + provides deployment-level identity only; the workload must sign its own + responses if that property is required. + +## How it works + +On startup, `git-launcher`: + +1. Parses a line-oriented config file. By default it reads + `/etc/git-launcher/config.conf`; you can pass a different path for local + testing. The config is parsed, not sourced. +2. Rejects missing required keys, unknown keys, short SHAs, branches, and tags. +3. Creates or reuses `WORK_DIR` if it is a Git checkout for the same `REPO_URL`. +4. Runs `git fetch --tags --prune origin`. +5. Checks out `COMMIT_SHA` in detached mode. +6. Runs `git reset --hard COMMIT_SHA` and `git clean -ffdx`. +7. Verifies `git rev-parse HEAD` equals `COMMIT_SHA`. +8. Runs the workload entry script, or advanced `RUN_CMD` mode if configured. + +Missing commits are hard failures. There is no fallback to a branch, tag, +latest commit, or previous checkout. + +## Prepare a workload repo + +For workloads you control, use default mode: put a Bash entry script in the +workload repository and pin the commit that contains it. + +```text +example-workload/ + entrypoint.sh + scripts/ + src/ +``` + +`entrypoint.sh` is the workload boundary. It should: + +- Install or validate the runtime dependencies the workload needs. +- Build or prepare the application from the pinned source tree. +- Be safe to run again after a container restart. +- Exit non-zero when install, build, configuration, or startup fails. +- `exec` the long-running workload process so it becomes PID 1. +- Keep mutable state, databases, uploads, retained request bodies, and build + caches outside `WORK_DIR`. + +A minimal entry script looks like this: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null && pwd) +cd "$SCRIPT_DIR" + +./scripts/build.sh +exec ./bin/server +``` + +The launcher runs the script with `bash entrypoint.sh`, so the file does not +need the executable bit. + +## Write the launcher config + +Create an env-file style config: + +```conf +REPO_URL=https://github.com/example-org/example-workload.git +COMMIT_SHA= +WORK_DIR=/var/lib/git-launcher/example-workload +``` + +Rules: + +- `COMMIT_SHA` must be a full 40-hex SHA-1 or 64-hex SHA-256 commit hash. +- `WORK_DIR` is a checkout cache, not application state. +- Blank lines and lines starting with `#` are ignored. +- Matching single or double quotes around a value are stripped once. +- Unknown keys are rejected. +- Values are not shell-expanded during parsing. + +## Run locally + +Local runs require `bash`, `git`, and standard coreutils. After writing a +config file for a real workload commit, run the launcher script directly. +Pass the path explicitly when your local config is not at the container default +`/etc/git-launcher/config.conf`: + +```sh +./bin/git-launcher ./config.conf +``` + +Expected startup logs include: + +```text +[git-launcher] config: ./config.conf +[git-launcher] repo: https://github.com/example-org/example-workload.git +[git-launcher] commit: +[git-launcher] mode: default (workload repo entry script) +[git-launcher] checking out +[git-launcher] scrubbing checkout +[git-launcher] HEAD verified: +[git-launcher] exec in /var/lib/git-launcher/example-workload: bash entrypoint.sh +``` + +Logs are useful for development and smoke tests. They are not signed evidence; +remote verification must use dstack attestation and the image provenance chain +described in [VERIFY.md](./VERIFY.md). + +## Deploy with dstack + +Always pin the launcher image by OCI digest: + +```yaml +image: docker.io//git-launcher@sha256: +``` + +Do not deploy by mutable tag in production. + +### Recommended: attest config through compose + +Put the launcher config in the compose file with `configs:`. dstack measures +the compose into the attested app config, so changing either the image digest +or the workload pin changes the attestation. + +```yaml +services: + workload: + image: docker.io//git-launcher@sha256: + configs: + - source: pin + target: /etc/git-launcher/config.conf + environment: + APP_CONFIG_PATH: /var/lib/example-workload/config.json + volumes: + - workload-checkout:/var/lib/git-launcher + - workload-state:/var/lib/example-workload + - /var/run/dstack.sock:/var/run/dstack.sock + restart: unless-stopped + +configs: + pin: + content: | + REPO_URL=https://github.com/example-org/example-workload.git + COMMIT_SHA= + WORK_DIR=/var/lib/git-launcher/example-workload + +volumes: + workload-checkout: + workload-state: +``` + +Use Docker Compose `environment:` for non-secret runtime settings that should +be visible in the attested deployment. Use dstack encrypted secrets, dstack +KMS, or mounted secret files for secrets. + +Mount `/var/run/dstack.sock` when the workload uses the dstack SDK for KMS keys +or TDX quotes. The launcher does not use the socket directly. + +### Alternative: bake config into a derived image + +If you want one image digest to determine both the launcher and the workload +pin, build a small downstream image: + +```dockerfile +FROM docker.io//git-launcher@sha256: +COPY config.conf /etc/git-launcher/config.conf +``` + +Deploy the derived image by its own digest. The derived image digest now binds +the launcher implementation and the config bytes. This is useful when downstream +tooling cannot use compose `configs:`, but it means changing the workload pin +requires rebuilding the derived image. + +### Development only: bind-mount config from the host + +For local iteration, bind-mounting the config is convenient: + +```yaml +services: + workload: + image: docker.io//git-launcher@sha256: + volumes: + - ./config.conf:/etc/git-launcher/config.conf:ro + - workload-checkout:/var/lib/git-launcher + - /var/run/dstack.sock:/var/run/dstack.sock + restart: unless-stopped + +volumes: + workload-checkout: +``` + +Do not use this binding as production evidence. The host can replace the file +without changing the dstack attestation. + +## Config reference + +The launcher accepts these keys only. + +| Key | Required | Mode | Meaning | +| --- | --- | --- | --- | +| `REPO_URL` | Yes | All | Git URL of the workload repo. HTTPS and SSH-style Git URLs are accepted by `git clone`. | +| `COMMIT_SHA` | Yes | All | Full 40-hex SHA-1 or 64-hex SHA-256 commit hash. Branches, tags, and short SHAs are rejected. | +| `WORK_DIR` | Yes | All | Local checkout directory. Created if empty. Reused only when it is already a Git checkout whose `origin` exactly matches `REPO_URL`. | +| `REPO_SUBDIR` | No | All | Relative subdirectory to enter before running the entry script or `RUN_CMD`. Must not be absolute or contain `..`. | +| `ENTRYPOINT_SCRIPT` | No | Default | Relative path to the Bash entry script. Defaults to `entrypoint.sh`. Must not be absolute or contain `..`. | +| `RUN_CMD` | No | Advanced | Shell command to exec with `bash -c` instead of the default entry script. Use only when the workload repo cannot contain an entry script. | +| `INSTALL_CMD` | No | Advanced | Shell command to run before `RUN_CMD`. Only valid when `RUN_CMD` is set. | + +Default mode is recommended. In default mode, the trust-bearing config is +`REPO_URL`, `COMMIT_SHA`, and whichever of `REPO_SUBDIR` or +`ENTRYPOINT_SCRIPT` you set, because those fields select the code that runs. +`WORK_DIR` is local storage plumbing. + +Advanced mode is for third-party repos that you cannot modify. In advanced +mode, `RUN_CMD` and `INSTALL_CMD` are also trust-bearing config because the +launcher executes those strings with `bash -c`. + +## Persistent volume behavior + +`WORK_DIR` should usually live on a persistent Docker volume, such as +`/var/lib/git-launcher/`. + +On first boot, the launcher clones `REPO_URL` into `WORK_DIR`. On later boots, +it reuses the directory only if it is already a Git checkout whose `origin` +matches `REPO_URL`. Every boot still fetches, checks out `COMMIT_SHA`, resets +tracked files, removes untracked files, and verifies `HEAD`. + +Treat `WORK_DIR` as a source cache. Do not store application state, SQLite +databases, uploads, retained bodies, or build artifacts there if the workload +expects them to survive a restart. Use a separate workload-owned volume. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `COMMIT_SHA must be a full... hash` | The config uses a branch, tag, short SHA, uppercase hex, or non-hex text | Use the full lowercase commit hash from the workload repo | +| `git checkout failed for commit...` | The repo at `REPO_URL` does not contain the configured commit, or the launcher cannot fetch it | Check the repo URL, network access, credentials, and commit hash | +| `existing checkout... origin ... but config wants...` | `WORK_DIR` already contains a clone of a different repo | Use a new `WORK_DIR`, or remove the old checkout intentionally | +| `WORK_DIR ... is not empty and is not a git checkout` | The checkout directory contains unrelated files | Point `WORK_DIR` at an empty directory or a valid clone of `REPO_URL` | +| `entry script ... not found` | Default mode is selected, but the pinned commit lacks the configured entry script | Add the script to the workload repo, set `ENTRYPOINT_SCRIPT`, or use advanced `RUN_CMD` mode | +| `INSTALL_CMD requires RUN_CMD` | The config sets install logic without selecting advanced mode | Add `RUN_CMD`, or move install logic into the workload repo entry script | + +## Tests + +Run the integration test suite from this directory: + +```sh +./tests/run-tests.sh +``` + +The tests create a local Git fixture and verify that the launcher: + +- Runs default-mode `entrypoint.sh` and advanced-mode `RUN_CMD`. +- Rejects branches, tags, short SHAs, missing required keys, and unknown keys. +- Refuses path traversal in `REPO_SUBDIR` and `ENTRYPOINT_SCRIPT`. +- Refuses a non-empty non-Git `WORK_DIR`. +- Refuses a reused checkout whose `origin` differs from `REPO_URL`. +- Scrubs dirty tracked files and untracked files before launch. +- Preserves normal process environment variables for the workload. +- Checks that the release workflow publishes image build provenance. + +## Release image provenance + +The release workflow at +[.github/workflows/git-launcher-release.yml](../.github/workflows/git-launcher-release.yml) +runs the launcher tests, builds and pushes +`docker.io/${DOCKERHUB_ORG}/git-launcher:`, and publishes a GitHub +artifact attestation for the pushed OCI digest. + +The attestation is a signed chain of custody from the GitHub Actions workflow +to the image digest. It is not a bit-for-bit reproducibility claim. A verifier +should compare the deployed digest with the attested digest and audit the +`git-launcher/` source at the commit named by that provenance. + +## Audit surface + +Before relying on a deployment, audit: + +1. `bin/git-launcher`, especially config parsing, SHA validation, checkout, + cleanup, `HEAD` verification, and the final exec path. +2. The attested launcher config: `REPO_URL`, `COMMIT_SHA`, and any + `REPO_SUBDIR`, `ENTRYPOINT_SCRIPT`, `RUN_CMD`, or `INSTALL_CMD`. +3. The workload repo at the pinned `COMMIT_SHA`, including the selected entry + script and all code it starts. + +If those three surfaces match the values in the dstack attestation and the +launcher image provenance, the deployment identifies one exact workload commit. diff --git a/git-launcher/VERIFY.md b/git-launcher/VERIFY.md new file mode 100644 index 0000000..823cf55 --- /dev/null +++ b/git-launcher/VERIFY.md @@ -0,0 +1,373 @@ +# Verifying a git-launcher deployment + +Use this guide when you need to verify that a dstack CVM is running +`git-launcher` and that the workload commit executed inside the TEE is the +one you audited. + +Successful verification proves a deployment-level identity: + +- the CVM attests to the expected dstack measurements and compose hash; +- the attested compose selects the expected launcher image digest; +- the launcher image digest has the expected Sigstore build provenance; +- the attested launcher config selects the expected workload repo and commit; +- the workload repo at that commit contains the code you reviewed. + +It does not prove that every response from the workload is signed by that +commit. If you need per-response identity, implement signing inside the +workload with a key released under the dstack KMS policy. + +Before you start, collect: + +- the CVM ID, app ID, instance ID, or name; +- the expected launcher image digest; +- the expected `REPO_URL` and full `COMMIT_SHA`; +- any expected `REPO_SUBDIR`, `ENTRYPOINT_SCRIPT`, `RUN_CMD`, or + `INSTALL_CMD`; +- the expected dstack OS reference values for `mrtd`, `rtmr0`, `rtmr1`, and + `rtmr2`; +- the workload repo checkout you intend to audit. + +## Quick path (default mode, 3 checks) + +In default mode the workload repo provides its own `entrypoint.sh` at the +pinned commit, so the trust-bearing config is `REPO_URL + COMMIT_SHA` +(plus `REPO_SUBDIR` and `ENTRYPOINT_SCRIPT` when used, since each selects +which script in the pinned repo gets run) and the install/run command +chain disappears from the verifier's checklist. `WORK_DIR` is local +plumbing and is not trust-bearing. +The whole chain is: + +```mermaid +flowchart LR + A[dstack attestation] --> B[launcher image digest
+ REPO_URL
+ COMMIT_SHA
+ REPO_SUBDIR / ENTRYPOINT_SCRIPT if used] + B --> C[Sigstore attestation
for launcher image digest
= dstack-examples@ref/SHA] + B --> D[upstream repo at COMMIT_SHA
incl. the chosen entry script] +``` + +1. **Compare the dstack attestation with your expected deployment.** + `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). Compare `mrtd` and `rtmr0`-`rtmr2` with the dstack OS image + reference values, the `compose-hash` event with + `sha256(tcb_info.app_compose)`, the launcher image digest in the + attested compose with your audited release digest, and the + attested `REPO_URL` + `COMMIT_SHA` (and `REPO_SUBDIR` / + `ENTRYPOINT_SCRIPT` if present) against the workload pin you intended + to deploy. The deep-path checklist below has the exact extraction + commands. +2. **Verify launcher image provenance via Sigstore.** Confirm the image + digest from step 1 carries a build-provenance attestation signed by + the expected `Dstack-TEE/dstack-examples` GitHub Actions workflow at + the ref / commit you audited. +3. **Audit the upstream commit.** Check out the workload repo at + `COMMIT_SHA` and review it. In default mode this single review covers + the workload code *and* its entry point `entrypoint.sh`; no separate + install/run command audit is needed. + +If all three checks line up, the bytes executing in the TEE are exactly the +upstream commit you audited, produced by an audited launcher. + +As a smoke check, `phala logs --cvm-id ` should show `scrubbing checkout`, +`HEAD verified: `, and `exec in : bash entrypoint.sh`. Logs +are not signed evidence; the trust root is the three checks above. + +> **Advanced mode adds one step.** If the launcher config sets `RUN_CMD` +> (and optionally `INSTALL_CMD`) instead of relying on `entrypoint.sh`, +> those strings are trust-bearing deployment config: read them from the +> attested compose in step 1 and audit them like any other deployment +> code — they are not part of the upstream repo at `COMMIT_SHA` and so +> are not covered by its source provenance. The simplification of the +> default mode is exactly that this extra step does not exist. + +The rest of this document explains how the chain works and what to do at +each step. + +## How the chain works + +Two configuration approaches are supported. The recommended one for +production is **compose-mounted config**: the workload pin lives inline in +the compose file, dstack measures the compose into the attested +`compose-hash`, and the same compose can be governed by dstack's KMS +policy. The other approach, **derived image**, bakes the config into a +downstream image; the image digest then covers both launcher and pin. + +### Recommended: compose-mounted config + +```mermaid +flowchart LR + L[launcher image
@sha256:<L>] --> CMP + P[config bytes
REPO_URL
COMMIT_SHA] -.inline configs:.-> CMP + CMP[docker-compose.yml] --> CH[compose-hash
= sha256 app_compose] + CH --> Q[dstack attestation
TDX quote] +``` + +The compose YAML references the generic launcher image by digest and +provides the launcher's config via a compose `configs:` block (with +`content:` inline). In default mode the config is just `REPO_URL` + +`COMMIT_SHA` + `WORK_DIR`; in advanced mode it also carries `RUN_CMD` +(and optionally `INSTALL_CMD`). Either way dstack measures the resulting +`app_compose` JSON into the quote as the `compose-hash` event, so changing +either the image reference or the config bytes changes the attestation. + +This is also the surface that dstack KMS policy governs: a CVM can only +unwrap KMS-protected secrets while running a compose whose hash matches +what the policy allows. + +### Alternative: derived image + +```mermaid +flowchart LR + L[launcher image
@sha256:<L>] --> D[derived image
FROM launcher
COPY config.conf] + D --> Q[dstack attestation
TDX quote] +``` + +A small downstream image is built `FROM` the launcher image and `COPY`s +the config in. Its single digest binds both launcher and pin. The +attested compose carries just the derived image reference. This avoids +inline `configs:` but means the pin is no longer governed by compose-level +KMS policy — change the pin, rebuild the image, get a new digest. + +Use this path if you need a single digest to fully describe the workload, +or if downstream tooling cannot author compose `configs:` blocks. + +## Step-by-step verifier checklist + +The CLI calls below assume a Phala CLI authenticated against the workspace +that owns the CVM. The CVM identifier can be UUID, `app_id`, instance ID, +or name. + +### 1. Fetch and verify the dstack attestation + +```sh +phala cvms attestation --cvm-id --json > attestation.json +``` + +The JSON contains the TDX quote, `tcb_info` (with `mrtd`, `rtmr0`–`rtmr3`, +`event_log`, `app_compose`), and the certificate chain. Feed it into the +dstack verifier (or trust the Phala Cloud verifier as the lite path) to +confirm: + +* The quote signs over dstack's measurements with a valid Intel TDX + signing chain. +* The measurements are consistent with the running platform identity. + +### 2. Read image digest and compose hash from the attestation + +The attested compose lives at `tcb_info.app_compose` (a JSON string). Its +SHA-256 is the `compose-hash` event in `tcb_info.event_log` (`imr: 3`, +`event: "compose-hash"`), and is what the TDX quote attests. + +```sh +jq -r '.tcb_info.app_compose' attestation.json | sha256sum +jq -r '.tcb_info.event_log[] | select(.event=="compose-hash") | .event_payload' attestation.json +``` + +The two hex strings must match. Then parse the compose and pull the +launcher image reference plus the inline `configs:` block: + +```sh +jq -r '.tcb_info.app_compose' attestation.json \ + | jq -r '.docker_compose_file' +``` + +The image reference is what you compare to your published launcher image +in step 3; the `configs:` block is what you parse in step 5. + +#### Reference values to compare + +This step is where reference-value checking actually happens — the +attestation is only useful insofar as you compare its measurements to a +known-expected set. Concretely, before signing off on a deployment, decide +the expected value for each row below, then run the JSON-extraction +command and assert equality: + +| Reference value | Source of truth | Where in `attestation.json` | +| --- | --- | --- | +| Launcher image digest | The published image digest at the launcher release you audited (and that step 3 verifies via Sigstore). | The `image:` reference inside `tcb_info.app_compose.docker_compose_file`. | +| Compose hash | `sha256` of the JSON-encoded `tcb_info.app_compose` you audited locally. | `tcb_info.event_log[] \| select(.event=="compose-hash") \| .event_payload`. | +| `mrtd` | The TDX measurement of the dstack OS image you expect (published with each dstack OS release). | `tcb_info.mrtd`. | +| `rtmr0` / `rtmr1` / `rtmr2` | Boot-time measurements of the same dstack OS image. Published with the dstack release alongside `mrtd`. | `tcb_info.rtmr0` / `rtmr1` / `rtmr2`. | +| `os-image-hash` event | The dstack OS image hash you expect (matches the `mrtd` / `rtmr0..2` set above). | `tcb_info.event_log[] \| select(.event=="os-image-hash") \| .event_payload`. | +| `app-id` event | Either the on-chain dstack app contract / config ID you registered, or, for KMS-less deployments, the value you accept for this CVM. | `tcb_info.event_log[] \| select(.event=="app-id") \| .event_payload`. | + +A one-shot reference-comparison script looks roughly like this: + +```sh +expected_image=docker.io//git-launcher@sha256: +expected_compose_hash=$(sha256sum < audited-app_compose.json | awk '{print $1}') +expected_mrtd= +expected_rtmr0= +expected_rtmr1= +expected_rtmr2= + +a=attestation.json +[ "$(jq -r '.tcb_info.mrtd' $a)" = "$expected_mrtd" ] || { echo MRTD mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.rtmr0' $a)" = "$expected_rtmr0" ] || { echo RTMR0 mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.rtmr1' $a)" = "$expected_rtmr1" ] || { echo RTMR1 mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.rtmr2' $a)" = "$expected_rtmr2" ] || { echo RTMR2 mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.event_log[] | select(.event=="compose-hash") | .event_payload' $a)" \ + = "$expected_compose_hash" ] || { echo compose-hash mismatch >&2; exit 1; } +[ "$(jq -r '.tcb_info.app_compose | fromjson | .docker_compose_file' $a | grep -oP 'image:\s*\K\S+')" \ + = "$expected_image" ] || { echo launcher image mismatch >&2; exit 1; } +echo OK +``` + +`rtmr3` is intentionally not compared as a single reference value because +it is the running extension over the runtime event log (`app-id`, +`compose-hash`, `os-image-hash`, instance bring-up events, etc.); verify +its constituent events individually as above, or replay the event log +into `rtmr3` if your verifier supports it. + +### 3. Verify launcher image provenance via Sigstore + +The `git-launcher-release.yml` workflow publishes an +`actions/attest-build-provenance` attestation bound to the pushed image +digest. The attestation is not a claim of bit-for-bit reproducibility — it +is a signed statement that *this* OCI digest was produced by *this* +GitHub Actions workflow run, from a specific repo / ref / commit, using +the GitHub OIDC identity. + +```sh +gh attestation verify \ + --owner Dstack-TEE \ + oci://docker.io//git-launcher@sha256: +``` + +or equivalently with `cosign verify-attestation` against +`https://search.sigstore.dev/?hash=sha256:`. Confirm: + +* the subject digest equals the image digest from step 2; +* the signing identity is the expected + `Dstack-TEE/dstack-examples` workflow at the expected ref / commit. + +That commit is the source of truth for the launcher's bytes. Treat the +Sigstore attestation as the chain of custody from the +`git-launcher/` source at that commit to the deployed image +digest. + +If you want to go further you can rebuild the image from that commit and +compare digests. The image build is deterministic in practice (Ubuntu +base pinned by digest, minimal apt install, single `COPY` of the bash +script), but the release process does not guarantee bit-for-bit +reproducibility, so a digest mismatch on rebuild is not necessarily +evidence of tampering. + +### 4. Extract and audit the workload pin + +Parse the `configs:` content from step 2 and read `REPO_URL` and +`COMMIT_SHA` (plus `REPO_SUBDIR` and `ENTRYPOINT_SCRIPT` if present — +each selects which script in the pinned repo is used). `WORK_DIR` is +local plumbing only and is not part of the trust-bearing config. +Docker Compose `environment:` does not change the bytes that run, but it can +change workload behavior. Audit non-secret environment variables as runtime +deployment configuration, and keep secrets in encrypted secrets / KMS / mounted +secret files rather than inline compose. + +In default mode there are no `INSTALL_CMD` / `RUN_CMD` strings to audit — +the entry point is the fixed-path `entrypoint.sh` in the workload repo, +which is covered by source provenance of the pinned commit. In advanced +mode (`RUN_CMD` present), also read `RUN_CMD` and any `INSTALL_CMD` and +audit them as trust-bearing deployment config: they are not part of the +upstream repo at `COMMIT_SHA` and so are not covered by its source +provenance. + +```sh +git -C rev-parse --verify +``` + +Confirm the upstream repo at `REPO_URL` contains `COMMIT_SHA`, and review +the workload at that commit, including `/entrypoint.sh` in +default mode. This is the code that actually serves traffic. + +### 5. Spot-check runtime logs + +```sh +phala logs --cvm-id -n 200 +``` + +Default-mode output should include these lines (the launcher logs `mode` +during config summary, then the checkout/scrub/verify lines, then the `exec` +line, so they appear in this order): + +``` +[git-launcher] mode: default (workload repo entry script) +[git-launcher] checking out +[git-launcher] scrubbing checkout +[git-launcher] HEAD verified: +[git-launcher] exec in [/]: bash entrypoint.sh +``` + +Advanced mode logs `mode: advanced (RUN_CMD)` early on, and the last +`exec in ...:` line shows the explicit `RUN_CMD` instead of +`bash entrypoint.sh`. Either way these lines show the launcher reached +the post-checkout state. They are not signed, so they don't replace +steps 1–4 — they corroborate. + +A workload that needs signed runtime evidence should produce its own +attested output (see [Limitations](#limitations)). + +## Reference: production smoke transcript + +A real verification of this example was exercised against production +Phala on 2026-05-11 using the recommended compose-mounted-config path. +The pinned upstream (`octocat/Hello-World`) does not host a +`entrypoint.sh`, so the smoke used **advanced mode** to set `RUN_CMD` +inline; default-mode behavior is covered by the launcher's own test +suite. The compose-hash binding it demonstrates is identical: + +| Field | Value | +| --- | --- | +| Launcher image | `docker.io/h4x3rotab/git-launcher-smoke@sha256:0d3f2dbda5e6ae9513ea4e8e69dcbc87c1f3af29744f0e36b9814685e5739866` | +| Compose pattern | inline `configs:` with `content:` block carrying the launcher config | +| Workload repo | `https://github.com/octocat/Hello-World.git` | +| Pinned commit | `7fd1a60b01f91b314f59955a4e4d4e80d8edf11d` | +| CVM name | `twl-cfg-smoke-20260511-180207` (deleted post-verification) | +| App ID | `app_5696a018cb75b2beadb3b44e9a379058ca2ed6c3` | +| `compose-hash` (imr 3) | `995f0e566f6e14382dedfff53203eebbd729b7e0307724df0e60c6e4d1d2b752` | +| `sha256(app_compose_json)` | `995f0e566f6e14382dedfff53203eebbd729b7e0307724df0e60c6e4d1d2b752` — matches | + +The match between the `compose-hash` event in `tcb_info.event_log` and +the SHA-256 of `tcb_info.app_compose` is the binding the recommended path +relies on: change the compose (image reference or inline config bytes), +get a different attestation. + +`phala ps --cvm-id ` showed the running container's image was exactly +the expected launcher digest. `phala logs --cvm-id ` showed: + +``` +[git-launcher] checking out 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d +[git-launcher] HEAD verified: 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d +[git-launcher] exec in /var/lib/git-launcher/hello: ... +GIT_LAUNCHER_PINNED_HEAD=7fd1a60b01f91b314f59955a4e4d4e80d8edf11d +GIT_LAUNCHER_README_BYTES=13 +GIT_LAUNCHER_READY +``` + +`GIT_LAUNCHER_PINNED_HEAD` is from `git rev-parse HEAD` evaluated *inside +the TEE container* by the workload's `RUN_CMD`, so it is independent +corroboration that the bytes running are the pinned commit. + +## Limitations + +* **No receipt signing in the launcher.** The launcher fetches and execs + code; it does not sign its own outputs. Workload identity for + individual responses must be implemented by the workload itself (for + example via an in-TEE signing key released by dstack KMS). +* **No per-response workload identity key.** A relying party cannot ask + "is this response from the workload at `COMMIT_SHA`?" by checking a + signature the launcher produced. Identity here means "is the CVM + measured as running this image+config?" — a deployment-level identity, + not a per-response identity. +* **Runtime logs are not signed.** Logs are useful for forensics and + smoke testing but cannot be the trust root for a remote verifier. +* **Generic image digest alone does not bind the workload pin.** The + compose hash (compose-mounted path) or derived-image digest (alternative + path) is what binds them. +* **Sigstore attestation ≠ reproducibility.** Verifying the Sigstore + attestation tells you the image digest was produced by a specific + GitHub Actions workflow run from a specific commit. The release + process does not guarantee bit-for-bit rebuilds. +* **Trust in the upstream Git host.** The launcher verifies the + `COMMIT_SHA` it actually checked out, but it does not enforce which + Git host serves it. `REPO_URL` is part of the attested config; review + and trust that URL together with the rest of the config. diff --git a/git-launcher/bin/git-launcher b/git-launcher/bin/git-launcher new file mode 100755 index 0000000..134ad42 --- /dev/null +++ b/git-launcher/bin/git-launcher @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# git-launcher: minimal launcher for a workload pinned at a +# specific upstream Git commit. +# +# Reads one env-file config, clones the configured repo, hard-pins to the +# requested commit SHA, then hands control to the workload. +# +# Default mode (recommended): the workload repo ships an entry point at the +# path named by ENTRYPOINT_SCRIPT (default 'entrypoint.sh'), under REPO_SUBDIR +# if set else the repo root. The launcher runs it with 'bash