Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/build-tsm-shim.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: build tsm-shim

on:
push:
branches: [main]
paths:
- 'tsm-shim/**'
- '.github/workflows/build-tsm-shim.yml'
pull_request:
paths:
- 'tsm-shim/**'
- '.github/workflows/build-tsm-shim.yml'
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE: ghcr.io/dstack-tee/dstack-tsm-shim
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

# On PRs (incl. forks) GITHUB_TOKEN lacks packages:write, so only build.
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short

- name: Build and push
uses: docker/build-push-action@v6
with:
context: tsm-shim
file: tsm-shim/Dockerfile
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ volumes:
|---------|-------------|--------|
| [timelock-nts](./timelock-nts) | Raw socket usage (what the SDK wraps) | Available |
| [attestation/configid-based](./attestation/configid-based) | ConfigID-based verification | Available |
| [tsm-shim](./tsm-shim) | Run unmodified `configfs-tsm` binaries (inblob/outblob) via a sidecar | Available |

### Gateway & Domains

Expand Down
21 changes: 21 additions & 0 deletions tsm-shim/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Pure stdlib — no pip, no build deps. Built & pushed to GHCR by
# .github/workflows/build-tsm-shim.yml.
FROM python:3.12-slim

LABEL org.opencontainers.image.title="dstack-tsm-shim"
LABEL org.opencontainers.image.description="configfs-tsm compatibility shim: re-exposes dstack guest-agent GetQuote as inblob/outblob FIFOs"
LABEL org.opencontainers.image.source="https://github.com/Dstack-TEE/dstack-examples"

COPY tsm_shim.py /usr/local/bin/tsm_shim.py
COPY demo-app.py /usr/local/bin/demo-app.py

ENV TSM_REPORT_DIR=/run/tsm/report \
DSTACK_SOCKET=/var/run/dstack.sock

# Report healthy only once both FIFOs exist, so an app can gate on
# `depends_on: { tsm-shim: { condition: service_healthy } }` with no race.
HEALTHCHECK --interval=2s --timeout=2s --retries=30 --start-period=1s \
CMD test -p "$TSM_REPORT_DIR/inblob" && test -p "$TSM_REPORT_DIR/outblob" || exit 1

# Default role is the shim daemon (reads TSM_REPORT_DIR / DSTACK_SOCKET from env).
ENTRYPOINT ["python3", "/usr/local/bin/tsm_shim.py"]
114 changes: 114 additions & 0 deletions tsm-shim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# configfs-tsm shim (run unmodified TDX attestation binaries)

Some programs request a TDX quote through the **standard Linux interfaces** —
`configfs-tsm` (`/sys/kernel/config/tsm/report/*`, with `inblob`/`outblob`) and a
`/dev/tdx-guest` device — rather than through the dstack SDK / guest-agent
socket. On a stock dstack CVM those kernel interfaces aren't exposed to app
containers, so such binaries fail out of the box.

This example ships a small **sidecar** that bridges the gap. It re-exposes the
guest-agent's `GetQuote` RPC under the configfs-tsm file ABI: your app writes
`report_data` to `inblob` and reads the raw Intel DCAP TDX quote from `outblob`,
exactly as it would against the kernel.

- **No OS change** — pure userspace, runs in a normal container.
- **No FUSE, no `CAP_SYS_ADMIN`, no privileged mode, no device passthrough.**
- **No weaker attestation** — the quote is the genuine hardware quote and
`report_data` is forwarded byte-for-byte.

The shim image is built and published to GHCR by
[`.github/workflows/build-tsm-shim.yml`](../.github/workflows/build-tsm-shim.yml):
`ghcr.io/dstack-tee/dstack-tsm-shim`.

## Try it

```bash
phala deploy -n tsm-shim-example -c docker-compose.yaml
phala cvms logs <app_id> -c app
```

Expected `app` log:

```
quote length : 5010 bytes
quote header : 0400 (a TDX v4 quote starts with 0400)
report_data bound in quote: True
PASS - unmodified configfs-tsm app got a real TDX quote via the shim
```

## Adopt it in your app

Add the `tsm-shim` service and two volumes, then add the four lines marked `(+)`
to your existing service:

```yaml
services:
tsm-shim:
image: ghcr.io/dstack-tee/dstack-tsm-shim:latest
restart: unless-stopped
volumes:
- /var/run/dstack.sock:/var/run/dstack.sock
- tsm-report:/run/tsm/report

my-app:
image: your-app:latest
# ... your existing config ...
depends_on:
tsm-shim:
condition: service_healthy # (+) wait until the shim is ready
environment:
- TSM_REPORT_PATH=/run/tsm/report # (+) point your app at the shim dir
volumes:
- tsm-report:/run/tsm/report # (+) see the shim's inblob/outblob
- tsm-devstub:/dev/tdx-guest # (+) satisfy /dev/tdx-guest checks

volumes:
tsm-report: {}
tsm-devstub: {}
```

If your binary **hard-codes** `/sys/kernel/config/tsm/report`, mount the shared
volume there instead of using `TSM_REPORT_PATH`:

```yaml
volumes:
- tsm-report:/sys/kernel/config/tsm/report
```

For production, pin the image by digest (e.g.
`ghcr.io/dstack-tee/dstack-tsm-shim:latest@sha256:...`).

## How it works

