Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 5 additions & 12 deletions crates/example-stage2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 16 additions & 69 deletions crates/stage1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <aarch64|x86_64> <URL> [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]
Expand All @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
74 changes: 35 additions & 39 deletions crates/stage1/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,13 +52,17 @@ fn main() {
}

fn main_inner() -> Result<()> {
let args: Vec<String> = 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<String> = 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 {
Expand All @@ -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 <URL> or --file <PATH>")),
})?);

// 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<Option<Vec<u8>>> {
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 <URL> | --file <PATH> | --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]
Expand Down Expand Up @@ -365,24 +379,6 @@ fn fetch_cloud_metadata() -> Result<ParsedData> {
.context("Failed to fetch metadata from any cloud provider (tried EC2, GCP, Azure)")
}

fn fetch_from_url(url: &str) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<Bytes> {
let client = http_client()?;
// reqwest already owns the body as `Bytes`; hand it back as-is (no extra copy).
Expand Down