diff --git a/docs/wallet-integration/hosted/getting-started.mdx b/docs/wallet-integration/hosted/getting-started.mdx index c26e194f1..8072e6d31 100644 --- a/docs/wallet-integration/hosted/getting-started.mdx +++ b/docs/wallet-integration/hosted/getting-started.mdx @@ -3,16 +3,12 @@ title: Turnkey Hosted description: Integrate with VisualSign via Turnkey's hosted enclave service --- -{/* -TODO: Fill in when Turnkey integration details are available. -*/} - Turnkey operates VisualSign parser instances in AWS Nitro Enclaves, providing transaction parsing with attestation verification without requiring you to manage enclave infrastructure. ## Prerequisites -- Turnkey account with API credentials -- Understanding of [attestation verification](../self-hosted-tee/attestation) +- Turnkey account with API credentials. +- Understanding of [attestation verification](../self-hosted-tee/attestation). ## Integration overview @@ -31,21 +27,105 @@ sequenceDiagram W->>W: Display to user ``` -## Getting started +## Attestation verification - -Integration details coming soon. Contact Turnkey for early access. - +Every parse response carries an AWS Nitro attestation document. To trust the parsed output you must: -## Attestation verification +1. Verify the attestation's certificate chain back to the AWS Nitro root CA. +2. Check the document's PCR values against an allowlist you control. +3. Verify the parser's ECDSA signature over the parsed payload using the public key from the attestation. + +See [Attestation Verification](../self-hosted-tee/attestation) for implementation details of all three steps. The remainder of this page covers (2) — specifically, how to obtain the PCR values you should put in your allowlist. + +## PCR allowlist values + +The PCRs are determined by the qos enclave runtime version Turnkey has deployed (currently TVC `v2026.2.6`, qos commit [`d866f2c6cbc58cc08c24eab4828f0824ad16a226`](https://github.com/tkhq/qos/commit/d866f2c6cbc58cc08c24eab4828f0824ad16a226)). You can reproduce them locally from that qos rev — never trust an externally supplied PCR file without verifying it. + +### Generating PCRs from source + +The `scripts/extract-nitro-pcrs.sh` tool in this repo clones tkhq/qos at the deployment rev declared in `src/Cargo.toml`, builds `qos_enclave`, and extracts `/nitro.pcrs`: + +```bash +./scripts/extract-nitro-pcrs.sh +``` + +The deployment rev lives in a marker comment in `src/Cargo.toml`: + +```toml +# qos-deployment-rev = d866f2c6cbc58cc08c24eab4828f0824ad16a226 +``` + +This is distinct from the `rev = "..."` on each `qos_*` crate in the same file, which pins the qos library code that `parser_app` compiles against and does not affect PCRs. To audit a prospective deployment bump before updating the marker, pass `--rev` explicitly: + +```bash +./scripts/extract-nitro-pcrs.sh --rev <40-char-qos-sha> +``` + +#### Prerequisites -Even with a hosted solution, you must verify attestations. The attestation document proves: +- Docker 26+ +- Git and GNU Make +- ~8 GB of free RAM and ~15 GB of free disk (StageX base images, a clean cargo compile, and the qos OCI output). -1. The response came from a genuine AWS Nitro Enclave -2. The enclave is running the expected VisualSign parser code -3. The parsed output hasn't been tampered with +#### Options + +| Flag | Default | Purpose | +| --- | --- | --- | +| `--cargo-toml PATH` | `src/Cargo.toml` | Source of the deployment-rev marker. | +| `--qos-dir PATH` | `mktemp -d` | qos checkout location. The default is ephemeral and removed on exit; pass an explicit path to keep cached cargo and docker layers between runs. The script verifies the directory's `origin` URL matches `tkhq/qos` before mutating it. | +| `--output PATH` | `out/nitro.pcrs` | Where to write the PCRs file. | +| `--skopeo-image REF` | digest-pinned `quay.io/skopeo/stable` | Container image used to convert the OCI layout to a docker-loadable tar (no host `skopeo` install needed). | +| `--rev REV` | (from `Cargo.toml` marker) | Override the rev. | + +#### How it works + +1. **Read the rev** — the script extracts a 40-character hex from the `# qos-deployment-rev = ...` line in `src/Cargo.toml`. If `--rev` is supplied it bypasses this step. +2. **Check out qos** — clones `tkhq/qos` into the work dir and checks out the rev. If `--qos-dir` points at an existing clone whose `origin` is the upstream `tkhq/qos` URL and whose `HEAD` already matches the rev, the script reuses it; if the origin differs the script refuses to touch the directory. +3. **Build `qos_enclave`** — runs `make out/qos_enclave/index.json` inside the qos checkout. This is qos's deterministic [StageX](https://codeberg.org/stagex/stagex)-based build: it produces a `nitro.eif` and `nitro.pcrs` via `eif_build` and bakes them into the OCI image. The full pipeline lives in qos's [`qos_enclave` Containerfile](https://github.com/tkhq/qos/blob/main/src/images/qos_enclave/Containerfile). +4. **Extract `nitro.pcrs`** — runs the pinned skopeo image (as your host user, so the staging files clean up) to convert the OCI directory output to a docker-archive tar, loads it under a unique per-run tag, copies `/nitro.pcrs` out of a freshly created container, and removes both the container and the image on exit. + +#### What the PCRs measure + +PCR0 and PCR1 measure the Nitro EIF and kernel, and PCR2 measures the boot ramdisk that contains qos's `init` binary. The `parser_app` binary is loaded into qos at runtime over vsock and is **not** part of these PCR measurements — its integrity is established separately via the qos manifest, which is covered at [Level 3 attestation](../self-hosted-tee/attestation#level-3-manifest-verification). PCR3 in a live attestation measures the AWS IAM role and is intentionally absent from `nitro.pcrs`. + +### Cross-checking against a live prod attestation + +You can confirm the locally built PCRs match what the production enclave actually serves by fetching a fresh attestation through [visualsign-turnkeyclient](https://github.com/anchorageoss/visualsign-turnkeyclient). + +```bash +# 1. Build the client +git clone https://github.com/anchorageoss/visualsign-turnkeyclient.git +cd visualsign-turnkeyclient +go build -o ./bin/visualsign-turnkeyclient . + +# 2. Fetch a real prod attestation and print PCRs (any well-formed unsigned +# tx works — the parser returns the same enclave attestation regardless). +./bin/visualsign-turnkeyclient verify \ + --host https://api.turnkey.com \ + --organization-id \ + --key-name \ + --chain CHAIN_ETHEREUM \ + --unsigned-payload \ + --debug 2>&1 | grep -E '^[[:space:]]+PCR\[[012]\]' + +# 3. Compare against the script output. +./scripts/extract-nitro-pcrs.sh --output /tmp/local.pcrs +cat /tmp/local.pcrs +``` + +If both sets of PCR0/PCR1/PCR2 hex values match, the local build reproduces the deployed runtime byte for byte. + +## Reproducibility + +The qos build is deterministic. Running the script twice should produce byte-identical `nitro.pcrs` files. Pass `--qos-dir` to the second run so the cargo and docker layer caches are reused — the second build should be a no-op: + +```bash +./scripts/extract-nitro-pcrs.sh --output /tmp/pcrs.a --qos-dir /tmp/qos +./scripts/extract-nitro-pcrs.sh --output /tmp/pcrs.b --qos-dir /tmp/qos +diff /tmp/pcrs.a /tmp/pcrs.b +``` -See [Attestation Verification](../self-hosted-tee/attestation) for implementation details. +If the diff is empty, the build reproduced. ## API reference diff --git a/docs/wallet-integration/self-hosted-tee/attestation.mdx b/docs/wallet-integration/self-hosted-tee/attestation.mdx index 1c29dc29b..abbb9e593 100644 --- a/docs/wallet-integration/self-hosted-tee/attestation.mdx +++ b/docs/wallet-integration/self-hosted-tee/attestation.mdx @@ -241,13 +241,13 @@ func verifyPCRs(pcrs map[int][]byte) error { ### Updating your allowlist -When the parser is updated, PCR values change. Follow this process: +When the deployed qos enclave runtime is updated, PCR values change. Follow this process: -1. Subscribe to parser release announcements -2. Verify new PCR values against published hashes -3. Add new PCRs to your allowlist -4. Deploy to production -5. Remove old PCRs after migration completes +1. Watch for deployment announcements that change the qos runtime rev (the rev whose EIF boots in the enclave, not the qos library rev parser_app links against — see [Generating PCRs from source](../hosted/getting-started#generating-pcrs-from-source) for the distinction). +2. Reproduce the new PCR values: run `scripts/extract-nitro-pcrs.sh` with the updated deployment rev and confirm the script's output matches the values published with the release. +3. Add new PCRs to your allowlist. +4. Deploy to production. +5. Remove old PCRs after migration completes. ### Supporting multiple versions diff --git a/scripts/extract-nitro-pcrs.sh b/scripts/extract-nitro-pcrs.sh new file mode 100755 index 000000000..1884b0074 --- /dev/null +++ b/scripts/extract-nitro-pcrs.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Build the tkhq/qos enclave image at the deployment rev declared in +# src/Cargo.toml (the `# qos-deployment-rev = …` marker, not the library +# `rev = "..."` on each qos crate) and extract /nitro.pcrs from the +# resulting OCI image. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" + +CARGO_TOML="$REPO_ROOT/src/Cargo.toml" +OUTPUT="$REPO_ROOT/out/nitro.pcrs" +SKOPEO_IMAGE="quay.io/skopeo/stable@sha256:4d60d6c00b62b463d0a99a7aeedc49358a32c8222540c72d561606e188afb168" +QOS_REMOTE="https://github.com/tkhq/qos.git" +QOS_DIR="" +REV="" + +QOS_DIR_AUTO=0 +STAGE_DIR="" +CID="" +DOCKER_TAG="" + +usage() { + cat <&2 + exit 1 +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --cargo-toml) CARGO_TOML="$2"; shift 2 ;; + --qos-dir) QOS_DIR="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + --skopeo-image) SKOPEO_IMAGE="$2"; shift 2 ;; + --rev) REV="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "Unknown argument: $1" ;; + esac + done +} + +read_rev() { + [[ -f "$CARGO_TOML" ]] || die "Cargo.toml not found: $CARGO_TOML" + local marker + marker=$(grep -oE '^#[[:space:]]*qos-deployment-rev[[:space:]]*=[[:space:]]*[0-9a-f]{40}' "$CARGO_TOML" || true) + [[ -n "$marker" ]] || die "No '# qos-deployment-rev = ...' marker in $CARGO_TOML" + REV=$(echo "$marker" | grep -oE '[0-9a-f]{40}') +} + +ensure_qos_checkout() { + if [[ -z "$QOS_DIR" ]]; then + QOS_DIR="$(mktemp -d -t visualsign-qos.XXXXXX)" + QOS_DIR_AUTO=1 + fi + + if [[ -d "$QOS_DIR/.git" ]]; then + local origin + origin="$(git -C "$QOS_DIR" remote get-url origin 2>/dev/null || true)" + [[ "$origin" == "$QOS_REMOTE" ]] \ + || die "qos checkout $QOS_DIR has origin '$origin'; expected '$QOS_REMOTE'. Refusing to mutate." + local head + head="$(git -C "$QOS_DIR" rev-parse HEAD)" + if [[ "$head" == "$REV" ]]; then + echo "Reusing qos checkout at $QOS_DIR (HEAD=$head)" >&2 + return + fi + [[ -z "$(git -C "$QOS_DIR" status --porcelain)" ]] \ + || die "qos checkout $QOS_DIR has uncommitted changes" + echo "Updating qos checkout in $QOS_DIR to $REV" >&2 + git -C "$QOS_DIR" fetch --quiet origin "$REV" + git -C "$QOS_DIR" checkout --quiet "$REV" + return + fi + + echo "Cloning tkhq/qos into $QOS_DIR" >&2 + git clone --quiet "$QOS_REMOTE" "$QOS_DIR" + git -C "$QOS_DIR" checkout --quiet "$REV" +} + +build_qos_enclave() { + echo "Building qos_enclave at rev $REV (may take several minutes)..." >&2 + make -C "$QOS_DIR" out/qos_enclave/index.json +} + +extract_pcrs() { + local oci_dir="$QOS_DIR/out/qos_enclave" + [[ -f "$oci_dir/index.json" ]] || die "qos build did not produce $oci_dir/index.json" + + STAGE_DIR="$(mktemp -d -t visualsign-pcrs.XXXXXX)" + DOCKER_TAG="qos-enclave:extract-$$-${RANDOM}" + + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$oci_dir:/src:ro" \ + -v "$STAGE_DIR:/dst" \ + "$SKOPEO_IMAGE" \ + copy "oci:/src:latest" "docker-archive:/dst/qos_enclave.tar:$DOCKER_TAG" + + docker load -i "$STAGE_DIR/qos_enclave.tar" + CID="$(docker create "$DOCKER_TAG")" + + mkdir -p "$(dirname "$OUTPUT")" + docker cp "$CID:/nitro.pcrs" "$OUTPUT" + [[ -s "$OUTPUT" ]] || die "nitro.pcrs not found in qos-enclave image" +} + +cleanup() { + if [[ -n "$CID" ]]; then + docker rm "$CID" >/dev/null 2>&1 || true + fi + if [[ -n "$DOCKER_TAG" ]]; then + docker rmi "$DOCKER_TAG" >/dev/null 2>&1 || true + fi + if [[ -n "$STAGE_DIR" ]]; then + rm -rf "$STAGE_DIR" + fi + if [[ "$QOS_DIR_AUTO" -eq 1 && -n "$QOS_DIR" ]]; then + rm -rf "$QOS_DIR" + fi +} + +main() { + parse_args "$@" + if [[ -z "$REV" ]]; then + read_rev + fi + trap cleanup EXIT + ensure_qos_checkout + build_qos_enclave + extract_pcrs + echo "Wrote $OUTPUT:" >&2 + cat "$OUTPUT" + echo +} + +main "$@" diff --git a/src/Cargo.toml b/src/Cargo.toml index d0d6ff8c9..d2c433263 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -40,6 +40,18 @@ repository = "https://github.com/anchorageoss/visualsign-parser" [workspace.dependencies] # See https://doc.rust-lang.org/nightly/cargo/reference/workspaces.html#the-dependencies-table + +# qos dependencies. Two distinct revs matter: +# * Library rev (the `rev = "..."` on each crate below) — compiled into parser_app +# so it links against qos_core etc. +# * Deployment rev — the qos version whose EIF actually boots in the Nitro enclave +# and is measured by PCRs in attestation documents. They are not required to +# match. The deployment rev currently tracks TVC v2026.2.6: +# https://github.com/tkhq/qos/commit/d866f2c6cbc58cc08c24eab4828f0824ad16a226 +# PCRs from upstream CI: +# https://github.com/tkhq/qos/actions/runs/22202233633/job/64217796901 +# scripts/extract-nitro-pcrs.sh reads the marker line below to reproduce them. +# qos-deployment-rev = d866f2c6cbc58cc08c24eab4828f0824ad16a226 qos_client = { git = "https://github.com/tkhq/qos.git", rev = "365ba7ed529bc5af617bcfb27502c3efce8b37ae", default-features = false } qos_core = { git = "https://github.com/tkhq/qos.git", rev = "365ba7ed529bc5af617bcfb27502c3efce8b37ae", default-features = false } qos_crypto = { git = "https://github.com/tkhq/qos.git", rev = "365ba7ed529bc5af617bcfb27502c3efce8b37ae", default-features = false }