`tsm-shim` exposes `inblob` and `outblob` as **named pipes (FIFOs)** in a shared
volume. A read of `outblob` blocks until the quote for the most recent `inblob`
write is ready — which matches configfs-tsm's write-then-read contract with no
race and no privileges. When `inblob` is written, the shim calls
`POST /GetQuote` on `/var/run/dstack.sock` and writes the returned quote to
`outblob`. The image reports healthy only once both FIFOs exist, so the app can
gate on `depends_on: { condition: service_healthy }`.

The `/dev/tdx-guest` device can't be created inside another container from a
sidecar, so an empty volume is mounted at that path — enough for the common
"does the device exist?" check. (dstack permits mounting under `/dev`.)

## Files

| File | Purpose |
|------|---------|
| `tsm_shim.py` | the sidecar daemon (pure Python stdlib, no dependencies) |
| `demo-app.py` | a stand-in unmodified configfs-tsm consumer (bundled self-test) |
| `Dockerfile` | builds the published image |
| `docker-compose.yaml` | the shim + demo app wired together |

## Limitations

- Covers the **configfs-tsm `inblob`/`outblob`** path (used by `go-configfs-tsm`,
recent `libtdx-attest`, etc.).
- Does **not** emulate the `/dev/tdx-guest` `TDX_CMD_GET_REPORT0` ioctl. That
returns a raw, locally-MAC'd TDREPORT, which dstack does not expose and which
can't be derived from a quote — so it isn't recoverable in userspace by any
shim. Binaries that drive the device by ioctl rather than configfs are out of
scope.
- One quote at a time per shim instance (matches the kernel's single
configfs-tsm entry). Run one shim per app.
60 changes: 60 additions & 0 deletions tsm-shim/demo-app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Demo "unmodified" attestation app — a standard configfs-tsm consumer.

Does exactly what a real binary built against the Linux TSM interface does:
1. check that the TDX guest device exists,
2. write up to 64 bytes of report_data to <dir>/inblob,
3. read the raw Intel DCAP TDX quote from <dir>/outblob.

The only deployment-specific knob is TSM_REPORT_PATH. On a stock dstack CVM that
directory is served by the tsm-shim sidecar instead of the kernel.
"""
import hashlib
import os
import sys
import time


def detect_tdx() -> bool:
return os.path.exists("/dev/tdx-guest") or os.path.exists("/dev/tdx_guest")


def main() -> None:
report_dir = os.environ.get("TSM_REPORT_PATH", "/sys/kernel/config/tsm/report/dstack")

if not detect_tdx():
print("FAIL: no TDX guest device (/dev/tdx-guest)")
sys.exit(1)

# `depends_on: condition: service_healthy` already gates startup on the shim
# FIFOs existing; this short retry just mirrors what real attestation libs do.
for _ in range(100):
if os.path.exists(f"{report_dir}/inblob"):
break
time.sleep(0.1)

report_data = hashlib.sha256(b"dstack-tsm-shim-demo").digest() # 32 bytes
with open(f"{report_dir}/inblob", "wb") as f:
f.write(report_data[:64].ljust(64, b"\0"))
with open(f"{report_dir}/outblob", "rb") as f:
quote = f.read()

print(f"report_dir : {report_dir}")
print(f"report_data : {report_data.hex()}")
print(f"quote length : {len(quote)} bytes")
print(f"quote header : {quote[:2].hex()} (a TDX v4 quote starts with 0400)")
bound = report_data[:32] in quote
print(f"report_data bound in quote: {bound}")
print(
"PASS - unmodified configfs-tsm app got a real TDX quote via the shim"
if (quote[:2].hex() == "0400" and bound)
else "FAIL - unexpected quote (header or report_data binding off)"
)

sys.stdout.flush()
while True:
time.sleep(3600)


if __name__ == "__main__":
main()
40 changes: 40 additions & 0 deletions tsm-shim/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: tsm-shim-example

# Run an UNMODIFIED configfs-tsm attestation binary on a stock dstack CVM.
#
# `tsm-shim` is a prebuilt sidecar (built & published to GHCR by
# .github/workflows/build-tsm-shim.yml) that re-exposes the dstack guest-agent's
# GetQuote RPC under the standard configfs-tsm file ABI (inblob/outblob). Your
# app talks to the kernel-style interface it already expects; the shim forwards
# report_data to real TDX hardware and returns the genuine quote. No OS change,
# no FUSE, no privileged container, no extra capabilities.
#
# The `app` service below is a self-test (it reuses the shim image only to run a
# bundled demo client). In your own deployment, replace `app` with your service
# and add the four lines marked (+).

services:
# ---------- the shim sidecar ----------
tsm-shim:
image: ghcr.io/dstack-tee/dstack-tsm-shim:latest
restart: unless-stopped
volumes:
- /var/run/dstack.sock:/var/run/dstack.sock # source of real quotes
- tsm-report:/run/tsm/report # FIFOs shared with the app

# ---------- your app (here: a bundled demo client) ----------
app:
image: ghcr.io/dstack-tee/dstack-tsm-shim:latest # <- replace with your image
entrypoint: ["python3", "/usr/local/bin/demo-app.py"] # <- your app's entrypoint
depends_on:
tsm-shim:
condition: service_healthy # (+) wait until FIFOs exist
environment:
- TSM_REPORT_PATH=/run/tsm/report # (+) point the app at the shim
volumes:
- tsm-report:/run/tsm/report # (+) see the shim's FIFOs
- tsm-devstub:/dev/tdx-guest # (+) make /dev/tdx-guest "exist"

volumes:
tsm-report: {}
tsm-devstub: {}
Loading
Loading