diff --git a/Makefile b/Makefile index 797f8a6..ebea154 100644 --- a/Makefile +++ b/Makefile @@ -243,6 +243,8 @@ test-chain: # pubkey in _stage1 and _stage2 (payloads roll forward under a stable key). # SIGN_ARGS=1 (implies SIGN) also serve signed args.json (+ .sig) and set _stage2.args_url, # exercising stage1's signed-remote-args path. +# ARGS='[..]' set inline _stage2.args to this JSON array (ignored when SIGN_ARGS is set, +# which supplies its own signed args). Used by the smoke-args-% target. # FALLBACK=1 make the _stage2 url a list [dead 127.0.0.1:9, real] so stage1's mirror # fallback is exercised (the first url refuses, the second serves). test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \ @@ -281,13 +283,35 @@ test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \ S2="\"$*\": { \"url\": $$S2URL, \"sha256\": \"$$S2_SHA\" }"; \ echo "user-data: sha256 mode (UKI $$UKI_SHA, stage2 $$S2_SHA)"; \ fi; \ - printf '{\n "_stage1": { %s },\n "_stage2": { %s }\n}\n' "$$S1" "$$S2" > user-data.stage0.json; \ + S2ARGS=""; \ + if [ -n '$(ARGS)' ] && [ -z "$(SIGN_ARGS)" ]; then \ + AJSON=$$(printf '%s' '$(ARGS)'); \ + S2ARGS="\"args\": $$AJSON, "; \ + echo "user-data: inline _stage2.args = $$AJSON"; \ + fi; \ + printf '{\n "_stage1": { %s },\n "_stage2": { %s%s }\n}\n' "$$S1" "$$S2ARGS" "$$S2" > user-data.stage0.json; \ $(DOCKER_RUN) $(DOCKER_OPT_KVM) \ -e YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1 --cap-add=NET_ADMIN --device=/dev/net/tun \ $(HARNESS_IMAGE) --kind stage0 --arch $* \ --boot-disk "$(STAGE0_BOOT_DISK)" \ --serve-dir "$$D" --user-data user-data.stage0.json $(if $(TRACE),--trace) +# ---- Smoke test: _stage2 args actually reach the payload's argv ---- +# Boot the full chain with a known inline args array (one arg contains a space, to prove +# it is a real argv vector and not shell word-splitting) and assert the payload echoed it. +# The signed-remote-args path is covered separately by `test-chain-% SIGN=1 SIGN_ARGS=1`. +.PHONY: smoke-args-% +smoke-args-%: + @log="build/$*/smoke-args.log"; mkdir -p "build/$*"; \ + $(MAKE) test-chain-$* ARGS='["--smoke","hello world"]' 2>&1 | tee "$$log"; \ + echo "=== smoke-args assertion ==="; \ + if grep -q 'arg\[1\]: --smoke' "$$log" && grep -q 'arg\[2\]: hello world' "$$log"; then \ + echo "PASS: inline _stage2.args reached the payload argv (spaces preserved)"; \ + else \ + echo "FAIL: expected 'arg[1]: --smoke' and 'arg[2]: hello world' in the console output"; \ + exit 1; \ + fi + ##################################################################### # Housekeeping diff --git a/README.md b/README.md index df4da63..4491f55 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,15 @@ stage1 admits its stage2 payload from a `_stage2` block in the instance's user-d Any statically-linked Linux ELF works, as long as it reads its config from stdin; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`. +## Arguments and config model + +Two distinct hops, don't conflate them: + +- **stage1's own config** comes from the cloud **metadata** service (the PID-1 boot path) or, when stage1 is run as a normal process, from a user-data doc **piped on stdin** (`stage1 < user-data.json`). There are no `--url`/`--file` flags — pipe it in. `--attest` remains for diagnostics. +- **The stage2 app's argv** comes from **`_stage2.args`** (inline) or the signed `args_url` (which overrides inline); these are handed to the payload as `argv[1..]` (with `argv[0] = "stage2"`). + +Note on `_stage1.args`: that field belongs to **stage0**, which sets the booted EFI program's UEFI *LoadOptions* from it — the generic contract for any EFI stage1. For **this Linux UKI**, the kernel command line is baked into the signed, measured `.cmdline` and is authoritative: under Secure Boot the stub **ignores** LoadOptions, so `_stage1.args` cannot (and must not) alter the UKI cmdline. Configure a UKI-based stage1 through **`_stage2`**, not the kernel cmdline. See the [stage0 repo](https://github.com/lockboot/stage0) for the LoadOptions contract. + ## Deploy The **`deploy`** tool (binary `lockboot-deploy`) turns local build artifacts into an upload-ready deployment: it signs (or hashes) the UKI + stage2, composes **mirror URL lists** from repeated `--base-url`, and emits a directory plus a merged `user-data.json` carrying both `_stage1` (the UKI hop) and `_stage2` (the payload hop). diff --git a/crates/example-stage2/README.md b/crates/example-stage2/README.md index 830eee9..381b8f9 100644 --- a/crates/example-stage2/README.md +++ b/crates/example-stage2/README.md @@ -88,20 +88,13 @@ Launch an EC2 instance with the configuration in the user-data field. The `stage 5. Generate a TPM attestation document 6. Execute the stage2 binary (this example) -### Option 2: Manual Testing with File +### Option 2: Manual Testing (config on stdin) ```bash -# Create a config file -stage1 --make-config https://lockboot.s3.us-east-1.amazonaws.com/examples/stage2/example-stage2 > config.json - -# Execute with stage1 -sudo stage1 --file config.json -``` - -### Option 3: Manual Testing with URL - -```bash -sudo stage1 --url https://lockboot.s3.us-east-1.amazonaws.com/examples/stage2/user-data.example.json +# Generate user-data.json with the `deploy` tool (lockboot-deploy), then pipe it in: +sudo stage1 < user-data.json +# or +curl -s https://lockboot.s3.us-east-1.amazonaws.com/examples/stage2/user-data.example.json | sudo stage1 ``` ## Expected Output diff --git a/crates/stage1/README.md b/crates/stage1/README.md index 49df755..27da1f0 100644 --- a/crates/stage1/README.md +++ b/crates/stage1/README.md @@ -48,62 +48,22 @@ Any additional fields in the JSON are preserved, the raw file is written to `/tm ## Usage Modes -### 1. Production Mode (PID 1 / No Arguments) +### 1. Production (PID 1 / no arguments) -When running as PID 1 or without arguments, stage1 automatically fetches its JSON config from the cloud metadata service, uses the TPM to attest the instance state, then executes it. +As PID 1 (or with no arguments), stage1 fetches its user-data JSON from the cloud metadata service, admits and measures the stage2 payload, attests the virgin state, then execs it. This is the normal boot path. -### 2. Testing with Local File +### 2. Config on stdin -```bash -stage1 --file config.json -``` - -Reads configuration from a local JSON file. Useful for testing before deploying to cloud. - -### 3. Testing with Remote URL - -```bash -stage1 --url https://example.com/config.json -``` - -Fetches configuration from a remote URL. The URL content is hashed and logged. - -### 4. Generate Configuration - -```bash -stage1 --make-config [existing-config.json] -``` - -Downloads a binary, computes its SHA256 hash, and outputs a valid stage1 configuration with architecture-specific settings: - -**Example:** -```bash -# Generate config for x86_64 binary -stage1 --make-config x86_64 https://example.com/stage2-x86_64 - -# Output: -{ - "_stage2": { - "x86_64": { - "url": "https://example.com/stage2-x86_64", - "sha256": "a1b2c3d4e5f6..." - } - } -} -``` +A user-data document piped on stdin takes precedence over the metadata service - the Unix way, for local testing or bespoke delivery: -**Building a multi-architecture config:** ```bash -# First architecture creates the config -stage1 --make-config x86_64 https://example.com/stage2-amd64 > config.json - -# Second architecture adds to existing config -stage1 --make-config aarch64 https://example.com/stage2-arm64 config.json > config.json +stage1 < user-data.json +cat user-data.json | stage1 ``` -This allows you to use the same configuration file across different architectures - stage1 will automatically select the appropriate binary based on its build target. +stage1 only reads stdin when it is a pipe or a regular file, never the console, so PID 1 never blocks waiting for input. Deployment documents are produced by the separate `deploy` tool (`lockboot-deploy`), not by this binary. -### 5. Generate TPM Attestation +### 3. Generate TPM Attestation ```bash sudo stage1 --attest [challenge] @@ -129,20 +89,13 @@ The optional `challenge` can be used as a signing mechanism or as proof-of-liven ## TPM Measurements -It first creates the following files in `/tmp/`: - -| File | Content | Purpose | -|------|---------|---------| -| `/tmp/stage2.exe` | Downloaded binary | The stage2 executable (mode 0755) | -| `/tmp/stage2-config.json` | Full JSON config | Configuration data for stage2 | -| `/tmp/stage1.attest` | TPM attestation | Pre-execution attestation document | - -Before executing stage2, the TPM PCRs are extended with cryptographic measurements: +Before executing stage2, stage1 extends exactly one PCR: | PCR | Purpose | Value Extended | |-----|---------|----------------| -| **PCR 14** | Stage2 Binary | SHA256 hash of the downloaded binary | -| **PCR 15** | Configuration | SHA256 hash of the entire JSON config | +| **PCR 14** | Stage2 binary | SHA-256 of the stage2 code, and nothing else | + +Measurement is **code-only**: the config, the admission pin/key, and the argv are *not* measured, so the platform quote is reproducible from the boot artifacts alone. PCR 15 is deliberately left untouched for a stage2 app to measure whatever config *it* deems trust-relevant. The stage2 payload runs from a sealed in-memory image (never a file on disk); it receives the raw user-data JSON on **stdin**, and the pre-execution attestation is written to `/tmp/stage1.attest`. ## Attestation Trust Model @@ -152,7 +105,7 @@ A restricted signing key can only sign digests that the TPM itself produces. Whe Without the restricted key constraint, the trust model collapses into tautology: stage2 could simply sign "I'm in virgin state" and the attestation would prove nothing. -The attestation uses `H(H(binary) || H(config))` as the nonce - a hash of the binary hash concatenated with the config hash - binding the attestation to the specific intended workload. Once this virgin-state attestation exists, any future use of the same AK inherits this trust anchor. A verifier can reason: "this AK was attested in a clean PCR state with config X, therefore subsequent quotes from this AK come from a system that started from that trusted state." +The attestation uses `H(binary)` as the nonce - the SHA-256 of the stage2 code - binding the attestation to the specific intended workload. Config is deliberately not bound, matching the code-only PCR 14 measurement. Once this virgin-state attestation exists, any future use of the same AK inherits this trust anchor. A verifier can reason: "this AK was attested in a clean PCR state before this exact binary ran, therefore subsequent quotes from this AK come from a system that started from that trusted state." The restricted signing constraint is imposed by cloud vTPMs (notably GCP), which limits the AK to operations like `TPM2_Quote`, `TPM2_Certify`, and `TPM2_CertifyCreation`. While this can feel limiting, it's precisely what makes the trust model sound. @@ -243,18 +196,12 @@ aws s3 cp target/aarch64-unknown-linux-musl/release/example-stage2 \ s3://mybucket/stage2-arm64 ``` -### 3. Generate multi-architecture configuration -```bash -# Add x86_64 config -stage1 --make-config x86_64 https://mybucket.s3.amazonaws.com/stage2-amd64 > config.json - -# Add aarch64 config to the same file -stage1 --make-config aarch64 https://mybucket.s3.amazonaws.com/stage2-arm64 config.json > config.json -``` +### 3. Generate the deployment document +Use the `deploy` tool (`lockboot-deploy`) to hash (or sign) the payloads and emit a `user-data.json` carrying `_stage1` and `_stage2`; see the [repo README](../../README.md#deploy). stage1 does not generate config itself. ### 4. Test locally ```bash -sudo stage1 --file config.json +stage1 < user-data.json ``` ### 5. Deploy to cloud diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index 6012cfe..a69c9cd 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -11,7 +11,7 @@ use vaportpm_attest::{PcrOps, Tpm}; use vaportpm_attest as tpm; use std::ffi::CString; use std::fs; -use std::io::{self, Write}; +use std::io::{self, Read, Write}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStringExt; use std::thread; @@ -52,13 +52,17 @@ fn main() { } fn main_inner() -> Result<()> { - let args: Vec = std::env::args().collect(); - // This runs when: PID is 1 (init process) OR no arguments provided - if is_pid1() || args.len() <= 1 { - return stage2(fetch_cloud_metadata()?); + // A user-data document piped on stdin wins over everything (the Unix way): stage0 + // passes no args to stage1, and stage1 takes no config flags -- pipe whatever you + // want in (`stage1 < user-data.json`). In production, PID 1's stdin is the console + // (a tty), so this is skipped and the cloud metadata service is used instead. + if let Some(bytes) = stdin_config()? { + return stage2(parse_json_to_config(bytes)?); } - // Handle --attest command - if args[1] == "--attest" { + + // --attest is a standalone diagnostic (print a TPM attestation and exit). + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--attest") { let nonce = if args.len() > 2 { args[2].as_bytes().to_vec() } else { @@ -71,21 +75,31 @@ fn main_inner() -> Result<()> { }; return Ok(println!("{}", tpm::attest(&nonce)?)); } - // Dev paths: fetch a user-data doc from a URL or file, then run stage2. - if args.len() == 3 { - return stage2(parse_json_to_config(match args[1].as_str() { - "--url" => fetch_from_url(&args[2])?, - "--file" => read_from_file(&args[2])?, - _ => return Err(anyhow!("Invalid argument. Use --url or --file ")), - })?); + + // Default (and the PID-1 boot path): fetch the user-data doc from cloud metadata. + stage2(fetch_cloud_metadata()?) +} + +/// A user-data document piped on stdin, or `None` when nothing is piped. Only reads when +/// fd 0 is a pipe or a regular file -- never a tty/char device (the PID-1 console) -- so +/// it can never block waiting for input that will not come. +fn stdin_config() -> Result>> { + let fd = io::stdin().as_raw_fd(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return Ok(None); + } + let kind = st.st_mode as u32 & libc::S_IFMT as u32; + if kind != libc::S_IFREG as u32 && kind != libc::S_IFIFO as u32 { + return Ok(None); // tty / char device / socket -> not a piped config + } + let mut buf = Vec::new(); + io::stdin().read_to_end(&mut buf).context("read config from stdin")?; + if buf.is_empty() { + return Ok(None); } - Err(anyhow!( - "Usage: stage1 [--url | --file | --attest [nonce]]\n\ - With no arguments (or as PID 1): fetch the user-data doc from the cloud metadata\n\ - service and run stage2. --url/--file fetch the doc from a URL or file instead.\n\ - --attest: print a TPM attestation (EK certs, PCRs, certified signing key).\n\ - (Deployment config is generated by the separate `deploy` tool, not this binary.)" - )) + log_hash("stdin", &buf); + Ok(Some(buf)) } /// Get kernel-style timestamp string: [ 2.231397] @@ -365,24 +379,6 @@ fn fetch_cloud_metadata() -> Result { .context("Failed to fetch metadata from any cloud provider (tried EC2, GCP, Azure)") } -fn fetch_from_url(url: &str) -> Result> { - let body = http_client()? - .get(url) - .send() - .context("Failed to fetch user-data from URL")? - .bytes() - .context("Failed to read response from URL")? - .to_vec(); - log_hash(url, &body); - Ok(body) -} - -fn read_from_file(path: &str) -> Result> { - let data = fs::read(path).context(format!("Failed to read file: {}", path))?; - log_hash(path, data.as_slice()); - Ok(data) -} - fn download_binary(url: &str) -> Result { let client = http_client()?; // reqwest already owns the body as `Bytes`; hand it back as-is (no extra copy).