diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..a534eb0 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,42 @@ +# Per-repo Cargo config for vaportpm. vaportpm is a GNU-HOST repo: its default builds/tests and the +# attest/verify CLIs are host (x86_64-unknown-linux-gnu) executables. Host linking therefore uses the +# image's default `cc` (gcc, present in lockboot:build) -- we set NO host linker override, because +# rust-lld cannot link glibc executables (rustc does not hand it the system library search paths, only +# the self-contained crt for musl). Build scripts run at build time and don't affect artifact bytes, +# so linking them with gcc is fine for reproducibility. Only the musl release ARTIFACTS use rust-lld + +# bundled musl (fully static, reproducible, cc-free) -- see the sections below. This is the "gnu-host +# repos link C and need cc" case that stage0/stage1's config comments call out. +# +# Kept in the repo (not only the shared workspace config) because CI checks out each repo ALONE, and +# Cargo CONCATENATES rustflags across config files -- the shared /src/.cargo/config.toml sets no +# rustflags, so nothing doubles. No `[build] target = musl`: that would force musl onto the host tools +# and the test suite. Musl is opt-in via `--target` for `make release-x86` / `release-aarch64` below. + +[target.x86_64-unknown-linux-musl] +# Fully static musl release artifacts. rust-lld + bundled musl, no system cc. +rustflags = [ + "-C", "linker=rust-lld", + "-C", "target-feature=+crt-static", + "-C", "link-arg=-static", + # Remap embedded dep source paths to a fixed prefix so builds don't depend on CARGO_HOME + # (CI uses /tmp/.cargo, local uses /src/.cargo); keeps CI and local byte-identical. + "--remap-path-prefix=/src/.cargo=/cargo", + "--remap-path-prefix=/tmp/.cargo=/cargo", + # Remap the rust-src sysroot to the baked /rustc/ so std panic-location paths match CI. + # UPDATE THE HASH on a toolchain bump (rustc -Vv commit-hash); a stale value silently no-ops. + "--remap-path-prefix=/src/.rustup/toolchains/1.91.1-x86_64-unknown-linux-gnu/lib/rustlib/src/rust=/rustc/ed61e7d7e242494fb7057f2657300d9e77bb4fcb", +] + +[target.aarch64-unknown-linux-musl] +rustflags = [ + "-C", "linker=rust-lld", + "-C", "target-feature=+crt-static", + "-C", "link-arg=-static", + "--remap-path-prefix=/src/.cargo=/cargo", + "--remap-path-prefix=/tmp/.cargo=/cargo", + "--remap-path-prefix=/src/.rustup/toolchains/1.91.1-x86_64-unknown-linux-gnu/lib/rustlib/src/rust=/rustc/ed61e7d7e242494fb7057f2657300d9e77bb4fcb", +] + +[env] +# Reproducible builds when this repo is built standalone (no workspace parent in CI). +SOURCE_DATE_EPOCH = "0" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index c9bbb2e..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "Rust Dev", - "image": "mcr.microsoft.com/devcontainers/rust:latest", - "mounts": [ - "source=${localWorkspaceFolder},target=/src,type=bind", - "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" - ], - "workspaceFolder": "/src", - "remoteEnv": { - "HOME": "/src" - }, - "runArgs": [ - "--network=host", - "--user", "1000:1000", - "--group-add", "134", - "--privileged" - ], - "customizations": { - "vscode": { - "extensions": [ - "rust-lang.rust-analyzer" - ], - "settings": { - "telemetry.telemetryLevel": "off" - } - } - }, - "remoteUser": "1000" -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a7683a..617bda8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,19 +5,21 @@ on: branches: [main] pull_request: branches: [main] - -env: - CARGO_TERM_COLOR: always + workflow_dispatch: jobs: ci: - name: CI runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - run: make ci - - run: make build + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + # Everything runs inside lockboot:build (built on demand by the Makefile's + # docker-build-base prerequisite) on the pinned 1.91.1 toolchain -- no host + # Rust toolchain is installed here. GitHub sets CI=true, so the Makefile's + # CACHE_ENV redirects cargo/rustup homes to /tmp inside the container. + - name: CI (fmt-check + check + clippy + test + doc) + run: make ci + + - name: Release build + run: make build diff --git a/Cargo.toml b/Cargo.toml index 692d4e3..840c646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ hex = { version = "0.4", default-features = false, features = ["alloc"] } # X.509 and crypto for verification der = { version = "0.7", default-features = false, features = ["alloc", "pem", "oid"] } spki = { version = "0.7", features = ["alloc"] } +# Keep no_std-base at the workspace level (attest's UEFI build). `builder`/`hazmat` are needed +# only by vaportpm-verify's tests, so they are opted in there (a std crate), not here. x509-cert = { version = "0.2", default-features = false, features = ["pem"] } p256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } p384 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..f9df7d5 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,48 @@ +FROM rust:1.91-slim-bookworm + +# Add musl targets for both architectures +RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl + +# Install tools needed for UKI building +RUN apt-get -qq update && \ + apt-get install -y --no-install-recommends \ + binutils-aarch64-linux-gnu \ + binutils-x86-64-linux-gnu \ + cpio \ + curl \ + dosfstools \ + efitools \ + fdisk \ + findutils \ + gdisk \ + gnupg \ + gzip \ + kmod \ + make \ + openssl \ + ovmf \ + python3 \ + python3-pip \ + qemu-efi-aarch64 \ + rpm \ + rpm2cpio \ + sbsigntool \ + util-linux \ + uuid-runtime \ + wget \ + xz-utils && \ + pip3 install --break-system-packages virt-firmware + +# Reproducible builds environment +ENV SOURCE_DATE_EPOCH=0 +ENV CARGO_INCREMENTAL=0 +# rustflags are per-target in .cargo/config.toml; a global RUSTFLAGS would force +crt-static onto +# the host and break proc-macros. + +# User environment (project-specific cargo/rustup data) +ENV HOME=/src +ENV CARGO_HOME=/src/.cargo +ENV RUSTUP_HOME=/src/.rustup +ENV HISTFILE=/dev/null + +WORKDIR /src diff --git a/Makefile b/Makefile index 47d829a..3b5ec1a 100644 --- a/Makefile +++ b/Makefile @@ -1,49 +1,56 @@ -.PHONY: all build release-x86 check test fmt fmt-check clippy doc clean ci coverage setup-coverage +.PHONY: all build release-x86 release-aarch64 check test fmt fmt-check clippy doc clean ci \ + setup-coverage coverage-html coverage-text + +# Shared build harness (docker images + DOCKER_RUN plumbing). Vendored byte-identically from +# stage0/build.mk (the canonical source) via the workspace `make sync-harness`; do not hand-edit. +# `make check-harness` guards against drift. Every cargo recipe runs inside lockboot:build so the +# build is cc-free-by-design and reproducible (rust-lld + the shared /src/.cargo, /src/.rustup). +include build.mk + +# vaportpm is a gnu-host repo: the std tooling (attest/verify CLIs) and the test suite build for the +# host target inside the image (glibc is present there); musl is opt-in via the release-* targets. +CARGO = $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo all: build -build: - cargo build --workspace --release +build: docker-build-base + $(CARGO) build --workspace --release -release-x86: - cargo build --workspace --release --target x86_64-unknown-linux-musl +release-x86: docker-build-base + $(CARGO) build --workspace --release --target x86_64-unknown-linux-musl -release-aarch64: - cargo build --workspace --release --target aarch64-unknown-linux-musl +release-aarch64: docker-build-base + $(CARGO) build --workspace --release --target aarch64-unknown-linux-musl -check: - cargo check --workspace --all-targets +check: docker-build-base + $(CARGO) check --workspace --all-targets -test: - cargo test --workspace +test: docker-build-base + $(CARGO) test --workspace -fmt: - cargo fmt --all +fmt: docker-build-base + $(CARGO) fmt --all -fmt-check: - cargo fmt --all -- --check +fmt-check: docker-build-base + $(CARGO) fmt --all -- --check -clippy: - cargo clippy --workspace --all-targets -- -D warnings +clippy: docker-build-base + $(CARGO) clippy --workspace --all-targets -- -D warnings -doc: - RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +doc: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) -e RUSTDOCFLAGS="-D warnings" $(BUILD_IMAGE) cargo doc --workspace --no-deps -clean: - cargo clean +clean: docker-build-base + $(CARGO) clean ci: fmt-check check clippy test doc -setup-coverage: - rustup component add llvm-tools-preview - cargo install cargo-llvm-cov +setup-coverage: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "rustup component add llvm-tools-preview && cargo install cargo-llvm-cov" -coverage-html: - cargo llvm-cov -p vaportpm-verify --html +coverage-html: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo llvm-cov -p vaportpm-verify --html @echo "Coverage report: target/llvm-cov/html/index.html" -coverage-text: - cargo llvm-cov -p vaportpm-verify - -rustup: - rustup default stable \ No newline at end of file +coverage-text: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo llvm-cov -p vaportpm-verify diff --git a/build.mk b/build.mk new file mode 100644 index 0000000..38a24e7 --- /dev/null +++ b/build.mk @@ -0,0 +1,77 @@ +# ---- Lock.Boot shared build harness ---------------------------------------------------------- +# Runs every cargo/tool invocation inside the locally-built lockboot:build image, so builds are +# cc-free-by-design and byte-reproducible (rust-lld + musl, shared /src/.cargo + /src/.rustup). +# +# CANONICAL SOURCE: stage0/build.mk. This file is vendored byte-identically into each participating +# repo (stage1, vaportpm, ...) because CI checks out each repo ALONE (no workspace parent), so a +# shared harness cannot be a cross-repo include -- it must live in the repo. Do NOT hand-edit the +# copies: edit stage0/build.mk, then run `make sync-harness` from the workspace ($(CANON)=stage0), +# guarded by `make check-harness`. +# +# Each repo's Makefile does `include build.mk` and defines its own targets, invoking cargo as +# $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo ... +# with a `docker-build-base` prerequisite so the image is built on demand (incl. standalone CI). + +# ---- Docker images (shared lockboot family; built locally, never published) ---- +BUILD_IMAGE = lockboot:build +HARNESS_IMAGE = lockboot:harness + +.PHONY: docker-build-base +docker-build-base: + docker build -f Dockerfile.build -t $(BUILD_IMAGE) . + +# ---- Docker run plumbing (keep identical across repos) ---- +# Own build artifacts by whoever owns the checkout, not the caller's euid. Under +# `gh act` the caller is root but the bind-mounted tree is still yours, so stat +# keeps output user-owned instead of trampling the project dir with root files. +# On a normal host/devcontainer run this equals `id -u`/`id -g`, so nothing changes. +USER_ID := $(shell stat -c %u .) +GROUP_ID := $(shell stat -c %g .) + +KVM_GID := $(shell stat -c %g /dev/kvm 2>/dev/null || echo "") +KVM_MOUNT := $(shell test -e /dev/kvm && echo "-v /dev/kvm:/dev/kvm") +DOCKER_OPT_KVM := $(if $(KVM_GID),--group-add $(KVM_GID)) $(KVM_MOUNT) + +# Recursive-docker passthrough: rules that shell out to the HOST docker daemon (e.g. stage1's UKI +# rootfs extraction / runtime-image buildx) forward the socket + its gid. Defined here for every +# repo; harmless (expands empty) when a repo has no such rule. +DOCKER_SOCK_GID := $(shell stat -c %g /var/run/docker.sock 2>/dev/null || echo "") +DOCKER_SOCK_MOUNT := $(shell test -e /var/run/docker.sock && echo "-v /var/run/docker.sock:/var/run/docker.sock") +DOCKER_OPT_DOCKER := $(DOCKER_SOCK_MOUNT) $(if $(DOCKER_SOCK_GID),--group-add $(DOCKER_SOCK_GID)) + +DOCKER_SAMEUSER := -u $(USER_ID):$(GROUP_ID) + +# Host-path translation for docker-in-devcontainer. Inside the devcontainer /src is +# a host bind mount and the inner Docker talks to the HOST daemon, which cannot +# resolve /src/... paths; translate $(CURDIR) to the real host path (the bracketed +# subpath findmnt reports for the /src bind). On the host CURDIR is not under /src, +# so this is a pass-through and your workflow is unchanged. Keep identical across repos. +HOST_DIR := $(CURDIR) +ifneq ($(filter /src/%,$(CURDIR)),) + SRC_BIND := $(shell findmnt -fnro SOURCE --target /src 2>/dev/null | sed -n 's/.*\[\(.*\)\]$$/\1/p') + ifneq ($(SRC_BIND),) + HOST_DIR := $(SRC_BIND)$(CURDIR:/src%=%) + endif +endif + +# Mount the WORKSPACE (parent of this repo) at /src so builds reuse the shared +# workspace-level .cargo/.rustup (matching the devcontainer), instead of creating +# per-repo copies. The repo then lives at /src/$(REPO_NAME). +REPO_NAME := $(notdir $(HOST_DIR)) +HOST_WS := $(patsubst %/,%,$(dir $(HOST_DIR))) + +# Under CI / `gh act` (CI=true, runs as root) keep cargo/rustup caches ephemeral +# inside the container, so root-owned dirs never land in the bind-mounted project. +# Locally (no CI) the image's CARGO_HOME=/src/.cargo + RUSTUP_HOME=/src/.rustup win, +# i.e. the shared workspace caches. +CACHE_ENV := $(if $(CI),-e CARGO_HOME=/tmp/.cargo -e RUSTUP_HOME=/tmp/.rustup) + +DOCKER_RUN = docker run --rm \ + --privileged \ + -v $(HOST_WS):/src \ + -h lockboot \ + --add-host lockboot:127.0.0.1 \ + -e OWNER_UID=$(USER_ID) \ + -e OWNER_GID=$(GROUP_ID) \ + $(CACHE_ENV) \ + -w /src/$(REPO_NAME) diff --git a/crates/vaportpm-attest/Cargo.toml b/crates/vaportpm-attest/Cargo.toml index 434a57e..72096ca 100644 --- a/crates/vaportpm-attest/Cargo.toml +++ b/crates/vaportpm-attest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vaportpm-attest" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "Cloud vTPM attestation - minimal TPM 2.0 implementation without C dependencies" license.workspace = true diff --git a/crates/vaportpm-attest/src/derive.rs b/crates/vaportpm-attest/src/derive.rs new file mode 100644 index 0000000..c63bc49 --- /dev/null +++ b/crates/vaportpm-attest/src/derive.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! PCR-gated key derivation. +//! +//! Derive arbitrary-length key material that is bound to the current PCR state and to +//! this TPM, namespaced by a caller-chosen label. Same TPM at the same PCR state +//! reproduces the identical bytes on every boot; any other state cannot obtain them. +//! +//! The binding is enforced by the TPM, not by the caller. A keyedhash HMAC key is +//! created in the owner hierarchy with `userWithAuth` cleared and an `authPolicy` equal +//! to a `TPM2_PolicyPCR` digest over the full PCR set; using the key (HMAC) therefore +//! requires a policy session in which the TPM itself reads the *live* PCRs. A caller in +//! a different state cannot satisfy the policy (the TPM measures reality, not a supplied +//! value), so it cannot exercise the key — reconstructing the key material via a forged +//! template does not help, because the policy check happens at use time. +//! +//! The gated key is a KDF root: `HMAC(key, label || counter)` yields the requested +//! bytes, and `label` namespaces independent derivations from the one PCR-gated root. + +use alloc::vec::Vec; +use anyhow::{bail, Result}; + +use crate::pcr::pcr_selection; +use crate::session::SessionOps; +use crate::{ + CommandBuffer, ObjectAttributes, Tpm, TpmAlg, TpmCc, TpmSt, TPM_RH_OWNER, TPM_SE_POLICY, + TPM_SE_TRIAL, +}; + +/// TPM_ALG_KEYEDHASH / TPM_ALG_HMAC — not in the [`TpmAlg`] enum (asym/sym algs only). +const TPM_ALG_KEYEDHASH: u16 = 0x0008; +const TPM_ALG_HMAC: u16 = 0x0005; + +/// PCR-gated key derivation extension trait. +pub trait DeriveOps { + /// Derive `len` bytes bound to the SHA-256 values of the PCRs named in `pcr_indices` + /// and to this TPM, namespaced by `label`. Deterministic across reboots while those + /// PCRs and the TPM seed are unchanged; unobtainable in any other state. The caller + /// chooses which PCRs identify "the same platform" — e.g. code/policy PCRs while + /// excluding volatile ones like PCR 5 (GPT). `label` separates independent + /// derivations from the same gated root. + fn derive_pcr_bound(&mut self, pcr_indices: &[u8], label: &[u8], len: usize) + -> Result>; +} + +impl DeriveOps for Tpm { + fn derive_pcr_bound( + &mut self, + pcr_indices: &[u8], + label: &[u8], + len: usize, + ) -> Result> { + let pcrs = pcr_selection(pcr_indices, TpmAlg::Sha256); + let auth_policy = self.pcr_policy_digest(&pcrs)?; + let template = build_keyedhash_template(&auth_policy); + let key = self.create_keyedhash_primary(TPM_RH_OWNER, &template)?; + + // Flush the transient key even if a derivation step fails. + let result = self.hmac_kdf(key, &pcrs, label, len); + let _ = self.flush_context(key); + result + } +} + +impl Tpm { + /// `HMAC(key, label || counter)` stretched to `len` bytes, over a single policy + /// session. Each authorized HMAC consumes the session's policy digest, so + /// `PolicyPCR` is re-satisfied before every block (the TPM re-reads the live PCRs); + /// the session itself is created once and flushed at the end. + fn hmac_kdf(&mut self, key: u32, pcrs: &[u8], label: &[u8], len: usize) -> Result> { + let session = self.start_auth_session(TPM_SE_POLICY)?; + let result = (|| { + let mut out = Vec::with_capacity(len); + let mut counter: u32 = 0; + while out.len() < len { + self.policy_pcr(session, pcrs)?; // (re)satisfy before each authorized use + let mut msg = Vec::with_capacity(label.len() + 4); + msg.extend_from_slice(label); + msg.extend_from_slice(&counter.to_be_bytes()); + let block = self.tpm2_hmac_policy(key, session, &msg)?; + if block.len() != 32 { + bail!("TPM2_HMAC returned {} bytes, expected 32", block.len()); + } + out.extend_from_slice(&block); + counter += 1; + } + out.truncate(len); + Ok(out) + })(); + let _ = self.flush_context(session); + result + } + + /// `authPolicy` digest for `pcrs` in the current state, via a trial session (the TPM + /// computes the exact `PolicyPCR` digest for us). + fn pcr_policy_digest(&mut self, pcrs: &[u8]) -> Result> { + let session = self.start_auth_session(TPM_SE_TRIAL)?; + let digest = (|| { + self.policy_pcr(session, pcrs)?; + self.policy_get_digest(session) + })(); + let _ = self.flush_context(session); + digest + } + + /// `TPM2_CreatePrimary` for the keyedhash template; returns the transient handle. + fn create_keyedhash_primary(&mut self, hierarchy: u32, template: &[u8]) -> Result { + let command = CommandBuffer::new() + .write_u32(hierarchy) + .write_auth_empty_pw() + // inSensitive (TPM2B_SENSITIVE_CREATE): empty userAuth + empty data + .write_u16(4) + .write_u16(0) + .write_u16(0) + .write_tpm2b(template) // inPublic + .write_u16(0) // outsideInfo (empty) + .write_u32(0) // creationPCR: empty — creation-data/CertifyCreation only, + // does NOT bind the key (binding is authPolicy + policy session) + .finalize(TpmSt::Sessions, TpmCc::CreatePrimary); + let mut resp = self.transmit(&command)?; + resp.read_u32() // objectHandle (parameters follow, unused) + } + + /// `TPM2_HMAC` of `data` through the keyedhash key, authorized by a policy `session`. + fn tpm2_hmac_policy(&mut self, key: u32, session: u32, data: &[u8]) -> Result> { + let command = CommandBuffer::new() + .write_u32(key) + .write_auth_policy_session(session) + .write_tpm2b(data) // buffer (TPM2B_MAX_BUFFER) + .write_u16(TpmAlg::Null as u16) // hashAlg NULL — key carries its HMAC scheme + .finalize(TpmSt::Sessions, TpmCc::Hmac); + let mut resp = self.transmit(&command)?; + let _param_size = resp.read_u32()?; + resp.read_tpm2b() + } +} + +/// TPMT_PUBLIC for a keyedhash HMAC primary gated by `auth_policy`. `userWithAuth` is +/// cleared so use requires the policy session; NULL `unique` (the key material need not +/// carry the PCR state — the policy session enforces it at use time). +fn build_keyedhash_template(auth_policy: &[u8]) -> Vec { + let attrs = ObjectAttributes::new() + .fixed_tpm() + .fixed_parent() + .sensitive_data_origin() + .sign_encrypt(); + + CommandBuffer::new() + .write_u16(TPM_ALG_KEYEDHASH) // type + .write_u16(TpmAlg::Sha256 as u16) // nameAlg + .write_u32(attrs.value()) // objectAttributes (no userWithAuth) + .write_tpm2b(auth_policy) // authPolicy = PolicyPCR digest + // parameters (TPMS_KEYEDHASH_PARMS): scheme HMAC over SHA-256 + .write_u16(TPM_ALG_HMAC) + .write_u16(TpmAlg::Sha256 as u16) + // unique (TPM2B_DIGEST) - empty + .write_u16(0) + .into_vec() +} diff --git a/crates/vaportpm-attest/src/lib.rs b/crates/vaportpm-attest/src/lib.rs index a55a04f..7aa4d1a 100644 --- a/crates/vaportpm-attest/src/lib.rs +++ b/crates/vaportpm-attest/src/lib.rs @@ -30,6 +30,7 @@ use std::io::{Read, Write}; pub mod a9n; #[cfg(feature = "attest")] pub mod cert; +pub mod derive; pub mod ek; // NSM (AWS Nitro) attestation is reached via a TPM vendor command, so it is // no_std-capable and part of the attest surface (not std). @@ -39,8 +40,10 @@ pub mod nv; pub mod pcr; #[cfg(feature = "attest")] pub mod roots; +pub(crate) mod session; // Re-export extension traits for convenience +pub use derive::DeriveOps; pub use ek::KeyOps; pub use nv::NvOps; pub use pcr::PcrOps; @@ -67,6 +70,7 @@ pub use cert::StdHttpFetcher; pub enum TpmCc { PcrRead = 0x0000017E, PcrExtend = 0x00000182, + Hmac = 0x00000155, GetCapability = 0x0000017A, CreatePrimary = 0x00000131, Sign = 0x0000015D, @@ -369,6 +373,18 @@ impl CommandBuffer { .write_u16(0) // password/hmac - empty } + /// Authorization area referencing a policy `session` (empty nonce, continueSession, + /// empty caller HMAC). Same 9-byte shape as the password area, with the policy + /// session handle in place of `TPM_RS_PW`. + #[allow(dead_code)] + fn write_auth_policy_session(self, session: u32) -> Self { + self.write_u32(9) // authorizationSize + .write_u32(session) // sessionHandle - policy session + .write_u16(0) // nonceCaller - empty + .write_u8(0x01) // sessionAttributes - continueSession + .write_u16(0) // hmac - empty + } + fn finalize(mut self, tag: TpmSt, code: TpmCc) -> Vec { let total_size = 10 + self.data.len(); // header is 10 bytes let header = TpmCommandHeader::new(tag, total_size as u32, code); diff --git a/crates/vaportpm-attest/src/pcr.rs b/crates/vaportpm-attest/src/pcr.rs index 0df203a..dfa7ac6 100644 --- a/crates/vaportpm-attest/src/pcr.rs +++ b/crates/vaportpm-attest/src/pcr.rs @@ -400,16 +400,9 @@ impl PcrOps for Tpm { } let pcr_digest = pcr_hasher.finalize(); - // Step 2: Build PCR selection structure - let mut pcr_select = [0u8; 3]; // 3 bytes for PCRs 0-23 - for (index, _value) in pcr_values { - if *index < 24 { - pcr_select[*index as usize / 8] |= 1 << (*index % 8); - } - } - - // Step 3: Calculate policy digest + // Step 2: Calculate policy digest // policyDigest = SHA256(previousDigest || TPM_CC_PolicyPCR || pcrSelection || pcrDigest) + let indices: Vec = pcr_values.iter().map(|(index, _value)| *index).collect(); let mut policy_hasher = Sha256::new(); // previousDigest starts as all zeros (32 bytes for SHA256) @@ -418,13 +411,8 @@ impl PcrOps for Tpm { // TPM_CC_PolicyPCR = 0x0000017F policy_hasher.update((TpmCc::PolicyPCR as u32).to_be_bytes()); - // TPML_PCR_SELECTION structure - // count (4 bytes) - policy_hasher.update(1u32.to_be_bytes()); - // TPMS_PCR_SELECTION: hash (2 bytes) + sizeOfSelect (1 byte) + pcrSelect (3 bytes) - policy_hasher.update((pcr_alg as u16).to_be_bytes()); - policy_hasher.update([3u8]); // sizeOfSelect - policy_hasher.update(pcr_select); + // TPML_PCR_SELECTION (count=1, pcr_alg bank, PCRs from pcr_values) + policy_hasher.update(pcr_selection(&indices, pcr_alg)); // PCR digest policy_hasher.update(pcr_digest); @@ -434,3 +422,22 @@ impl PcrOps for Tpm { Ok(policy_digest.to_vec()) } } + +/// Marshal a single-bank `TPML_PCR_SELECTION` (count=1) selecting `indices` in the `alg` +/// bank: 4-byte count, 2-byte hash alg, 1-byte sizeofSelect=3, then the 3-byte select +/// bitmap covering PCRs 0-23. Shared by callers that gate on a PCR set (derivation policy, +/// software policy-digest calculation). +pub(crate) fn pcr_selection(indices: &[u8], alg: TpmAlg) -> Vec { + let mut bitmap = [0u8; 3]; + for &i in indices { + if i < 24 { + bitmap[(i / 8) as usize] |= 1 << (i % 8); + } + } + CommandBuffer::new() + .write_u32(1) // count + .write_u16(alg as u16) // hash + .write_u8(3) // sizeofSelect + .write_bytes(&bitmap) + .into_vec() +} diff --git a/crates/vaportpm-attest/src/session.rs b/crates/vaportpm-attest/src/session.rs new file mode 100644 index 0000000..7c8a95e --- /dev/null +++ b/crates/vaportpm-attest/src/session.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Authorization sessions and policy commands. +//! +//! Low-level building blocks for TPM authorization sessions and the policy assertions +//! layered onto them: start a trial or policy session, bind it to a PCR selection, and +//! read back the resulting policy digest. These are the primitives that higher-level +//! schemes (PCR-gated key derivation, sealing, policy-gated quote) build on; kept +//! `pub(crate)` until an external caller needs them. + +use alloc::vec::Vec; +use anyhow::Result; + +use crate::{CommandBuffer, Tpm, TpmAlg, TpmCc, TpmSt, TPM_RH_NULL}; + +/// Authorization-session and policy primitives. +pub(crate) trait SessionOps { + /// `TPM2_StartAuthSession` for `session_type` (e.g. `TPM_SE_POLICY` / `TPM_SE_TRIAL`); + /// returns the transient session handle. + fn start_auth_session(&mut self, session_type: u8) -> Result; + + /// `TPM2_PolicyPCR` with an empty `pcrDigest`, binding the session to the *current* + /// values of the PCRs named in `pcrs` (a marshalled `TPML_PCR_SELECTION`). + fn policy_pcr(&mut self, session: u32, pcrs: &[u8]) -> Result<()>; + + /// `TPM2_PolicyGetDigest` — read the accumulated policy digest for `session`. + fn policy_get_digest(&mut self, session: u32) -> Result>; +} + +impl SessionOps for Tpm { + /// `TPM2_StartAuthSession` — unsalted, unbound, SHA-256, no parameter encryption. + /// The session nonces don't affect the policy digest or the HMAC output, so a fixed + /// caller nonce keeps derivation deterministic. + fn start_auth_session(&mut self, session_type: u8) -> Result { + let command = CommandBuffer::new() + .write_u32(TPM_RH_NULL) // tpmKey (unsalted) + .write_u32(TPM_RH_NULL) // bind (unbound) + .write_tpm2b(&[0u8; 32]) // nonceCaller + .write_u16(0) // encryptedSalt (empty) + .write_u8(session_type) // sessionType + .write_u16(TpmAlg::Null as u16) // symmetric = NULL + .write_u16(TpmAlg::Sha256 as u16) // authHash + .finalize(TpmSt::NoSessions, TpmCc::StartAuthSession); + let mut resp = self.transmit(&command)?; + resp.read_u32() // sessionHandle (nonceTPM follows, unused) + } + + /// `TPM2_PolicyPCR` — empty pcrDigest so the TPM binds the *current* PCR values. + fn policy_pcr(&mut self, session: u32, pcrs: &[u8]) -> Result<()> { + let command = CommandBuffer::new() + .write_u32(session) + .write_u16(0) // pcrDigest (empty -> TPM uses current PCRs) + .write_bytes(pcrs) // pcrs (TPML_PCR_SELECTION) + .finalize(TpmSt::NoSessions, TpmCc::PolicyPCR); + self.transmit(&command)?; + Ok(()) + } + + /// `TPM2_PolicyGetDigest`. + fn policy_get_digest(&mut self, session: u32) -> Result> { + let command = CommandBuffer::new() + .write_u32(session) + .finalize(TpmSt::NoSessions, TpmCc::PolicyGetDigest); + let mut resp = self.transmit(&command)?; + resp.read_tpm2b() + } +} diff --git a/crates/vaportpm-verify/Cargo.toml b/crates/vaportpm-verify/Cargo.toml index 0226a46..78f508d 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vaportpm-verify" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "Cloud vTPM attestation verification - TPM and Nitro attestation verification" license.workspace = true @@ -12,7 +12,7 @@ vaportpm-attest = { path = "../vaportpm-attest" } # UEFI build); vaportpm-verify is a std crate, so it opts each back into `std`. der = { workspace = true, features = ["std"] } spki = { workspace = true } -x509-cert = { workspace = true, features = ["std"] } +x509-cert = { workspace = true, features = ["std", "builder", "hazmat"] } # Time types pki-types = { workspace = true } @@ -37,9 +37,11 @@ serde_json = { workspace = true, features = ["std"] } zerocopy = { workspace = true } [dev-dependencies] -rcgen = "0.13" +# Test certificates are built with x509-cert's `builder` (a workspace dep) + p256/p384 signers, so +# no `ring`/`rcgen` in the tree. rand_core (getrandom) supplies OsRng for ephemeral test keys. p256 = { workspace = true, features = ["pkcs8"] } p384 = { workspace = true, features = ["pkcs8"] } +rand_core = { version = "0.6", features = ["getrandom"] } [lib] name = "vaportpm_verify" diff --git a/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs index 4be5c6e..9078460 100644 --- a/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs +++ b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs @@ -8,7 +8,8 @@ use std::collections::BTreeMap; -use p256::pkcs8::DecodePrivateKey as _; +use p256::pkcs8::EncodePrivateKey as _; +use rand_core::OsRng; use crate::error::{ CborParseReason, ChainValidationReason, CoseVerifyReason, InvalidAttestReason, @@ -24,9 +25,8 @@ use crate::{ /// Helper: generate an ephemeral P-256 AK key pair, returning (P256PublicKey, pkcs8_der). fn ephemeral_ak() -> (P256PublicKey, Vec) { - let ak_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let ak_pkcs8 = ak_key.serialize_der(); - let ak_sk = p256::ecdsa::SigningKey::from_pkcs8_der(&ak_pkcs8).unwrap(); + let ak_sk = p256::ecdsa::SigningKey::random(&mut OsRng); + let ak_pkcs8 = ak_sk.to_pkcs8_der().unwrap().as_bytes().to_vec(); let ak_point = ak_sk.verifying_key().to_encoded_point(false); let ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); (ak_pubkey, ak_pkcs8) diff --git a/crates/vaportpm-verify/src/test_support.rs b/crates/vaportpm-verify/src/test_support.rs index f38bdfd..2e0de2a 100644 --- a/crates/vaportpm-verify/src/test_support.rs +++ b/crates/vaportpm-verify/src/test_support.rs @@ -21,6 +21,20 @@ use crate::x509::hash_public_key; use crate::{CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation}; use pki_types::UnixTime; +// Pure-Rust test certificate factory (x509-cert `builder` + p256/p384 signers) -- replaces rcgen, +// so no `ring`/C toolchain enters the tree. See `define_certgen!` below. +use std::str::FromStr; + +use der::asn1::{GeneralizedTime, UtcTime}; +use der::Encode as _; +use p256::pkcs8::EncodePrivateKey as _; +use rand_core::OsRng; +use x509_cert::builder::{Builder, CertificateBuilder, Profile}; +use x509_cert::name::Name; +use x509_cert::serial_number::SerialNumber; +use x509_cert::spki::SubjectPublicKeyInfoOwned; +use x509_cert::time::{Time, Validity}; + // ============================================================================ // TPM Quote builder // ============================================================================ @@ -233,6 +247,107 @@ pub fn build_nitro_cose_doc( // Certificate chain generation // ============================================================================ +/// Validity window wide enough to cover both the fixed test verification time +/// (`EPHEMERAL_TIMESTAMP_SECS`, Feb 2026) and the real build clock. rcgen defaulted to a similarly +/// wide window (1975..4096), which these tests relied on; `Validity::from_now` would set +/// `not_before = now` and reject the fixed-timestamp verification. +fn wide_validity() -> Validity { + Validity { + not_before: Time::UtcTime( + UtcTime::from_unix_duration(Duration::from_secs(946_684_800)).unwrap(), // 2000-01-01 + ), + not_after: Time::GeneralTime( + GeneralizedTime::from_unix_duration(Duration::from_secs(4_102_444_800)).unwrap(), // 2100-01-01 + ), + } +} + +/// Generate a self-signed CA and a CA-signed leaf builder for one ECDSA curve. The CA gets +/// `Profile::Root` (BasicConstraints CA + KeyUsage keyCertSign) and the leaf `Profile::Leaf` +/// (CA:FALSE + KeyUsage digitalSignature) -- exactly what `validate_tpm_cert_chain` requires. +macro_rules! define_certgen { + ($curve:ident, $ca_fn:ident, $leaf_fn:ident) => { + /// Self-signed CA cert (DER) with subject/issuer `CN=cn`, signed by `ca`. + pub(crate) fn $ca_fn(cn: &str, ca: &$curve::ecdsa::SigningKey) -> Vec { + let subject = Name::from_str(&format!("CN={cn}")).unwrap(); + let spki = SubjectPublicKeyInfoOwned::from_key(*ca.verifying_key()).unwrap(); + CertificateBuilder::new( + Profile::Root, + SerialNumber::from(1u32), + wide_validity(), + subject, + spki, + ca, + ) + .unwrap() + .build::<$curve::ecdsa::DerSignature>() + .unwrap() + .to_der() + .unwrap() + } + + /// Leaf cert (DER) `CN=cn`, subject key `leaf`, signed by `ca` (issuer `CN=issuer_cn`). + pub(crate) fn $leaf_fn( + cn: &str, + leaf: &$curve::ecdsa::SigningKey, + issuer_cn: &str, + ca: &$curve::ecdsa::SigningKey, + ) -> Vec { + let subject = Name::from_str(&format!("CN={cn}")).unwrap(); + let issuer = Name::from_str(&format!("CN={issuer_cn}")).unwrap(); + let spki = SubjectPublicKeyInfoOwned::from_key(*leaf.verifying_key()).unwrap(); + CertificateBuilder::new( + Profile::Leaf { + issuer, + enable_key_agreement: false, + enable_key_encipherment: false, + include_subject_key_identifier: true, + }, + SerialNumber::from(2u32), + wide_validity(), + subject, + spki, + ca, + ) + .unwrap() + .build::<$curve::ecdsa::DerSignature>() + .unwrap() + .to_der() + .unwrap() + } + }; +} +define_certgen!(p256, mk_p256_ca, mk_p256_leaf); +define_certgen!(p384, mk_p384_ca, mk_p384_leaf); + +/// A self-signed P-256 CA with BasicConstraints CA:TRUE but NO KeyUsage extension. Used to verify +/// the chain validator rejects a CA lacking Key Usage. `Profile::Manual` opts out of the builder's +/// default extensions, so we add only BasicConstraints. +pub(crate) fn mk_p256_ca_no_keyusage(cn: &str, ca: &p256::ecdsa::SigningKey) -> Vec { + let subject = Name::from_str(&format!("CN={cn}")).unwrap(); + let spki = SubjectPublicKeyInfoOwned::from_key(*ca.verifying_key()).unwrap(); + let mut builder = CertificateBuilder::new( + Profile::Manual { issuer: None }, + SerialNumber::from(1u32), + wide_validity(), + subject, + spki, + ca, + ) + .unwrap(); + builder + .add_extension(&x509_cert::ext::pkix::BasicConstraints { + ca: true, + path_len_constraint: None, + }) + .unwrap(); + builder + .build::() + .unwrap() + .to_der() + .unwrap() +} + /// Generated key material for a P-384 Nitro-style cert chain. pub struct NitroChainKeys { /// Root CA cert DER @@ -249,41 +364,21 @@ pub struct NitroChainKeys { /// /// Returns key material needed to build COSE documents and register the test root. pub fn generate_nitro_chain() -> NitroChainKeys { - // Root CA (self-signed, P-384) - let mut ca_params = - rcgen::CertificateParams::new(vec!["AWS Nitro Test Root".to_string()]).unwrap(); - ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params.key_usages = vec![ - rcgen::KeyUsagePurpose::KeyCertSign, - rcgen::KeyUsagePurpose::DigitalSignature, - ]; - ca_params - .distinguished_name - .push(rcgen::DnType::CommonName, "AWS Nitro Test Root"); - let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384).unwrap(); - let ca_cert = ca_params.self_signed(&ca_key).unwrap(); - - // Leaf cert (signed by root, P-384) - let mut leaf_params = - rcgen::CertificateParams::new(vec!["Nitro Test Leaf".to_string()]).unwrap(); - leaf_params.is_ca = rcgen::IsCa::NoCa; - leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; - leaf_params - .distinguished_name - .push(rcgen::DnType::CommonName, "Nitro Test Leaf"); - let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384).unwrap(); - let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + // Root CA (self-signed, P-384) + leaf signed by it. + let ca_key = p384::ecdsa::SigningKey::random(&mut OsRng); + let leaf_key = p384::ecdsa::SigningKey::random(&mut OsRng); + let root_der = mk_p384_ca("AWS Nitro Test Root", &ca_key); + let leaf_der = mk_p384_leaf("Nitro Test Leaf", &leaf_key, "AWS Nitro Test Root", &ca_key); // Compute root pubkey hash - let root_x509 = - x509_cert::Certificate::from_der(ca_cert.der()).expect("root cert should parse"); + let root_x509 = x509_cert::Certificate::from_der(&root_der).expect("root cert should parse"); let root_pubkey = crate::x509::extract_public_key(&root_x509).unwrap(); let root_pubkey_hash = hash_public_key(&root_pubkey); NitroChainKeys { - root_der: ca_cert.der().to_vec(), - leaf_der: leaf_cert.der().to_vec(), - cose_signing_key: leaf_key.serialize_der(), + root_der, + leaf_der, + cose_signing_key: leaf_key.to_pkcs8_der().unwrap().as_bytes().to_vec(), root_pubkey_hash, } } @@ -304,47 +399,31 @@ pub struct GcpChainKeys { /// Generate a P-256 cert chain for GCP tests (leaf + root). pub fn generate_gcp_chain() -> GcpChainKeys { - // Root CA (self-signed, P-256) - let mut ca_params = - rcgen::CertificateParams::new(vec!["GCP EK/AK Test Root".to_string()]).unwrap(); - ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params.key_usages = vec![ - rcgen::KeyUsagePurpose::KeyCertSign, - rcgen::KeyUsagePurpose::DigitalSignature, - ]; - ca_params - .distinguished_name - .push(rcgen::DnType::CommonName, "GCP EK/AK Test Root"); - let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let ca_cert = ca_params.self_signed(&ca_key).unwrap(); - - // Leaf cert (signed by root, P-256) - let mut leaf_params = - rcgen::CertificateParams::new(vec!["GCP AK Test Leaf".to_string()]).unwrap(); - leaf_params.is_ca = rcgen::IsCa::NoCa; - leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; - leaf_params - .distinguished_name - .push(rcgen::DnType::CommonName, "GCP AK Test Leaf"); - let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + // Root CA (self-signed, P-256) + leaf signed by it. + let ca_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let leaf_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let root_der = mk_p256_ca("GCP EK/AK Test Root", &ca_key); + let leaf_der = mk_p256_leaf( + "GCP AK Test Leaf", + &leaf_key, + "GCP EK/AK Test Root", + &ca_key, + ); // Extract AK pubkey from leaf cert - let leaf_x509 = - x509_cert::Certificate::from_der(leaf_cert.der()).expect("leaf cert should parse"); + let leaf_x509 = x509_cert::Certificate::from_der(&leaf_der).expect("leaf cert should parse"); let ak_pubkey_vec = crate::x509::extract_public_key(&leaf_x509).unwrap(); let ak_pubkey = P256PublicKey::from_sec1_uncompressed(&ak_pubkey_vec).unwrap(); // Compute root pubkey hash - let root_x509 = - x509_cert::Certificate::from_der(ca_cert.der()).expect("root cert should parse"); + let root_x509 = x509_cert::Certificate::from_der(&root_der).expect("root cert should parse"); let root_pubkey = crate::x509::extract_public_key(&root_x509).unwrap(); let root_pubkey_hash = hash_public_key(&root_pubkey); GcpChainKeys { - root_der: ca_cert.der().to_vec(), - leaf_der: leaf_cert.der().to_vec(), - ak_signing_key: leaf_key.serialize_der(), + root_der, + leaf_der, + ak_signing_key: leaf_key.to_pkcs8_der().unwrap().as_bytes().to_vec(), ak_pubkey, root_pubkey_hash, } @@ -386,11 +465,10 @@ pub fn build_valid_nitro( let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); // Generate AK key pair (P-256 for TPM Quote signing) - let ak_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let ak_signing_key_pkcs8 = ak_key.serialize_der(); + let ak_signing_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let ak_signing_key_pkcs8 = ak_signing_key.to_pkcs8_der().unwrap().as_bytes().to_vec(); // Extract AK public key (SEC1 uncompressed) - let ak_signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(&ak_signing_key_pkcs8).unwrap(); let ak_verifying_key = ak_signing_key.verifying_key(); let ak_point = ak_verifying_key.to_encoded_point(false); let ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); diff --git a/crates/vaportpm-verify/src/x509.rs b/crates/vaportpm-verify/src/x509.rs index 5b8a0a8..fbe4946 100644 --- a/crates/vaportpm-verify/src/x509.rs +++ b/crates/vaportpm-verify/src/x509.rs @@ -742,27 +742,22 @@ mod tests { use pki_types::UnixTime; use std::time::Duration; - // Root CA with cA:TRUE but NO Key Usage extension (empty key_usages → - // rcgen omits the extension entirely). BasicConstraints alone would + // Root CA with cA:TRUE but NO Key Usage extension. BasicConstraints alone would // accept it as a CA; the new Key Usage requirement must reject it. - let mut ca_params = - rcgen::CertificateParams::new(vec!["No-KU Test Root".to_string()]).unwrap(); - ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params.key_usages = vec![]; - let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let ca_cert = ca_params.self_signed(&ca_key).unwrap(); - + let ca_key = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); + let leaf_key = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); + let ca_der = crate::test_support::mk_p256_ca_no_keyusage("No-KU Test Root", &ca_key); // Leaf signed by that CA, with a valid Key Usage of its own. - let mut leaf_params = - rcgen::CertificateParams::new(vec!["No-KU Test Leaf".to_string()]).unwrap(); - leaf_params.is_ca = rcgen::IsCa::NoCa; - leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; - let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + let leaf_der = crate::test_support::mk_p256_leaf( + "No-KU Test Leaf", + &leaf_key, + "No-KU Test Root", + &ca_key, + ); let chain = vec![ - Certificate::from_der(leaf_cert.der()).unwrap(), - Certificate::from_der(ca_cert.der()).unwrap(), + Certificate::from_der(&leaf_der).unwrap(), + Certificate::from_der(&ca_der).unwrap(), ]; let time = UnixTime::since_unix_epoch(Duration::from_secs( diff --git a/crates/vaportpm-verify/tests/gcp.rs b/crates/vaportpm-verify/tests/gcp.rs index ff41d61..2630c55 100644 --- a/crates/vaportpm-verify/tests/gcp.rs +++ b/crates/vaportpm-verify/tests/gcp.rs @@ -464,56 +464,84 @@ fn test_gcp_decoded_reject_invalid_der_cert() { /// Covers: gcp.rs — provider_from_hash returns None → "Unknown root CA" #[test] fn test_gcp_decoded_reject_unknown_root_ca() { + use der::asn1::{GeneralizedTime, UtcTime}; + use der::Encode as _; use ecdsa::signature::hazmat::PrehashSigner; - use p256::pkcs8::DecodePrivateKey; + use rand_core::OsRng; use sha2::Digest; + use std::str::FromStr; + use x509_cert::builder::{Builder, CertificateBuilder, Profile}; + use x509_cert::name::Name; + use x509_cert::serial_number::SerialNumber; + use x509_cert::spki::SubjectPublicKeyInfoOwned; + use x509_cert::time::{Time, Validity}; + + // Wide validity so the fixed fixture verification time falls inside it (rcgen defaulted wide). + let validity = Validity { + not_before: Time::UtcTime( + UtcTime::from_unix_duration(Duration::from_secs(946_684_800)).unwrap(), // 2000-01-01 + ), + not_after: Time::GeneralTime( + GeneralizedTime::from_unix_duration(Duration::from_secs(4_102_444_800)).unwrap(), // 2100 + ), + }; - // Generate a self-signed CA - let mut ca_params = rcgen::CertificateParams::new(vec!["Fake Root CA".to_string()]).unwrap(); - ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params.key_usages = vec![ - rcgen::KeyUsagePurpose::KeyCertSign, - rcgen::KeyUsagePurpose::DigitalSignature, - ]; - ca_params - .distinguished_name - .push(rcgen::DnType::CommonName, "Fake Root CA"); - let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let ca_cert = ca_params.self_signed(&ca_key).unwrap(); - - // Generate a leaf cert signed by our fake CA - let mut leaf_params = rcgen::CertificateParams::new(vec!["Fake Leaf".to_string()]).unwrap(); - leaf_params.is_ca = rcgen::IsCa::NoCa; - leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; - leaf_params - .distinguished_name - .push(rcgen::DnType::CommonName, "Fake Leaf"); - let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); - let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + // Self-signed fake CA (P-256, proper CA + keyCertSign) and a leaf under it -- structurally valid + // so chain validation passes, but the root is unknown so provider lookup must reject it. + let ca_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let leaf_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let ca_subject = Name::from_str("CN=Fake Root CA").unwrap(); + + let ca_der = CertificateBuilder::new( + Profile::Root, + SerialNumber::from(1u32), + validity, + ca_subject.clone(), + SubjectPublicKeyInfoOwned::from_key(*ca_key.verifying_key()).unwrap(), + &ca_key, + ) + .unwrap() + .build::() + .unwrap() + .to_der() + .unwrap(); + + let leaf_der = CertificateBuilder::new( + Profile::Leaf { + issuer: ca_subject, + enable_key_agreement: false, + enable_key_encipherment: false, + include_subject_key_identifier: true, + }, + SerialNumber::from(2u32), + validity, + Name::from_str("CN=Fake Leaf").unwrap(), + SubjectPublicKeyInfoOwned::from_key(*leaf_key.verifying_key()).unwrap(), + &ca_key, + ) + .unwrap() + .build::() + .unwrap() + .to_der() + .unwrap(); // Start from the real fixture (has valid quote_attest, nonce, PCRs) let mut decoded = decode_gcp_amd_fixture(); // Extract AK public key from the leaf signing key - let leaf_signing_key_for_pk = - p256::ecdsa::SigningKey::from_pkcs8_der(&leaf_key.serialize_der()).unwrap(); - let ak_point = leaf_signing_key_for_pk - .verifying_key() - .to_encoded_point(false); + let ak_point = leaf_key.verifying_key().to_encoded_point(false); decoded.ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); // Re-sign the quote_attest with the fake leaf's private key so the // signature verification passes. verify_ecdsa_p256 does // verify_prehash(SHA-256(message)), so we sign_prehash the same digest. - let leaf_signing_key = - p256::ecdsa::SigningKey::from_pkcs8_der(&leaf_key.serialize_der()).unwrap(); let digest = sha2::Sha256::digest(&decoded.quote_attest); - let signature: p256::ecdsa::Signature = leaf_signing_key.sign_prehash(&digest).unwrap(); + let signature: p256::ecdsa::Signature = leaf_key.sign_prehash(&digest).unwrap(); decoded.quote_signature = signature.to_der().as_bytes().to_vec(); // Swap in our fake cert chain decoded.platform = DecodedPlatformAttestation::Gcp { - cert_chain_der: vec![leaf_cert.der().to_vec(), ca_cert.der().to_vec()], + cert_chain_der: vec![leaf_der, ca_der], }; let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..429c58e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.91.1" +components = ["rustfmt", "clippy"] +targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"] +profile = "minimal"