From 4961be884a1f4c3200d1d715e043a7a0d35ddc81 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 15 Oct 2026 09:43:40 +0300 Subject: [PATCH 1/2] test: serve the released cosigner, in process and under QEMU The cosigner is the one guest this runtime is built to serve, and it lives in its own repository now, released as cosigner.wasm. cosigner.lock pins a release by name and sha256 and scripts/cosigner.sh fetches it, refusing any other bytes; COSIGNER_WASM serves a local build instead, for a change that needs both sides at once. serve_cosigner loads it into the exported testing harness: every import it makes links, it opens its tenant's store, and GetServerInfo answers through the gate. A guest that is not the cosigner fails it on the route. ci-e2e.sh then boots it under QEMU. run-e2e.sh takes GUEST_AGNOSTIC=1 to run only the legs that hold for any guest (the gate, the attestation and both PCRs, a restart, a substitution) and skip the ones that drive guest-http's routes. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- cosigner.lock | 4 +++ deploy/qemu-nitro/run-e2e.sh | 27 +++++++++++++++++ runtime/tests/serve_cosigner.rs | 53 +++++++++++++++++++++++++++++++++ scripts/ci-e2e.sh | 9 +++++- scripts/ci-guests.sh | 6 ++++ scripts/cosigner.sh | 39 ++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 cosigner.lock create mode 100644 runtime/tests/serve_cosigner.rs create mode 100755 scripts/cosigner.sh diff --git a/README.md b/README.md index 1863150..a34c650 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,8 @@ cargo build --release -p enclave-runtime --features testing --bin passkey-client | `scripts/ci-check.sh` | Formatting, Clippy with warnings denied, workspace library tests, boot-origin tests | | `scripts/ci-guests.sh` | Builds the guests; tests tasks, held connections, dispatch, TLS binding, HTTP/2, gRPC, authentication and logging | | `scripts/ci-storage.sh` | Real MinIO/S3: retention, remount, corruption, rollback and competing claims (needs Docker) | -| `scripts/ci-e2e.sh` | The stages above, then the full QEMU stack (needs KVM, Docker and Nix) | +| `scripts/ci-e2e.sh` | The stages above, then the full QEMU stack (needs KVM, Docker and Nix), once with the example guest and once with the released [cosigner](https://github.com/vtxos/cosigner) pinned in [`cosigner.lock`](cosigner.lock) | +| `scripts/cosigner.sh` | Fetches that cosigner into `target/cosigner/`; `COSIGNER_WASM=path` uses a local build instead | | `scripts/wit-drift.sh` | Vendored guest WIT matches [`wit/`](wit/) | | `scripts/ci-bench.sh` | Criterion storage and instance-cost benchmarks | diff --git a/cosigner.lock b/cosigner.lock new file mode 100644 index 0000000..50bede1 --- /dev/null +++ b/cosigner.lock @@ -0,0 +1,4 @@ +# The cosigner this runtime's CI serves: a release of vtxos/cosigner, by name and the sha256 of its +# cosigner.wasm. scripts/cosigner.sh fetches it and refuses any other bytes. Shell syntax. +RELEASE=v0.1.0 +SHA256=ba4976e0b547e9e342fca273e1cd78d2426d0df55e2ea5d9761b6428b86889cf diff --git a/deploy/qemu-nitro/run-e2e.sh b/deploy/qemu-nitro/run-e2e.sh index dad2751..4853f63 100755 --- a/deploy/qemu-nitro/run-e2e.sh +++ b/deploy/qemu-nitro/run-e2e.sh @@ -59,6 +59,10 @@ PREFIX=e2e # A second guest, altered, for leg 8. Nothing else here needs one, so lib.sh # does not stage it unless asked. WITH_SUBSTITUTE=1 +# Any guest, not the example one (with GUEST_WASM): only the legs that hold for +# every guest run — the gate, the attestation, the PCRs, a restart and a +# substitution. The rest drive guest-http's own routes. +GUEST_AGNOSTIC="${GUEST_AGNOSTIC:-}" # shellcheck source=lib.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" @@ -91,6 +95,7 @@ code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 20 \ || fail "a request with no nonce answered $code; it should be refused before routing" echo "un-nonced requests refused: 400" +if [[ -z "$GUEST_AGNOSTIC" ]]; then # guest-http's routes, to the end of 2b say "2/8 a passkey enrols and its signed requests reach the guest" rm -f "$RUNDIR/alice.json" "$RUNDIR/bob.json" signed enrol >/dev/null || fail "registration failed" @@ -134,6 +139,7 @@ for i in $(seq 1 "$FILES"); do || fail "/files/work-$i.txt kept a stale value \"$got\" after being overwritten" done echo "$FILES files written, read, overwritten and re-read through the gate" +fi say "3/8 the attestation binds this connection's certificate, runtime and guest" # There is no attestation endpoint: the document rides on the `/auth/` exchange, @@ -151,6 +157,7 @@ say "3/8 the attestation binds this connection's certificate, runtime and guest grep -q "binding the attested certificate" "$RUNDIR/attest.log" \ || fail "the document did not bind the certificate this connection was served" +if [[ -z "$GUEST_AGNOSTIC" ]]; then # signs for guest-http's /counter say "3b/8 a signed interaction verifies the enclave before it trusts it" # The proof comes from the `/auth/` exchange, not from the guest's response: # that exchange is where a client identifies the enclave, before it hands over @@ -179,6 +186,7 @@ signed --dump-proof "$RUNDIR/proof" get --path /counter >/dev/null \ grep -q "binding the attested certificate" "$RUNDIR/attest-guest.log" \ || fail "the document did not bind that connection's certificate" +fi say "4/8 the attested PCR0 and PCR16 are the ones the builds produced" # `nitro-attest --pcr0 --guest` already enforced both, so reaching here means @@ -192,6 +200,7 @@ echo " attested: $(grep -oE '^PCR16 +[0-9a-f]+' "$RUNDIR/attest.log" | awk # --------------------------------------------------------------------------- # 5/8 — one instance per request, and one approval per interaction. # --------------------------------------------------------------------------- +if [[ -z "$GUEST_AGNOSTIC" ]]; then # guest-http's routes, to the end of 6b say "5/8 every request gets a fresh instance, and no two tenants share one" # `/memory` counts in the guest's linear memory and writes nowhere. What it @@ -409,6 +418,7 @@ echo "$wake" | jq -e '.message.notification == null' >/dev/null \ keys="$(echo "$wake" | jq -r '.message.data | keys | join(",")')" [[ "$keys" == "category,ref,v" ]] || fail "the wake carried more than its labels: $keys" echo "a data-only wake reached the enrolled device: category=task-done ref=e2e-job" +fi # --------------------------------------------------------------------------- # 7/8 — the boot machine, across a restart. @@ -515,6 +525,23 @@ echo "it attests the substitute it is running: PCR16 $SUBSTITUTE_PCR16" docker rm -f "$PREFIX-qemu-substitute" >/dev/null 2>&1 || true +if [[ -n "$GUEST_AGNOSTIC" ]]; then + cat < Vec { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../target/cosigner/cosigner.wasm"); + std::fs::read(path).expect("fetch the released cosigner first: scripts/cosigner.sh") +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the released cosigner: scripts/cosigner.sh"] +async fn the_released_cosigner_links_and_answers_through_the_gate() { + // Tasks and notify are what its world imports beyond WASI and telemetry, + // which is always linked; a runtime that stopped providing any of them + // fails here, at instantiation. + let enclave = Enclave::builder(cosigner()) + .background_tasks() + .notify() + .start() + .await + .unwrap(); + let alice = enclave.enrol().await.unwrap(); + + // GetServerInfo, unary: one empty message as a gRPC frame, uncompressed + // and zero bytes long. The answer is a frame carrying the network the + // cosigner was configured for, regtest when nothing says otherwise. + let (status, body) = enclave + .signed( + &alice, + "POST", + "/cosigner.v1.Cosigner/GetServerInfo", + "\0\0\0\0\0", + ) + .await + .unwrap(); + assert_eq!(status, 200, "{body:?}"); + assert!( + body.contains("regtest"), + "the cosigner did not answer with its network: {body:?}" + ); +} diff --git a/scripts/ci-e2e.sh b/scripts/ci-e2e.sh index 464f8a1..d8b4720 100755 --- a/scripts/ci-e2e.sh +++ b/scripts/ci-e2e.sh @@ -30,4 +30,11 @@ cd "$REPO" # KVM, vsock, the QEMU image and vhost-device-vsock — see scripts/lib.sh. prepare_enclave_host -exec deploy/qemu-nitro/run-e2e.sh +deploy/qemu-nitro/run-e2e.sh + +# The same stack again, serving the cosigner as released (cosigner.lock): only +# the legs that hold for any guest, since the rest drive the example's routes. +# The image is built by now, so this costs its boots. +"$REPO/scripts/cosigner.sh" +GUEST_WASM="$REPO/target/cosigner/cosigner.wasm" GUEST_AGNOSTIC=1 \ + exec deploy/qemu-nitro/run-e2e.sh diff --git a/scripts/ci-guests.sh b/scripts/ci-guests.sh index 4dee6f4..5532580 100755 --- a/scripts/ci-guests.sh +++ b/scripts/ci-guests.sh @@ -71,6 +71,12 @@ cargo test -p enclave-runtime --test serve_auth -- --include-ignored say "guest logging" cargo test -p enclave-runtime --test guest_logs -- --include-ignored +# The one guest this runtime is built to serve, as its own repository released +# it (cosigner.lock): every import it makes still links, and it answers. +say "the released cosigner" +"$REPO/scripts/cosigner.sh" +cargo test -p enclave-runtime --test serve_cosigner -- --include-ignored + # The verifier's own tests build the binary, so there is no separate build step # here — nothing in CI executes the artifact itself. say "verifier measurement requirements" diff --git a/scripts/cosigner.sh b/scripts/cosigner.sh new file mode 100755 index 0000000..53f7b80 --- /dev/null +++ b/scripts/cosigner.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Put the released cosigner at target/cosigner/cosigner.wasm. +# +# The one guest this runtime is built to serve, as its own repository released +# it: the release and sha256 are pinned in cosigner.lock. CI serves it in +# process (runtime/tests/serve_cosigner.rs) and boots it under QEMU (run-e2e.sh +# with GUEST_AGNOSTIC=1), so a runtime change that breaks it fails here rather +# than after its release. +# +# COSIGNER_WASM names a local build to use instead, for a change that needs +# both sides at once. Idempotent otherwise: a file that already matches the pin +# is kept. + +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=../cosigner.lock +. "$REPO/cosigner.lock" + +out="$REPO/target/cosigner/cosigner.wasm" +mkdir -p "$(dirname "$out")" + +if [[ -n "${COSIGNER_WASM:-}" ]]; then + install -m 0644 "$COSIGNER_WASM" "$out" + echo "cosigner from $COSIGNER_WASM, not $RELEASE" + exit 0 +fi + +if echo "$SHA256 $out" | sha256sum -c --status - 2>/dev/null; then + echo "cosigner $RELEASE already at $out" + exit 0 +fi + +say "fetching cosigner $RELEASE" +url="https://github.com/vtxos/cosigner/releases/download/$RELEASE/cosigner.wasm" +curl -fL --retry 3 --retry-all-errors --max-time 300 -o "$out.part" "$url" \ + || fail "could not download $url" +echo "$SHA256 $out.part" | sha256sum -c - \ + || fail "cosigner $RELEASE does not match cosigner.lock" +mv "$out.part" "$out" +echo "cosigner $RELEASE at $out" From e7c20bca3459ea45de42243210676b9a3044025a Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 15 Oct 2026 10:58:52 +0300 Subject: [PATCH 2/2] test: use the cosigner in the e2e and nowhere else The e2e is the one place the cosigner needs to run: ci-e2e.sh boots the release cosigner.lock pins under QEMU. The in-process suite was a second check of the same thing, so it goes, and cosigner.sh only fetches and verifies; a local build goes straight to run-e2e.sh as GUEST_WASM. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 - runtime/tests/serve_cosigner.rs | 53 --------------------------------- scripts/ci-guests.sh | 6 ---- scripts/cosigner.sh | 23 ++------------ 4 files changed, 3 insertions(+), 80 deletions(-) delete mode 100644 runtime/tests/serve_cosigner.rs diff --git a/README.md b/README.md index a34c650..0d59d25 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,6 @@ cargo build --release -p enclave-runtime --features testing --bin passkey-client | `scripts/ci-guests.sh` | Builds the guests; tests tasks, held connections, dispatch, TLS binding, HTTP/2, gRPC, authentication and logging | | `scripts/ci-storage.sh` | Real MinIO/S3: retention, remount, corruption, rollback and competing claims (needs Docker) | | `scripts/ci-e2e.sh` | The stages above, then the full QEMU stack (needs KVM, Docker and Nix), once with the example guest and once with the released [cosigner](https://github.com/vtxos/cosigner) pinned in [`cosigner.lock`](cosigner.lock) | -| `scripts/cosigner.sh` | Fetches that cosigner into `target/cosigner/`; `COSIGNER_WASM=path` uses a local build instead | | `scripts/wit-drift.sh` | Vendored guest WIT matches [`wit/`](wit/) | | `scripts/ci-bench.sh` | Criterion storage and instance-cost benchmarks | diff --git a/runtime/tests/serve_cosigner.rs b/runtime/tests/serve_cosigner.rs deleted file mode 100644 index 3322ba8..0000000 --- a/runtime/tests/serve_cosigner.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! The cosigner, as released, served by this runtime. -//! -//! The one guest this runtime is built to serve lives in its own repository and -//! is pinned here by release (`cosigner.lock`). This is the check that a runtime -//! change still serves it: every import it makes resolves, it instantiates, -//! opens its tenant's store, and answers a gRPC call through the gate. What its -//! ceremonies do is the cosigner's own suites' to prove. -//! -//! ```console -//! $ scripts/cosigner.sh -//! $ cargo test -p enclave-runtime --test serve_cosigner -- --include-ignored -//! ``` - -use enclave_runtime::testing::Enclave; - -fn cosigner() -> Vec { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../target/cosigner/cosigner.wasm"); - std::fs::read(path).expect("fetch the released cosigner first: scripts/cosigner.sh") -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "needs the released cosigner: scripts/cosigner.sh"] -async fn the_released_cosigner_links_and_answers_through_the_gate() { - // Tasks and notify are what its world imports beyond WASI and telemetry, - // which is always linked; a runtime that stopped providing any of them - // fails here, at instantiation. - let enclave = Enclave::builder(cosigner()) - .background_tasks() - .notify() - .start() - .await - .unwrap(); - let alice = enclave.enrol().await.unwrap(); - - // GetServerInfo, unary: one empty message as a gRPC frame, uncompressed - // and zero bytes long. The answer is a frame carrying the network the - // cosigner was configured for, regtest when nothing says otherwise. - let (status, body) = enclave - .signed( - &alice, - "POST", - "/cosigner.v1.Cosigner/GetServerInfo", - "\0\0\0\0\0", - ) - .await - .unwrap(); - assert_eq!(status, 200, "{body:?}"); - assert!( - body.contains("regtest"), - "the cosigner did not answer with its network: {body:?}" - ); -} diff --git a/scripts/ci-guests.sh b/scripts/ci-guests.sh index 5532580..4dee6f4 100755 --- a/scripts/ci-guests.sh +++ b/scripts/ci-guests.sh @@ -71,12 +71,6 @@ cargo test -p enclave-runtime --test serve_auth -- --include-ignored say "guest logging" cargo test -p enclave-runtime --test guest_logs -- --include-ignored -# The one guest this runtime is built to serve, as its own repository released -# it (cosigner.lock): every import it makes still links, and it answers. -say "the released cosigner" -"$REPO/scripts/cosigner.sh" -cargo test -p enclave-runtime --test serve_cosigner -- --include-ignored - # The verifier's own tests build the binary, so there is no separate build step # here — nothing in CI executes the artifact itself. say "verifier measurement requirements" diff --git a/scripts/cosigner.sh b/scripts/cosigner.sh index 53f7b80..b9ce277 100755 --- a/scripts/cosigner.sh +++ b/scripts/cosigner.sh @@ -1,39 +1,22 @@ #!/usr/bin/env bash -# Put the released cosigner at target/cosigner/cosigner.wasm. -# -# The one guest this runtime is built to serve, as its own repository released -# it: the release and sha256 are pinned in cosigner.lock. CI serves it in -# process (runtime/tests/serve_cosigner.rs) and boots it under QEMU (run-e2e.sh -# with GUEST_AGNOSTIC=1), so a runtime change that breaks it fails here rather -# than after its release. -# -# COSIGNER_WASM names a local build to use instead, for a change that needs -# both sides at once. Idempotent otherwise: a file that already matches the pin -# is kept. +# Put the cosigner release cosigner.lock pins at target/cosigner/cosigner.wasm, +# refusing any other bytes. ci-e2e.sh boots it as the e2e's second guest. . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" # shellcheck source=../cosigner.lock . "$REPO/cosigner.lock" out="$REPO/target/cosigner/cosigner.wasm" -mkdir -p "$(dirname "$out")" - -if [[ -n "${COSIGNER_WASM:-}" ]]; then - install -m 0644 "$COSIGNER_WASM" "$out" - echo "cosigner from $COSIGNER_WASM, not $RELEASE" - exit 0 -fi - if echo "$SHA256 $out" | sha256sum -c --status - 2>/dev/null; then echo "cosigner $RELEASE already at $out" exit 0 fi say "fetching cosigner $RELEASE" +mkdir -p "$(dirname "$out")" url="https://github.com/vtxos/cosigner/releases/download/$RELEASE/cosigner.wasm" curl -fL --retry 3 --retry-all-errors --max-time 300 -o "$out.part" "$url" \ || fail "could not download $url" echo "$SHA256 $out.part" | sha256sum -c - \ || fail "cosigner $RELEASE does not match cosigner.lock" mv "$out.part" "$out" -echo "cosigner $RELEASE at $out"