diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..2b45142 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,21 @@ +name: e2e + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + flake-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + - run: nix flake check -L diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..f533997 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,37 @@ +name: Ruff + +on: + pull_request: + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/ruff.yml' + push: + branches: + - main + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/ruff.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ruff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.12" + - name: Run ruff check + run: uv run ruff check + - name: Run ruff format --check + run: uv run ruff format --check diff --git a/.github/workflows/spdx.yml b/.github/workflows/spdx.yml new file mode 100644 index 0000000..5203845 --- /dev/null +++ b/.github/workflows/spdx.yml @@ -0,0 +1,33 @@ +name: SPDX Check + +on: + pull_request: + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/spdx.yml' + push: + branches: + - main + paths: + - '**/*.py' + - 'pyproject.toml' + - '.github/workflows/spdx.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-spdx-headers: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: enarx/spdx@d4020ee98e3101dd487c5184f27c6a6fb4f88709 # master + with: + licenses: MIT diff --git a/.gitignore b/.gitignore index 83972fa..01f93b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,21 @@ +# Transpiler output +manifest.json +node__*.nix + +# Exceptions +!examples/*/manifest.json +!examples/*/node__*.nix + +# Nix build results +result +result-* +eif1 +eif2 +nodes + +# System +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/README.md b/README.md index cfb2832..de0c6e0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,144 @@ # NixReflect -A transpiler for mutually-referential reflective programming in Nix + +![SemVer](https://img.shields.io/badge/NixReflect-0.1.0-white) +![Python Version](https://img.shields.io/badge/Python-3.12-blue) +![Nix](https://img.shields.io/badge/Nix-flakes-5277C3) +[![License](https://img.shields.io/badge/License-MIT-red)](/LICENSE) + +A transpiler for mutually-referential reflective programming in **Nix** — the Nix-targeting sibling of [PyReflect](https://github.com/acompany-develop/PyReflect). +The transpiler is implemented in Python and is available as a library (`nixreflect`) and a command-line tool (`nixreflect`); its *output* is a set of mutually-referential standalone `.nix` files. + +## Overview + +NixReflect is a transpiler that makes *mutually-referential* programs — programs made of several nodes — possible. +Every node can reference the source code of itself and of the other nodes, without relying on any external source (e.g. file, stdin, registry). + +Naïvely embedding each node's code inside the others has no solution — it is a fixed-point problem with an infinite regress. +**Kleene's second recursion theorem** dissolves it: instead of the code itself, each node embeds a *code generator* together with its input data, from which the exact code of every node (itself and its peers) can be reconstructed intrinsically. +For the full background, see [PyReflect's README](https://github.com/acompany-develop/PyReflect#readme) — NixReflect is the same construction with Nix as the target language. + +Because the generated nodes are Nix expressions, and Nix builds are deterministic functions of their sources, the reflection extends beyond source code: a node can intrinsically derive any *reproducible build artifact* of its peers — hashes, container layers, or (see below) AWS Nitro Enclave images and their measurements. + +## Install + +```bash +uv pip install git+https://github.com/acompany-develop/NixReflect +``` + +This exposes the `nixreflect` command and the importable `nixreflect` package. +Alternatively, with Nix: + +```bash +nix run .#nixreflect -- TEMPLATE.json OUTPUT_DIR +``` + +## Usage + +### Library API + +```py +from nixreflect import parse_template, transpile + +with open(path, encoding="utf-8") as f: + # Read + text = f.read() + # Parse + template = parse_template(text) + # Transpile + nodes = transpile(template) +``` + +### Transpiler CLI + +```bash +# Input from file +nixreflect TEMPLATE.json OUTPUT_DIR + +# Input from stdin +cat TEMPLATE.json | nixreflect - OUTPUT_DIR +``` + +It writes one file per node (`node_.nix`) with a `manifest.json` mapping node-id to filename. + +## Transpiler details + +The template format, the transformation, and the framework API are the same as [PyReflect's](https://github.com/acompany-develop/PyReflect#readme), with `__nixreflect_*__` in place of `__pyreflect_*__` — see there for the full specification. +The Nix-specific differences: + +- A `code` body must be a single **Nix expression**; it becomes the emitted file's result expression. + Node-id tokens are rewritten into the function application `(__nixreflect_render__ "")`. +- Where PyReflect embeds the template as a Base64 blob decoded at runtime, NixReflect embeds the JSON text directly as a string literal (`__nixreflect_DATA__`) — pure Nix has no Base64 decoder, but `builtins.fromJSON` is built in. + The framework's `__nixreflect_nix_str__` mirrors the transpiler's string-literal escaping exactly, which is what makes the reconstruction byte-exact. + +## Example code + +Generated `.nix` files are evaluated with `nix eval` (any recent Nix with `nix-command` enabled; `nix-instantiate --eval --strict` works too). + +### quine + +Single node that evaluates to its own code. + +```bash +# Transpile +nixreflect examples/quine/template.json examples/quine/ + +# Run Node: Display its own code +nix eval --raw -f examples/quine/node___NODE.nix + +# Verify +nix eval --raw -f examples/quine/node___NODE.nix | diff - examples/quine/node___NODE.nix +``` + +### mutual_quine + +Two nodes, each of which evaluates to the other's *SHA-256 digest*. +The peer value is obtained intrinsically — node 1 reconstructs node 2's source from its own embedded data and hashes it, and vice versa — so node 1's self hash equals the value node 2 reports as its expected peer reference, and vice versa. + +```bash +# Transpile +nixreflect examples/mutual_quine/template.json examples/mutual_quine/ + +# Run Node 1: Display the SHA-256 digest of Node 2 +nix eval --raw -f examples/mutual_quine/node___NODE1.nix; echo +# Run Node 2: Display the SHA-256 digest of Node 1 +nix eval --raw -f examples/mutual_quine/node___NODE2.nix; echo + +# Verify +sha256sum examples/mutual_quine/*.nix +``` + +### trinity_quine + +A variant of `mutual_quine` with three nodes wired into a cycle: node 1 yields node 2's digest, node 2 yields node 3's, node 3 yields node 1's (1 → 2 → 3 → 1). +It demonstrates that the transpiler handles arbitrary n-node reference graphs, not just the symmetric two-node case. + +```bash +# Transpile +nixreflect examples/trinity_quine/template.json examples/trinity_quine/ + +# Run Nodes +nix eval --raw -f examples/trinity_quine/node___NODE1.nix; echo +nix eval --raw -f examples/trinity_quine/node___NODE2.nix; echo +nix eval --raw -f examples/trinity_quine/node___NODE3.nix; echo + +# Verify +sha256sum examples/trinity_quine/*.nix +``` + +### mutual_quine_ne — mutual quine as AWS Nitro Enclaves + +Two **Nitro Enclave images** (EIFs), built reproducibly with [monzo/aws-nitro-util](https://github.com/monzo/aws-nitro-util), each of which *rebuilds the other enclave's image inside itself at runtime* and prints the peer's reference **PCR values** — the measurements the Nitro hypervisor attests — derived intrinsically, with no network and no shared state. + +Unlike the examples above, running this one requires an **EC2 instance with AWS Nitro Enclaves enabled** and ample resources — it builds the EIFs on the host and each enclave rebuilds its peer's EIF in RAM (the tested setups are `m6a.xlarge` for x86\_64 and `m6g.xlarge` for AArch64: 4 vCPUs / 16 GiB, of which 2 vCPUs / 8 GiB go to the enclave). +See the full tutorial in [`examples/mutual_quine_ne/`](examples/mutual_quine_ne/README.md) for the instance prerequisites, host setup, and a step-by-step walk-through. + +## Tests + +All examples are covered end to end by flake checks ([`examples/checks.nix`](examples/checks.nix)), run in CI on every push: + +```bash +nix flake check -L +``` + +For each example this transpiles the template from scratch, asserts the output is byte-identical to the committed node files, evaluates the emitted nodes and asserts their quine property (self-reproduction / peer digests). +For `mutual_quine_ne` the `mutual-quine-ne-verify-*` checks additionally re-run the enclave entrypoint against each image's rootfs and demand that the EIF it reconstructs for its peer matches the peer's actual build — the mutual-quine property is proven without Nitro hardware. diff --git a/examples/checks.nix b/examples/checks.nix new file mode 100644 index 0000000..43b12bf --- /dev/null +++ b/examples/checks.nix @@ -0,0 +1,69 @@ +# E2E checks for the transpiler examples. For each template: +# 1. transpile it with the in-repo transpiler, +# 2. assert the output is byte-identical to the committed node files +# (drift detection), +# 3. evaluate the emitted nodes with nix-instantiate and assert the +# quine property they claim (self-reproduction / peer digests). +# The mutual_quine_ne example has its own, much stronger checks in +# ./mutual_quine_ne/default.nix (EIF reconstruction). +{ pkgs }: +let + # >= 3.12 for PEP 695; keep in sync with mutual_quine_ne/default.nix + transpiled = name: pkgs.runCommand "nixreflect-${name}-transpiled" + { nativeBuildInputs = [ pkgs.python312 ]; } '' + export PYTHONPATH=${../src} + mkdir -p $out + python3 -m nixreflect ${./. + "/${name}/template.json"} $out + ''; + + # nix-instantiate performs pure evaluation only, but still wants writable + # state and cache locations (same trick as mutual_quine_ne/run.sh) + check = name: script: pkgs.runCommand "nixreflect-e2e-${name}" + { nativeBuildInputs = [ pkgs.nix pkgs.jq ]; } '' + export HOME="$TMPDIR/home" XDG_CACHE_HOME="$TMPDIR/cache" \ + NIX_STATE_DIR="$TMPDIR/nix/state" NIX_LOG_DIR="$TMPDIR/nix/log" \ + NIX_CONF_DIR="$TMPDIR/nix/conf" + mkdir -p "$HOME" "$XDG_CACHE_HOME" "$TMPDIR/nix/state" \ + "$TMPDIR/nix/log" "$TMPDIR/nix/conf" + evalRaw() { nix-instantiate --eval --strict --json "$1" | jq -j .; } + digest() { sha256sum "$1" | cut -d" " -f1; } + ${script} + touch $out + ''; + + driftCheck = name: pkgs.runCommand "nixreflect-drift-${name}" { } '' + cd ${transpiled name} + for f in *; do + echo "comparing $f against the committed copy" + diff "$f" ${./. + "/${name}"}/"$f" + done + touch $out + ''; +in +{ + # the node evaluates to its own exact source + example-quine = check "quine" '' + d=${transpiled "quine"} + evalRaw "$d/node___NODE.nix" | cmp - "$d/node___NODE.nix" + ''; + + # each node evaluates to the SHA-256 digest of its peer's source + example-mutual-quine = check "mutual-quine" '' + d=${transpiled "mutual_quine"} + [ "$(evalRaw $d/node___NODE1.nix)" = "$(digest $d/node___NODE2.nix)" ] + [ "$(evalRaw $d/node___NODE2.nix)" = "$(digest $d/node___NODE1.nix)" ] + ''; + + # three nodes in a cycle: 1 -> 2 -> 3 -> 1 + example-trinity-quine = check "trinity-quine" '' + d=${transpiled "trinity_quine"} + [ "$(evalRaw $d/node___NODE1.nix)" = "$(digest $d/node___NODE2.nix)" ] + [ "$(evalRaw $d/node___NODE2.nix)" = "$(digest $d/node___NODE3.nix)" ] + [ "$(evalRaw $d/node___NODE3.nix)" = "$(digest $d/node___NODE1.nix)" ] + ''; + + # the committed example outputs are exactly what the transpiler emits today + transpile-drift-quine = driftCheck "quine"; + transpile-drift-mutual-quine = driftCheck "mutual_quine"; + transpile-drift-trinity-quine = driftCheck "trinity_quine"; +} diff --git a/examples/mutual_quine/manifest.json b/examples/mutual_quine/manifest.json new file mode 100644 index 0000000..82b38de --- /dev/null +++ b/examples/mutual_quine/manifest.json @@ -0,0 +1,4 @@ +{ + "__NODE1": "node___NODE1.nix", + "__NODE2": "node___NODE2.nix" +} \ No newline at end of file diff --git a/examples/mutual_quine/node___NODE1.nix b/examples/mutual_quine/node___NODE1.nix new file mode 100644 index 0000000..dbd6946 --- /dev/null +++ b/examples/mutual_quine/node___NODE1.nix @@ -0,0 +1,28 @@ +# auto-generated mutually-referential attestation node -- do not edit +let + __nixreflect_SELF__ = "__NODE1"; + __nixreflect_DATA__ = "{\"bodies\":{\"__NODE1\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE2\\\")\\n\",\"__NODE2\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE1\\\")\\n\"},\"framework\":\" # ========== NixReflect FRAMEWORK ==========\\n\\n __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__;\\n\\n # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str.\\n __nixreflect_nix_str__ = s: \\\"\\\\\\\"\\\" + builtins.replaceStrings\\n [ \\\"\\\\\\\\\\\" \\\"\\\\\\\"\\\" \\\"\\\\\${\\\" ] [ \\\"\\\\\\\\\\\\\\\\\\\" \\\"\\\\\\\\\\\\\\\"\\\" \\\"\\\\\\\\\\\\\${\\\" ] s + \\\"\\\\\\\"\\\";\\n\\n # Exact source code (string) of node `target`, reconstructed from embedded data.\\n __nixreflect_render__ = target:\\n __nixreflect_blob__.header\\n + \\\" __nixreflect_SELF__ = \\\" + __nixreflect_nix_str__ target + \\\";\\\\n\\\"\\n + \\\" __nixreflect_DATA__ = \\\" + __nixreflect_nix_str__ __nixreflect_DATA__ + \\\";\\\\n\\\"\\n + \\\"\\\\n\\\"\\n + __nixreflect_blob__.framework + \\\"\\\\n\\\"\\n + __nixreflect_blob__.bodies.\${target};\\n\\n __nixreflect_node_ids__ = __nixreflect_blob__.nodes;\\n\\n __nixreflect_self_id__ = __nixreflect_SELF__;\\nin\",\"header\":\"# auto-generated mutually-referential attestation node -- do not edit\\nlet\\n\",\"nodes\":[\"__NODE1\",\"__NODE2\"]}"; + + # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in +# ========== BODY ========== +builtins.hashString "sha256" (__nixreflect_render__ "__NODE2") diff --git a/examples/mutual_quine/node___NODE2.nix b/examples/mutual_quine/node___NODE2.nix new file mode 100644 index 0000000..35ac521 --- /dev/null +++ b/examples/mutual_quine/node___NODE2.nix @@ -0,0 +1,28 @@ +# auto-generated mutually-referential attestation node -- do not edit +let + __nixreflect_SELF__ = "__NODE2"; + __nixreflect_DATA__ = "{\"bodies\":{\"__NODE1\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE2\\\")\\n\",\"__NODE2\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE1\\\")\\n\"},\"framework\":\" # ========== NixReflect FRAMEWORK ==========\\n\\n __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__;\\n\\n # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str.\\n __nixreflect_nix_str__ = s: \\\"\\\\\\\"\\\" + builtins.replaceStrings\\n [ \\\"\\\\\\\\\\\" \\\"\\\\\\\"\\\" \\\"\\\\\${\\\" ] [ \\\"\\\\\\\\\\\\\\\\\\\" \\\"\\\\\\\\\\\\\\\"\\\" \\\"\\\\\\\\\\\\\${\\\" ] s + \\\"\\\\\\\"\\\";\\n\\n # Exact source code (string) of node `target`, reconstructed from embedded data.\\n __nixreflect_render__ = target:\\n __nixreflect_blob__.header\\n + \\\" __nixreflect_SELF__ = \\\" + __nixreflect_nix_str__ target + \\\";\\\\n\\\"\\n + \\\" __nixreflect_DATA__ = \\\" + __nixreflect_nix_str__ __nixreflect_DATA__ + \\\";\\\\n\\\"\\n + \\\"\\\\n\\\"\\n + __nixreflect_blob__.framework + \\\"\\\\n\\\"\\n + __nixreflect_blob__.bodies.\${target};\\n\\n __nixreflect_node_ids__ = __nixreflect_blob__.nodes;\\n\\n __nixreflect_self_id__ = __nixreflect_SELF__;\\nin\",\"header\":\"# auto-generated mutually-referential attestation node -- do not edit\\nlet\\n\",\"nodes\":[\"__NODE1\",\"__NODE2\"]}"; + + # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in +# ========== BODY ========== +builtins.hashString "sha256" (__nixreflect_render__ "__NODE1") diff --git a/examples/mutual_quine/template.json b/examples/mutual_quine/template.json new file mode 100644 index 0000000..a27e29e --- /dev/null +++ b/examples/mutual_quine/template.json @@ -0,0 +1,10 @@ +[ + { + "node-id": "__NODE1", + "code": "# ========== BODY ========== \nbuiltins.hashString \"sha256\" __NODE2" + }, + { + "node-id": "__NODE2", + "code": "# ========== BODY ========== \nbuiltins.hashString \"sha256\" __NODE1" + } +] diff --git a/examples/mutual_quine_ne/README.md b/examples/mutual_quine_ne/README.md new file mode 100644 index 0000000..99cf48b --- /dev/null +++ b/examples/mutual_quine_ne/README.md @@ -0,0 +1,310 @@ +# Mutual Quine as two AWS Nitro Enclaves + +This tutorial builds **two Nitro Enclave image files (EIFs)** such that, at runtime, *each enclave prints the reference PCR values of the other* — without ever talking to the other enclave, the host, or the network. +Everything an enclave needs to know about its peer is embedded in its own image. +It is established by combining: + +- **NixReflect** (this repo) — resolves the mutual-reference fixed point à la Kleene's second recursion theorem, exactly like the `mutual_quine` example in [PyReflect](https://github.com/acompany-develop/PyReflect); and +- **[monzo/aws-nitro-util](https://github.com/monzo/aws-nitro-util)** — builds EIFs *bit-for-bit reproducibly* with Nix, which is what makes "compute the peer's PCRs yourself" meaningful. + +## Background + +In Nitro Enclaves remote attestation, the attester (an enclave) has the **Nitro Security Module (NSM)** issue an **attestation document**. +The verifier first checks the document's authenticity against the **AWS Nitro root CA certificate**, then matches the PCRs it carries against **reference values** to confirm that the expected image is running; PCR0–2 are the measurements (SHA-384 digests) of the EIF. +(See [Humane-RAFW-NE](https://github.com/acompany-develop/Humane-RAFW-NE) for a detailed walk-through of this flow.) +The verifier must therefore hold the attester's reference PCR values in advance. + +Now let several enclaves attest *each other*: each peer must hold the other's reference PCRs. +The naïve hardcoding approach cannot realise this: + +- embedding B's PCR values in A's image changes A's contents, hence A's PCRs; +- that forces an update of the values embedded in B, changing B's PCRs; +- that forces an update of the values embedded in A — *ad infinitum*. + +Existing approaches therefore delegate the problem to a **trusted third party (TTP)** entrusted with recording and delivering each peer's reference PCRs — a *reference value provider* in [RATS (RFC 9334)](https://doi.org/10.17487/RFC9334) terms. + +This example is a **PoC that drops the TTP**: by combining **Kleene's second recursion theorem** with **reproducible builds**, each node *recomputes* its peers' reference PCR values by itself. +An image embeds neither the peer's PCRs nor the peer's image. +Instead, each embeds the *generator* of the Nix source. +The generator reconstructs the exact source of both nodes. +Each enclave builds the peer's EIF from its recomputed source *reproducibly*, and measures the EIF at runtime: + +```mermaid +graph TD + OwnNix["own /app/node.nix"] + --"nix eval (Kleene's trick)"--> PeerNix["peer's node.nix"] + --"reproducible build"--> PeerEif["peer's rootfs → ramdisk → EIF"] + --"eif_build measurement"--> PeerPcrs["peer's reference PCR[0-2]"] + +``` + +## Image layout + +Both EIFs are assembled by `aws-nitro-util` from: + +| EIF section | content | node1 vs node2 | +| --- | --- | --- | +| kernel + cmdline | AWS-provided blob | identical | +| bootstrap ramdisk | `init` (compiled from source) + `nsm.ko` + `/node-id` | **differs in `/node-id`** | +| user ramdisk | `/env`, `/cmd`, `/rootfs/**` | **differs in exactly one file** | + +The user ramdisk's rootfs contains: + +- `/nix/store/**` — the closure of the tool set the enclave needs at runtime (`bash`, `coreutils`, `findutils`, `cpio`, `gzip`, `jq`, `nix`, `eif_build`) plus a `payload` of byte-copies of the EIF build inputs (kernel, kernel config, **both** bootstrap ramdisks, `/env`/`/cmd` texts). + Identical for both. +- `/app/run` — the entrypoint (identical), +- `/app/closure.txt` — the store-path list (identical), +- `/app/node.nix` — the NixReflect-transpiled quine node. + **The only file that differs between the two user ramdisks.** + +Every node-specific artifact (`/app/node.nix`, and which bootstrap ramdisk an image boots) is Quine-derivable, and every other build input is carried byte-for-byte inside each image — so the peer's EIF is a pure function of data each enclave already has. + +At runtime `/app/run`: + +1. evaluates `/app/node.nix` with `nix-instantiate` — the quine yields `{ self, peer, selfSource, peerSource, ... }`; +2. sanity-checks that `selfSource` is byte-identical to its own `/app/node.nix`; +3. re-stages the peer's rootfs (own closure + `peerSource` as `/app/node.nix`); +4. re-packs the user ramdisk with the *same* deterministic `cpio | gzip -n` recipe `aws-nitro-util` uses at build time; +5. re-runs `eif_build` with the same pinned inputs — selecting the *peer's* bootstrap ramdisk from the payload by node id — and prints the resulting `pcr.json`: the peer's reference PCRs. + +Steps 3–5 mirror `rootfsFor` / `mkUserRamdisk` / `mkCpioArchive` / `mkEif` line by line; see the comments in [run.sh](run.sh) and [default.nix](default.nix). + +### Reproducibility + +The whole scheme stands on the EIF build being a *pure, pinned* function: + +- `flake.lock` pins `nixpkgs`, `aws-nitro-util`, and (transitively) the exact derivations of every tool that participates in the rebuild, on the host and inside the enclaves alike. + **Commit `flake.lock` and keep it fixed**: the two images and the in-enclave rebuild must all come from the same lock. +- The very same store paths that build the images are copied *into* the images, so the in-enclave rebuild runs the same `cpio`, `gzip`, and `eif_build` binaries, bit for bit, that the host used. +- `aws-nitro-util` zeroes timestamps and build metadata, making `image.eif` itself deterministic. + +## Prerequisites + +- **Build machine**: any `x86_64-linux` or `aarch64-linux` machine (or VM) with [Nix](https://nixos.org/download) and flakes enabled. + Building on the Nitro EC2 host itself is simplest — the enclave architecture then matches automatically. + On macOS, use a [linux-builder](https://nixos.org/manual/nixpkgs/stable/#sec-darwin-builder). +- **Run machine**: an EC2 instance with Nitro Enclaves enabled. + The instance's architecture must match the EIF's (`x86_64` EIF ⇔ `x86_64` instance). + +### Host setup + +1. Install Nix (multi-user) and enable flakes: + + ```bash + curl -fsSL https://install.determinate.systems/nix | sh -s -- install + # or the official installer: + # sh <(curl -fsSL https://nixos.org/nix/install) --daemon + # echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf + # sudo systemctl restart nix-daemon + ``` + +2. Install Docker, the Nitro Enclaves driver, and `nitro-cli` **v1.4.5, pinned to commit `18a5f6f35f110c0f235f193ae3caff9434d64ee1`** (adapted from [Humane-RAFW-NE's setup scripts](https://github.com/acompany-develop/Humane-RAFW-NE/tree/main/scripts)): + + ```bash + ./scripts/setup-nitro-cli.sh # runs ./scripts/setup-docker.sh as its first step + ``` + + The script is idempotent and leaves a state that survives reboots: the driver auto-loads (`modules-load.d`), `/run/nitro_enclaves` is recreated at boot (`tmpfiles.d`, otherwise `nitro-cli` fails with `E07`), the allocator service is enabled, and `/dev/nitro_enclaves` is opened to the `ne` group — after re-login, no `sudo` is needed for `nitro-cli`. + + It also reserves allocator resources for this demo (the enclaves rebuild an EIF in RAM, so be generous): 8192 MiB / 2 CPUs by default, tunable via `ALLOCATOR_MEMORY_MIB` / `ALLOCATOR_CPU_COUNT` environment variables. + + The host side is deliberately a shell script, not Nix: it manages kernel modules, udev, and systemd units on a foreign distro, which Nix cannot do declaratively outside NixOS. + Everything after this point — building the EIFs and their in-enclave reconstruction — is pure, pinned Nix. + +### Tested environment + +Last verified end to end on **2026-07-29**, on both **x86\_64** and **AArch64**: both enclaves booted on real Nitro hardware, and the PCRs each reconstructed for its peer matched both the peer's build-time `pcr.json` and the values the hypervisor reports (`nitro-cli describe-enclaves --metadata`) for a non-debug run of the peer. +(PCR values are architecture-specific; the sample outputs under [Run](#run) are from the AArch64 host.) + +#### x86\_64 host + +| item | value | +| --- | --- | +| Instance type | `m6a.xlarge` (4 vCPU / 16 GiB RAM; AMD EPYC 7R13, 2 threads/core), **Nitro Enclaves: enabled** | +| AMI | `ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-20260604` (`ami-0e5497a77ef21b5ac`, us-east-2) | +| OS / Kernel | Ubuntu 26.04 LTS / 7.0.0-1006-aws | +| Storage | 64 GiB | +| Enclave allocator | `memory_mib: 8192`, `cpu_count: 2` (2 of the 4 vCPUs reserved for enclaves) | +| nitro-cli | 1.4.5 (commit `18a5f6f35f110c0f235f193ae3caff9434d64ee1`) | +| Docker | 29.1.3 (`docker.io` 29.1.3-0ubuntu4.1) | +| Nix (host) | Determinate Nix 3.21.8 (Nix 2.34.8), flakes enabled | + +#### AArch64 host + +| item | value | +| --- | --- | +| Instance type | `m6g.xlarge` (4 vCPU / 16 GiB RAM; AWS Graviton2, Neoverse-N1, 1 thread/core), **Nitro Enclaves: enabled** | +| AMI | `ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-arm64-server-20260604` (`ami-04d0f56e9ce314a8e`, us-east-2) | +| OS / Kernel | Ubuntu 26.04 LTS / 7.0.0-1006-aws | +| Storage | 64 GiB | +| Enclave allocator | `memory_mib: 8192`, `cpu_count: 2` (2 of the 4 vCPUs reserved for enclaves) | +| nitro-cli | 1.4.5 (commit `18a5f6f35f110c0f235f193ae3caff9434d64ee1`) | +| Docker | 29.1.3 (`docker.io` 29.1.3-0ubuntu4.1) | +| Nix (host) | Determinate Nix 3.21.8 (Nix 2.34.8), flakes enabled | + +#### Enclaves + +Build inputs, pinned by `flake.lock` — these fully determine the images and everything that runs *inside* the enclaves, so they hold on any build machine: + +| input | pinned rev | supplies | +| --- | --- | --- | +| `nixpkgs` | `nixos-unstable` @ `624af66` | Python 3.12.13, which runs the transpiler at build time | +| `nitro-util` | [monzo/aws-nitro-util](https://github.com/monzo/aws-nitro-util) @ `b529ed6` | `mkEif` and friends, `eif_build`, `init`, the AWS kernel/`nsm.ko` blobs | +| `nitro-util/nixpkgs` | `d8fe5e6` | every tool packed into (and re-used inside) the images: Nix 2.18.2, bash 5.2p26, coreutils 9.4, `cpio`, `gzip`, `jq` | + +A different lock — like a different architecture — will produce different (still internally consistent) PCRs; commit `flake.lock` and keep it fixed across the fleet. + +## Run + +### Step 1 — transpilation (optional) + +From the repo root: + +```bash +nix build .#mutual-quine-ne-nodes -o nodes +cat nodes/node___ENCLAVE1.nix +``` + +You can also run the transpiler directly, without Nix: + +```bash +PYTHONPATH=src python3 -m nixreflect examples/mutual_quine_ne/template.json out/ +``` + +Each node evaluates to `{ self, peer, selfSource, peerSource, peerSourceSha256 }`, reconstructed entirely from the JSON blob embedded in the node itself. + +### Step 2 — EIF build + +```bash +nix build .#mutual-quine-ne-eif1 -o eif1 +nix build .#mutual-quine-ne-eif2 -o eif2 + +cat eif1/pcr.json # node1's reference PCRs +cat eif2/pcr.json # node2's reference PCRs +``` + +The first build compiles `eif_build` and `init` from source and downloads the pinned tool closure; subsequent builds are cheap. +Note `nix build` will always reproduce the same `image.eif` and `pcr.json` — that determinism is what the enclaves exploit. + +### Step 3 — hardware-free verification + +The flake ships checks that re-run **the exact enclave entrypoint** against each image's pristine rootfs and compare the PCRs it reconstructs for its peer with the peer's actual build: + +```bash +nix build .#checks.$(nix eval --raw --impure --expr builtins.currentSystem).mutual-quine-ne-verify-1-rebuilds-2 +nix build .#checks.$(nix eval --raw --impure --expr builtins.currentSystem).mutual-quine-ne-verify-2-rebuilds-1 +# or simply: +nix flake check +``` + +If these pass, what the enclaves will print on real hardware is already determined to be correct. + +### Step 4 — run on Nitro Enclaves + +The sample outputs below are actual values measured on the AArch64 host of the [tested environment](#tested-environment) with the committed `flake.lock`; your run reproduces them bit for bit as long as the lock is unchanged and the architecture matches (an x86\_64 run yields different, equally reproducible values). + +**Enclave 1** — boot it and attach to its console: + +```bash +nitro-cli run-enclave \ + --eif-path eif1/image.eif \ + --memory 8192 --cpu-count 2 \ + --debug-mode + +nitro-cli console --enclave-id "$(nitro-cli describe-enclaves | jq -r '.[0].EnclaveID')" +``` + +After boot, enclave 1 evaluates its quine, rebuilds its peer, and prints: + +``` +==[ NixReflect mutual quine -- Nitro Enclave edition ]== +warning: the group 'nixbld' specified in 'build-users-group' does not exist +self: __ENCLAVE1 +peer: __ENCLAVE2 +self-render is byte-identical to /app/node.nix + +==[ reference PCRs for peer enclave __ENCLAVE2 ]== +{ + "HashAlgorithm": "Sha384 { ... }", + "PCR0": "46c149390da0e5d0ec62945ed5d74931b08e257a9dba5168404c8c188d24eea1c21d82523cb316346af4701b24d1ac54", + "PCR1": "dedb7471a4bbc7b6b8716cef81429c270c0ecb6c401e24c7c8c467adee43dc9656077d648e0139f2996bb1e5c5d342f8", + "PCR2": "640ce90e3cdbf9956845284ec1a5cc4c10fcff1a589d35e31256224bad051e1a93b907444386afa2b4bae4d0a20e79c2" +} + +compare against the peer's build-time pcr.json and its (non-debug) attestation document. +``` + +These are exactly `eif2/pcr.json`'s values. +Terminate it: + +```bash +nitro-cli terminate-enclave --all +``` + +**Enclave 2** — same commands with `eif2/image.eif`: + +```bash +nitro-cli run-enclave \ + --eif-path eif2/image.eif \ + --memory 8192 --cpu-count 2 \ + --debug-mode + +nitro-cli console --enclave-id "$(nitro-cli describe-enclaves | jq -r '.[0].EnclaveID')" +``` + +``` +==[ NixReflect mutual quine -- Nitro Enclave edition ]== +warning: the group 'nixbld' specified in 'build-users-group' does not exist +self: __ENCLAVE2 +peer: __ENCLAVE1 +self-render is byte-identical to /app/node.nix + +==[ reference PCRs for peer enclave __ENCLAVE1 ]== +{ + "HashAlgorithm": "Sha384 { ... }", + "PCR0": "25ab88a08497a612730a544d43b7915f833c49cf2f61fbbc99d4b27a578369c3796de241c5c8c7dcc96506515a23abe1", + "PCR1": "d03d2dda4749caa29cc6d866e2d68d6af682b6f8620b80b929c8ddbc898d88f622c260b6fcdd4ee06b10790e490c5099", + "PCR2": "0658d33092eb2744b4110c06f56ec77981793e5ce88cc10fdf888119b2b16f31504c553f5485d12e47ab6ccef1407118" +} + +compare against the peer's build-time pcr.json and its (non-debug) attestation document. +``` + +— and these are exactly `eif1/pcr.json`'s values, the same measurements the Nitro hypervisor reports for a (non-debug) run of enclave 1 (`nitro-cli describe-enclaves --metadata`). +Each enclave derived, from itself alone, exactly the measurements the hypervisor attests for the other. +Note the two enclaves share none of PCR0/1/2 — the node-specific bootstrap ramdisk splits PCR1 as well. + +```bash +nitro-cli terminate-enclave --all +``` + +## Next steps + +The printed values are *reference* PCRs — precisely what you need to verify an **attestation document**. +A natural next step is to connect the two enclaves over vsock, exchange NSM attestation documents, and have each enclave verify the other's document against the PCRs it computed for itself here: mutual remote attestation with no externally provisioned measurement policy at all. + +## Notes + +- **`--debug-mode`** lets you read the console, but the *attestation document* of a debug enclave reports zeroed PCRs. + The console output (the peer's reference values) is unaffected; run without `--debug-mode` for real attestation. +- **Memory**: the enclave copies its tool closure, restages the peer's rootfs and packs an EIF in RAM. + 8 GiB is comfortable; shrink `appEnv` if you need less. +- The runtime rebuild in `run.sh` mirrors `aws-nitro-util`'s `mkUserRamdisk`, `mkCpioArchive` and `mkEif` recipes. + If you bump the `nitro-util` input, re-run `nix flake check` — it will catch any drift between the two. +- `verify` also byte-compares the reconstructed `image.eif` with the built one. + PCR equality is the load-bearing check; EIF metadata is not measured. + +## References + +- Acompany Co., Ltd., *PyReflect*, GitHub repository. + +- Acompany Co., Ltd., *Humane-RAFW-NE*, GitHub repository. + +- AWS. *User Guide - AWS Nitro Enclaves*. + +- D.-P. Dornseifer and B. Liderman, *Verify enclave counterparties with reproducible builds and cryptographic attestation using AWS Nitro Enclaves*, AWS Web3 Blog. + +- IETF, *RFC 9334: Remote ATtestation procedureS (RATS) Architecture*. + +- Monzo. *AWS Nitro utilities*, GitHub repository. + diff --git a/examples/mutual_quine_ne/default.nix b/examples/mutual_quine_ne/default.nix new file mode 100644 index 0000000..572fd44 --- /dev/null +++ b/examples/mutual_quine_ne/default.nix @@ -0,0 +1,144 @@ +# NixReflect mutual quine on AWS Nitro Enclaves. +# +# Builds two enclave images whose node-specific parts are /app/node.nix (user +# ramdisk) and /node-id (bootstrap ramdisk); every other build input is +# byte-identical between them, so each enclave can deterministically +# reconstruct the *other* image -- and therefore its reference PCRs -- from +# data embedded in itself alone. +{ nitro, eifBuild, eifInit, pkgs ? nitro.pkgs }: +# `pkgs` defaults to nitro-util's own nixpkgs so that the tools packed *into* +# the enclaves are the very same store paths aws-nitro-util uses to pack the +# ramdisks at build time -- a prerequisite for the in-enclave rebuild to be +# byte-exact. +let + arch = pkgs.stdenv.hostPlatform.uname.processor; + + eifName = "mutual-quine-ne"; + eifVersion = "0.1.0"; + # must stay identical between mkEif below and run.sh's runtime rebuild + cmdline = "reboot=k panic=30 pci=off nomodules console=ttyS0 random.trust_cpu=on root=/dev/ram0"; + + # transpile the template with NixReflect at build time (>= 3.12 for PEP 695) + nodes = pkgs.runCommand "nixreflect-mq-ne-nodes" + { nativeBuildInputs = [ pkgs.python312 ]; } '' + export PYTHONPATH=${../../src} + mkdir -p $out + python3 -m nixreflect ${./template.json} $out + ''; + + nodeId1 = "__ENCLAVE1"; + nodeId2 = "__ENCLAVE2"; + + # bootstrap ramdisk (measured into PCR1): init + nsm.ko as in + # nitro.mkSysRamdisk, plus /node-id to make the measurement node-specific + sysRamdiskFor = nodeId: nitro.mkCpioArchive { + name = "bootstrap-initramfs-${nodeId}"; + src = pkgs.runCommand "bootstrap-initramfs-${nodeId}-fs" { } '' + mkdir -p $out/dev + cp ${nitro.blobs.${arch}.nsmKo} $out/nsm.ko + cp ${eifInit + "/bin/init"} $out/init + echo ${nodeId} > $out/node-id + ''; + }; + sysRamdisk1 = sysRamdiskFor nodeId1; + sysRamdisk2 = sysRamdiskFor nodeId2; + + # every tool the in-enclave reconstruction needs, identical for both enclaves + appEnv = pkgs.buildEnv { + name = "mq-ne-tools"; + paths = [ + pkgs.bash + pkgs.coreutils + pkgs.findutils + pkgs.diffutils + pkgs.cpio + pkgs.gzip + pkgs.jq + pkgs.nix + eifBuild + ]; + }; + + env = "PATH=${appEnv}/bin"; + entrypoint = "/app/run"; + + # byte-copies of every EIF build input, identical for both enclaves (hence + # both bootstrap ramdisks); the runtime rebuild must never depend on a + # peer-specific store path + payload = pkgs.runCommand "mq-ne-payload" { } '' + mkdir -p $out + cp ${nitro.blobs.${arch}.kernel} $out/kernel + cp ${nitro.blobs.${arch}.kernelConfig} $out/kernel-config + cp ${sysRamdisk1} $out/sys-initramfs-${nodeId1}.cpio.gz + cp ${sysRamdisk2} $out/sys-initramfs-${nodeId2}.cpio.gz + cp ${pkgs.writeText "mq-ne-env" env} $out/env + cp ${pkgs.writeText "mq-ne-cmd" entrypoint} $out/cmd + ''; + + runScript = pkgs.writeShellScript "mq-ne-run" (builtins.replaceStrings + [ "@appEnv@" "@payload@" "@arch@" "@cmdline@" "@eifName@" "@eifVersion@" ] + [ "${appEnv}" "${payload}" arch cmdline eifName eifVersion ] + (builtins.readFile ./run.sh)); + + # /app/closure.txt lists every store path inside the rootfs. The list cannot + # itself live in the store (a store path cannot contain its own hash), so it + # is copied out to a plain file. + closureList = pkgs.closureInfo { rootPaths = [ appEnv payload ]; }; + + # run.sh step 2 mirrors this derivation -- keep them in sync + rootfsFor = label: nodeFile: pkgs.runCommand "mq-ne-rootfs-${label}" { } '' + mkdir -p $out/nix/store $out/app + for p in $(cat ${closureList}/store-paths); do + cp -r $p $out/nix/store/ + done + cp ${closureList}/store-paths $out/app/closure.txt + cp ${runScript} $out/app/run + cp ${nodeFile} $out/app/node.nix + ''; + + eifFor = sysRamdisk: rootfs: nitro.mkEif { + name = eifName; + version = eifVersion; + inherit arch cmdline; + kernel = nitro.blobs.${arch}.kernel; + kernelConfig = nitro.blobs.${arch}.kernelConfig; + ramdisks = [ + sysRamdisk + (nitro.mkUserRamdisk { inherit env entrypoint rootfs; }) + ]; + }; + + rootfs1 = rootfsFor "node1" "${nodes}/node_${nodeId1}.nix"; + rootfs2 = rootfsFor "node2" "${nodes}/node_${nodeId2}.nix"; + eif1 = eifFor sysRamdisk1 rootfs1; + eif2 = eifFor sysRamdisk2 rootfs2; + + # Re-run the exact enclave entrypoint against a node's pristine rootfs and + # demand that the EIF it reconstructs for its peer matches the EIF Nix built + # for that peer. This proves the mutual-quine property without Nitro hardware. + verifyFor = label: rootfs: peerEif: pkgs.runCommand "mq-ne-verify-${label}" { } '' + export NIXREFLECT_ROOT=${rootfs} + export NIXREFLECT_OUT=$PWD/out + export NIXREFLECT_HOLD=0 + ${runScript} + + echo "--- comparing runtime-reconstructed PCRs against ${peerEif}/pcr.json" + diff out/peer-pcr.json ${peerEif}/pcr.json + echo "PCRs match." + if cmp -s out/peer.eif ${peerEif}/image.eif; then + echo "EIF is even byte-for-byte identical." + else + echo "note: EIF bytes differ (unmeasured metadata only); the PCRs above are what count." + fi + touch $out + ''; +in +{ + inherit nodes appEnv payload runScript; + rootfs = { node1 = rootfs1; node2 = rootfs2; }; + eifs = { node1 = eif1; node2 = eif2; }; + verify = { + node1 = verifyFor "node1" rootfs1 eif2; + node2 = verifyFor "node2" rootfs2 eif1; + }; +} diff --git a/examples/mutual_quine_ne/run.sh b/examples/mutual_quine_ne/run.sh new file mode 100644 index 0000000..0e0c512 --- /dev/null +++ b/examples/mutual_quine_ne/run.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# +# NixReflect mutual quine, Nitro Enclave edition -- enclave entrypoint. +# +# Runs inside the enclave (NIXREFLECT_ROOT unset, i.e. against /) or inside the +# `verify` derivation sandbox (NIXREFLECT_ROOT=). +# Reconstructs the peer's EIF byte-for-byte from data embedded in this image +# alone -- no network, no files from outside -- and prints the peer's +# reference PCR values. +# +# @tokens@ are substituted by default.nix; every substituted store path is +# identical between the two enclave images. The images differ only in +# /app/node.nix (user ramdisk) and /node-id (bootstrap ramdisk), both +# derivable for the peer via the Quine framework. +set -eu +umask 0022 +export LC_ALL=C + +ROOT="${NIXREFLECT_ROOT:-}" +OUT="${NIXREFLECT_OUT:-/tmp/nixreflect}" +HOLD="${NIXREFLECT_HOLD:-1}" + +export PATH="@appEnv@/bin" + +work="$(mktemp -d)" +mkdir -p "$OUT" + +# nix only evaluates a single self-contained file (nothing is built or +# fetched), but it still wants writable state and cache locations. +export HOME="$work/home" +export XDG_CACHE_HOME="$work/cache" +export NIX_STATE_DIR="$work/nix/state" +export NIX_LOG_DIR="$work/nix/log" +export NIX_CONF_DIR="$work/nix/conf" +mkdir -p "$HOME" "$XDG_CACHE_HOME" "$NIX_STATE_DIR" "$NIX_LOG_DIR" "$NIX_CONF_DIR" + +echo "==[ NixReflect mutual quine -- Nitro Enclave edition ]==" + +# 1. Evaluate our own node file (a Nix Quine). +nix-instantiate --eval --strict --json "$ROOT/app/node.nix" > "$work/eval.json" +SELF_ID="$(jq -r .self "$work/eval.json")" +PEER_ID="$(jq -r .peer "$work/eval.json")" +echo "self: $SELF_ID" +echo "peer: $PEER_ID" + +# -j, not -r: the rendered sources already end in a newline. +jq -j .selfSource "$work/eval.json" > "$work/node-self.nix" +jq -j .peerSource "$work/eval.json" > "$work/node-peer.nix" + +# Quine sanity check: the source this image reconstructs for itself must be +# exactly the file it reconstructs it from. +cmp "$work/node-self.nix" "$ROOT/app/node.nix" +echo "self-render is byte-identical to /app/node.nix" + +# 2. Reconstruct the peer's root filesystem. +# Mirrors the rootfsFor derivation in default.nix: closure and /app are +# identical between the two enclaves except for /app/node.nix. +user="$work/user" +rfs="$user/rootfs" +mkdir -p "$rfs/nix/store" "$rfs/app" +for p in $(cat "$ROOT/app/closure.txt"); do + cp -r "$p" "$rfs/nix/store/" +done +cp "$ROOT/app/closure.txt" "$rfs/app/closure.txt" +cp "$ROOT/app/run" "$rfs/app/run" +cp "$work/node-peer.nix" "$rfs/app/node.nix" + +# 3. Reconstruct the peer's user ramdisk. +# Mirrors nitro.lib.mkUserRamdisk + nitro.lib.mkCpioArchive. +cp "@payload@/env" "$user/env" +cp "@payload@/cmd" "$user/cmd" +(cd "$rfs" && mkdir -p dev run sys var proc tmp || true) + +# At build time the ramdisk gets packed from a Nix store path, which the store +# has canonicalised: no write bits anywhere, every mtime set to 1. +chmod -R a-w "$user" +find "$user" -exec touch -h --date=@1 {} + +(cd "$user" && find * .[^.*] -print0 | sort -z | cpio -o -H newc -R +0:+0 --reproducible --null | gzip -n > "$OUT/peer-user-initramfs.cpio.gz") 2> /dev/null + +# 4. Rebuild the peer's EIF and measure it. Mirrors nitro.lib.mkEif. +cd "$work" +eif_build \ + --arch @arch@ \ + --kernel @payload@/kernel \ + --kernel_config @payload@/kernel-config \ + --cmdline "@cmdline@" \ + --ramdisk "@payload@/sys-initramfs-$PEER_ID.cpio.gz" \ + --ramdisk "$OUT/peer-user-initramfs.cpio.gz" \ + --name @eifName@ \ + --version @eifVersion@ \ + --build-tool='monzo-aws-nitro-util' --build-time='1970-01-01T00:00:00.000000+00:00' \ + --output "$OUT/peer.eif" >> log.txt +cat log.txt | tail -6 >> "$OUT/peer-pcr.json" + +echo +echo "==[ reference PCRs for peer enclave $PEER_ID ]==" +cat "$OUT/peer-pcr.json" +echo +echo "compare against the peer's build-time pcr.json and its (non-debug) attestation document." + +if [ "$HOLD" = "1" ]; then + # keep the enclave alive so the console output can be read at leisure + while true; do sleep 3600; done +fi diff --git a/examples/mutual_quine_ne/scripts/setup-docker.sh b/examples/mutual_quine_ne/scripts/setup-docker.sh new file mode 100755 index 0000000..acb9f3d --- /dev/null +++ b/examples/mutual_quine_ne/scripts/setup-docker.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# +# Install Docker on Ubuntu from the distro archive (docker.io). +# Docker is a build-time dependency of nitro-cli >= 1.4: `make nitro-cli` +# compiles the CLI inside a container. +# +# Idempotent: safe to re-run. Adapted from +# https://github.com/acompany-develop/Humane-RAFW-NE/blob/main/scripts/setup-docker.sh + +set -euo pipefail + +if command -v docker > /dev/null; then + echo "docker is already installed: $(docker --version)" +else + sudo apt-get update + sudo apt-get install -y docker.io +fi + +sudo systemctl enable --now docker + +# let the invoking user run docker without sudo (takes effect on next login) +if ! id -nG "$USER" | grep -qw docker; then + sudo usermod -aG docker "$USER" + echo "added $USER to the docker group -- log out and back in for it to apply" +fi + +docker --version diff --git a/examples/mutual_quine_ne/scripts/setup-nitro-cli.sh b/examples/mutual_quine_ne/scripts/setup-nitro-cli.sh new file mode 100755 index 0000000..db373c3 --- /dev/null +++ b/examples/mutual_quine_ne/scripts/setup-nitro-cli.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# +# Host setup for AWS Nitro Enclaves on Ubuntu (tested on Ubuntu 26.04 on a +# Nitro-Enclaves-enabled EC2 instance). Installs the nitro_enclaves kernel +# driver, nitro-cli v1.4.5 (pinned by commit hash) and the enclave resource +# allocator, and makes all of it survive reboots. +# +# Idempotent: safe to re-run; completed steps are skipped. +# Adapted from +# https://github.com/acompany-develop/Humane-RAFW-NE/blob/main/scripts/setup-nitro-cli.sh +# +# Why a shell script and not Nix: the host side is kernel-module + udev + +# systemd state on a foreign (non-NixOS) distro, which Nix cannot manage +# declaratively. Everything downstream of this script -- building the EIFs and +# their in-enclave reconstruction -- is pure Nix; see ../default.nix. +# +# Tunables (env vars): +# ALLOCATOR_MEMORY_MIB memory reserved for enclaves (default 8192 -- the +# mutual_quine_ne enclaves rebuild an EIF in RAM) +# ALLOCATOR_CPU_COUNT CPUs reserved for enclaves (default 2) + +set -euo pipefail + +NITRO_CLI_VERSION="1.4.5" +NITRO_CLI_COMMIT="18a5f6f35f110c0f235f193ae3caff9434d64ee1" # v1.4.5 +ALLOCATOR_MEMORY_MIB="${ALLOCATOR_MEMORY_MIB:-8192}" +ALLOCATOR_CPU_COUNT="${ALLOCATOR_CPU_COUNT:-2}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" +KERNEL_VERSION="$(uname -r)" + +# --- 1. build dependencies ------------------------------------------------- +sudo apt-get update +sudo apt-get install -y build-essential git jq + +# docker is required since nitro-cli v1.4: `make nitro-cli` builds in a container +if [ -x "$SCRIPT_DIR/setup-docker.sh" ]; then + "$SCRIPT_DIR/setup-docker.sh" +else + command -v docker > /dev/null || sudo apt-get install -y docker.io + sudo systemctl enable --now docker +fi + +# --- 2. nitro_enclaves kernel driver ---------------------------------------- +# Recent Ubuntu kernels ship the driver in-tree; older AWS kernels carry it in +# linux-modules-extra. Building it out of tree is a last resort. +if ! sudo modprobe nitro_enclaves 2> /dev/null; then + sudo apt-get install -y linux-modules-extra-aws \ + || sudo apt-get install -y "linux-modules-extra-$KERNEL_VERSION" \ + || true + sudo modprobe nitro_enclaves 2> /dev/null || NEED_DRIVER_BUILD=1 +fi + +# load the driver on every boot +echo nitro_enclaves | sudo tee /etc/modules-load.d/nitro_enclaves.conf > /dev/null + +# --- 3. nitro-cli v1.4.5 (pinned) ------------------------------------------- +installed_version="$(nitro-cli --version 2> /dev/null | awk '{print $3}' || true)" +if [ "$installed_version" = "$NITRO_CLI_VERSION" ] && [ -z "${NEED_DRIVER_BUILD:-}" ]; then + echo "nitro-cli $NITRO_CLI_VERSION already installed; skipping build" +else + # Rust toolchain (host-side vsock-proxy build) + if ! command -v cargo > /dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + fi + # rustup's env script references unset vars; disable -u around it + set +u; source "$HOME/.cargo/env"; set -u + + if [ ! -d "$HOME/aws-nitro-enclaves-cli" ]; then + git clone https://github.com/aws/aws-nitro-enclaves-cli "$HOME/aws-nitro-enclaves-cli" + fi + pushd "$HOME/aws-nitro-enclaves-cli" + git fetch --all --quiet + git checkout --quiet "$NITRO_CLI_COMMIT" + + # out-of-tree driver build, only as a last resort (step 2 failed) + if [ -n "${NEED_DRIVER_BUILD:-}" ]; then + pushd drivers/virt/nitro_enclaves + sudo make + sudo install -D nitro_enclaves.ko \ + "/usr/lib/modules/$KERNEL_VERSION/extra/nitro_enclaves/nitro_enclaves.ko" + sudo depmod -a + sudo modprobe nitro_enclaves + popd + fi + + if [ "$installed_version" != "$NITRO_CLI_VERSION" ]; then + sudo make nitro-cli + sudo make vsock-proxy + sudo make NITRO_CLI_INSTALL_DIR=/ install + fi + popd +fi + +# --- 4. device permissions & runtime directories ---------------------------- +# Upstream's `nitro-cli-config -i` does this interactively; do it declaratively +# so it holds across reboots. +getent group ne > /dev/null || sudo groupadd ne +id -nG "$USER" | grep -qw ne || { + sudo usermod -aG ne "$USER" + echo "added $USER to the ne group -- log out and back in for it to apply" +} + +# /dev/nitro_enclaves is root-only by default +sudo tee /etc/udev/rules.d/99-nitro-enclaves.rules > /dev/null << 'EOF' +SUBSYSTEM=="misc", KERNEL=="nitro_enclaves", GROUP="ne", MODE="0660" +EOF +sudo udevadm control --reload-rules +sudo udevadm trigger --name-match=nitro_enclaves 2> /dev/null || true + +# /run is tmpfs: without this entry nitro-cli fails with E07 after every reboot +sudo tee /etc/tmpfiles.d/nitro_enclaves.conf > /dev/null << 'EOF' +d /run/nitro_enclaves 2775 root ne - +d /var/log/nitro_enclaves 2775 root ne - +EOF +sudo systemd-tmpfiles --create /etc/tmpfiles.d/nitro_enclaves.conf + +# --- 5. enclave resource allocator ------------------------------------------ +sudo sed -i \ + -e "s/^memory_mib:.*/memory_mib: $ALLOCATOR_MEMORY_MIB/" \ + -e "s/^cpu_count:.*/cpu_count: $ALLOCATOR_CPU_COUNT/" \ + /etc/nitro_enclaves/allocator.yaml +sudo systemctl enable --now nitro-enclaves-allocator.service +sudo systemctl restart nitro-enclaves-allocator.service + +# --- 6. smoke test ----------------------------------------------------------- +nitro-cli --version +sudo nitro-cli describe-enclaves > /dev/null +echo +echo "OK: nitro-cli $NITRO_CLI_VERSION ready; allocator reserves ${ALLOCATOR_MEMORY_MIB} MiB / ${ALLOCATOR_CPU_COUNT} CPUs." +echo "Run 'newgrp ne' (or re-login) to use nitro-cli without sudo." diff --git a/examples/mutual_quine_ne/template.json b/examples/mutual_quine_ne/template.json new file mode 100644 index 0000000..3384e02 --- /dev/null +++ b/examples/mutual_quine_ne/template.json @@ -0,0 +1,10 @@ +[ + { + "node-id": "__ENCLAVE1", + "code": "# ========== BODY ========== \nlet\n peers = builtins.filter (n: n != __nixreflect_self_id__) __nixreflect_node_ids__;\n peer = builtins.head peers;\nin\n{\n self = __nixreflect_self_id__;\n inherit peer;\n selfSource = __nixreflect_render__ __nixreflect_self_id__;\n peerSource = __nixreflect_render__ peer;\n peerSourceSha256 = builtins.hashString \"sha256\" (__nixreflect_render__ peer);\n}" + }, + { + "node-id": "__ENCLAVE2", + "code": "# ========== BODY ========== \nlet\n peers = builtins.filter (n: n != __nixreflect_self_id__) __nixreflect_node_ids__;\n peer = builtins.head peers;\nin\n{\n self = __nixreflect_self_id__;\n inherit peer;\n selfSource = __nixreflect_render__ __nixreflect_self_id__;\n peerSource = __nixreflect_render__ peer;\n peerSourceSha256 = builtins.hashString \"sha256\" (__nixreflect_render__ peer);\n}" + } +] diff --git a/examples/quine/manifest.json b/examples/quine/manifest.json new file mode 100644 index 0000000..fa10436 --- /dev/null +++ b/examples/quine/manifest.json @@ -0,0 +1,3 @@ +{ + "__NODE": "node___NODE.nix" +} \ No newline at end of file diff --git a/examples/quine/node___NODE.nix b/examples/quine/node___NODE.nix new file mode 100644 index 0000000..d121319 --- /dev/null +++ b/examples/quine/node___NODE.nix @@ -0,0 +1,28 @@ +# auto-generated mutually-referential attestation node -- do not edit +let + __nixreflect_SELF__ = "__NODE"; + __nixreflect_DATA__ = "{\"bodies\":{\"__NODE\":\"# ========== BODY ========== \\n(__nixreflect_render__ \\\"__NODE\\\")\\n\"},\"framework\":\" # ========== NixReflect FRAMEWORK ==========\\n\\n __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__;\\n\\n # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str.\\n __nixreflect_nix_str__ = s: \\\"\\\\\\\"\\\" + builtins.replaceStrings\\n [ \\\"\\\\\\\\\\\" \\\"\\\\\\\"\\\" \\\"\\\\\${\\\" ] [ \\\"\\\\\\\\\\\\\\\\\\\" \\\"\\\\\\\\\\\\\\\"\\\" \\\"\\\\\\\\\\\\\${\\\" ] s + \\\"\\\\\\\"\\\";\\n\\n # Exact source code (string) of node `target`, reconstructed from embedded data.\\n __nixreflect_render__ = target:\\n __nixreflect_blob__.header\\n + \\\" __nixreflect_SELF__ = \\\" + __nixreflect_nix_str__ target + \\\";\\\\n\\\"\\n + \\\" __nixreflect_DATA__ = \\\" + __nixreflect_nix_str__ __nixreflect_DATA__ + \\\";\\\\n\\\"\\n + \\\"\\\\n\\\"\\n + __nixreflect_blob__.framework + \\\"\\\\n\\\"\\n + __nixreflect_blob__.bodies.\${target};\\n\\n __nixreflect_node_ids__ = __nixreflect_blob__.nodes;\\n\\n __nixreflect_self_id__ = __nixreflect_SELF__;\\nin\",\"header\":\"# auto-generated mutually-referential attestation node -- do not edit\\nlet\\n\",\"nodes\":[\"__NODE\"]}"; + + # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in +# ========== BODY ========== +(__nixreflect_render__ "__NODE") diff --git a/examples/quine/template.json b/examples/quine/template.json new file mode 100644 index 0000000..2278a72 --- /dev/null +++ b/examples/quine/template.json @@ -0,0 +1,6 @@ +[ + { + "node-id": "__NODE", + "code": "# ========== BODY ========== \n__NODE" + } +] diff --git a/examples/trinity_quine/manifest.json b/examples/trinity_quine/manifest.json new file mode 100644 index 0000000..fac2909 --- /dev/null +++ b/examples/trinity_quine/manifest.json @@ -0,0 +1,5 @@ +{ + "__NODE1": "node___NODE1.nix", + "__NODE2": "node___NODE2.nix", + "__NODE3": "node___NODE3.nix" +} \ No newline at end of file diff --git a/examples/trinity_quine/node___NODE1.nix b/examples/trinity_quine/node___NODE1.nix new file mode 100644 index 0000000..f4d93af --- /dev/null +++ b/examples/trinity_quine/node___NODE1.nix @@ -0,0 +1,28 @@ +# auto-generated mutually-referential attestation node -- do not edit +let + __nixreflect_SELF__ = "__NODE1"; + __nixreflect_DATA__ = "{\"bodies\":{\"__NODE1\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE2\\\")\\n\",\"__NODE2\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE3\\\")\\n\",\"__NODE3\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE1\\\")\\n\"},\"framework\":\" # ========== NixReflect FRAMEWORK ==========\\n\\n __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__;\\n\\n # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str.\\n __nixreflect_nix_str__ = s: \\\"\\\\\\\"\\\" + builtins.replaceStrings\\n [ \\\"\\\\\\\\\\\" \\\"\\\\\\\"\\\" \\\"\\\\\${\\\" ] [ \\\"\\\\\\\\\\\\\\\\\\\" \\\"\\\\\\\\\\\\\\\"\\\" \\\"\\\\\\\\\\\\\${\\\" ] s + \\\"\\\\\\\"\\\";\\n\\n # Exact source code (string) of node `target`, reconstructed from embedded data.\\n __nixreflect_render__ = target:\\n __nixreflect_blob__.header\\n + \\\" __nixreflect_SELF__ = \\\" + __nixreflect_nix_str__ target + \\\";\\\\n\\\"\\n + \\\" __nixreflect_DATA__ = \\\" + __nixreflect_nix_str__ __nixreflect_DATA__ + \\\";\\\\n\\\"\\n + \\\"\\\\n\\\"\\n + __nixreflect_blob__.framework + \\\"\\\\n\\\"\\n + __nixreflect_blob__.bodies.\${target};\\n\\n __nixreflect_node_ids__ = __nixreflect_blob__.nodes;\\n\\n __nixreflect_self_id__ = __nixreflect_SELF__;\\nin\",\"header\":\"# auto-generated mutually-referential attestation node -- do not edit\\nlet\\n\",\"nodes\":[\"__NODE1\",\"__NODE2\",\"__NODE3\"]}"; + + # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in +# ========== BODY ========== +builtins.hashString "sha256" (__nixreflect_render__ "__NODE2") diff --git a/examples/trinity_quine/node___NODE2.nix b/examples/trinity_quine/node___NODE2.nix new file mode 100644 index 0000000..f6e4fe1 --- /dev/null +++ b/examples/trinity_quine/node___NODE2.nix @@ -0,0 +1,28 @@ +# auto-generated mutually-referential attestation node -- do not edit +let + __nixreflect_SELF__ = "__NODE2"; + __nixreflect_DATA__ = "{\"bodies\":{\"__NODE1\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE2\\\")\\n\",\"__NODE2\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE3\\\")\\n\",\"__NODE3\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE1\\\")\\n\"},\"framework\":\" # ========== NixReflect FRAMEWORK ==========\\n\\n __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__;\\n\\n # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str.\\n __nixreflect_nix_str__ = s: \\\"\\\\\\\"\\\" + builtins.replaceStrings\\n [ \\\"\\\\\\\\\\\" \\\"\\\\\\\"\\\" \\\"\\\\\${\\\" ] [ \\\"\\\\\\\\\\\\\\\\\\\" \\\"\\\\\\\\\\\\\\\"\\\" \\\"\\\\\\\\\\\\\${\\\" ] s + \\\"\\\\\\\"\\\";\\n\\n # Exact source code (string) of node `target`, reconstructed from embedded data.\\n __nixreflect_render__ = target:\\n __nixreflect_blob__.header\\n + \\\" __nixreflect_SELF__ = \\\" + __nixreflect_nix_str__ target + \\\";\\\\n\\\"\\n + \\\" __nixreflect_DATA__ = \\\" + __nixreflect_nix_str__ __nixreflect_DATA__ + \\\";\\\\n\\\"\\n + \\\"\\\\n\\\"\\n + __nixreflect_blob__.framework + \\\"\\\\n\\\"\\n + __nixreflect_blob__.bodies.\${target};\\n\\n __nixreflect_node_ids__ = __nixreflect_blob__.nodes;\\n\\n __nixreflect_self_id__ = __nixreflect_SELF__;\\nin\",\"header\":\"# auto-generated mutually-referential attestation node -- do not edit\\nlet\\n\",\"nodes\":[\"__NODE1\",\"__NODE2\",\"__NODE3\"]}"; + + # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in +# ========== BODY ========== +builtins.hashString "sha256" (__nixreflect_render__ "__NODE3") diff --git a/examples/trinity_quine/node___NODE3.nix b/examples/trinity_quine/node___NODE3.nix new file mode 100644 index 0000000..8d1b700 --- /dev/null +++ b/examples/trinity_quine/node___NODE3.nix @@ -0,0 +1,28 @@ +# auto-generated mutually-referential attestation node -- do not edit +let + __nixreflect_SELF__ = "__NODE3"; + __nixreflect_DATA__ = "{\"bodies\":{\"__NODE1\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE2\\\")\\n\",\"__NODE2\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE3\\\")\\n\",\"__NODE3\":\"# ========== BODY ========== \\nbuiltins.hashString \\\"sha256\\\" (__nixreflect_render__ \\\"__NODE1\\\")\\n\"},\"framework\":\" # ========== NixReflect FRAMEWORK ==========\\n\\n __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__;\\n\\n # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str.\\n __nixreflect_nix_str__ = s: \\\"\\\\\\\"\\\" + builtins.replaceStrings\\n [ \\\"\\\\\\\\\\\" \\\"\\\\\\\"\\\" \\\"\\\\\${\\\" ] [ \\\"\\\\\\\\\\\\\\\\\\\" \\\"\\\\\\\\\\\\\\\"\\\" \\\"\\\\\\\\\\\\\${\\\" ] s + \\\"\\\\\\\"\\\";\\n\\n # Exact source code (string) of node `target`, reconstructed from embedded data.\\n __nixreflect_render__ = target:\\n __nixreflect_blob__.header\\n + \\\" __nixreflect_SELF__ = \\\" + __nixreflect_nix_str__ target + \\\";\\\\n\\\"\\n + \\\" __nixreflect_DATA__ = \\\" + __nixreflect_nix_str__ __nixreflect_DATA__ + \\\";\\\\n\\\"\\n + \\\"\\\\n\\\"\\n + __nixreflect_blob__.framework + \\\"\\\\n\\\"\\n + __nixreflect_blob__.bodies.\${target};\\n\\n __nixreflect_node_ids__ = __nixreflect_blob__.nodes;\\n\\n __nixreflect_self_id__ = __nixreflect_SELF__;\\nin\",\"header\":\"# auto-generated mutually-referential attestation node -- do not edit\\nlet\\n\",\"nodes\":[\"__NODE1\",\"__NODE2\",\"__NODE3\"]}"; + + # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in +# ========== BODY ========== +builtins.hashString "sha256" (__nixreflect_render__ "__NODE1") diff --git a/examples/trinity_quine/template.json b/examples/trinity_quine/template.json new file mode 100644 index 0000000..1f004b9 --- /dev/null +++ b/examples/trinity_quine/template.json @@ -0,0 +1,14 @@ +[ + { + "node-id": "__NODE1", + "code": "# ========== BODY ========== \nbuiltins.hashString \"sha256\" __NODE2" + }, + { + "node-id": "__NODE2", + "code": "# ========== BODY ========== \nbuiltins.hashString \"sha256\" __NODE3" + }, + { + "node-id": "__NODE3", + "code": "# ========== BODY ========== \nbuiltins.hashString \"sha256\" __NODE1" + } +] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0d939e9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,130 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nitro-util": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1780060305, + "narHash": "sha256-aG3Nc7c4Ltxk3o02oJWzCq33LHHw1GLjluMg9xfNLuY=", + "owner": "monzo", + "repo": "aws-nitro-util", + "rev": "b529ed6299a49ebe362d3cf618b21d6dac4a2e48", + "type": "github" + }, + "original": { + "owner": "monzo", + "repo": "aws-nitro-util", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1711703276, + "narHash": "sha256-iMUFArF0WCatKK6RzfUJknjem0H9m4KgorO/p3Dopkk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d8fe5e6c92d0d190646fb9f1056741a229980089", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1785090369, + "narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "624af665418d3c65d544145b4d34ad696439570e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nitro-util": "nitro-util", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..600d6f3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,59 @@ +{ + description = "NixReflect -- a transpiler for mutually-referential reflective programming in Nix"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + # NOTE: nitro-util's own nixpkgs pin is deliberately NOT overridden with + # `follows`: the mutual_quine_ne example takes every tool that ends up + # inside the enclaves from nitro-util's pkgs, so the binaries that pack the + # ramdisks at build time and the binaries that re-pack them inside the + # enclaves are the exact same store paths. + nitro-util.url = "github:monzo/aws-nitro-util"; + }; + + outputs = { self, nixpkgs, flake-utils, nitro-util }: + nixpkgs.lib.recursiveUpdate + + # the transpiler itself runs anywhere + (flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + nixreflect = pkgs.python3Packages.buildPythonApplication { + pname = "nixreflect"; + version = "0.1.0"; + pyproject = true; + src = ./.; + build-system = [ pkgs.python3Packages.hatchling ]; + }; + in + { + packages.default = nixreflect; + packages.nixreflect = nixreflect; + devShells.default = pkgs.mkShell { packages = [ pkgs.python3 ]; }; + # transpile every example and assert its quine property end to end + checks = import ./examples/checks.nix { inherit pkgs; }; + })) + + # EIFs boot Linux kernels; build them on (or via a remote builder for) + # the Linux system matching the enclave's architecture + (flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] (system: + let + mq = import ./examples/mutual_quine_ne { + nitro = nitro-util.lib.${system}; + eifBuild = nitro-util.packages.${system}.eif_build; + eifInit = nitro-util.packages.${system}.eif-init; + }; + in + { + packages = { + mutual-quine-ne-nodes = mq.nodes; + mutual-quine-ne-eif1 = mq.eifs.node1; + mutual-quine-ne-eif2 = mq.eifs.node2; + }; + checks = { + mutual-quine-ne-verify-1-rebuilds-2 = mq.verify.node1; + mutual-quine-ne-verify-2-rebuilds-1 = mq.verify.node2; + }; + })); +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e8b38d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "NixReflect" +version = "0.1.0" +readme = "README.md" +requires-python = ">= 3.12" +license = "MIT" +dependencies = [] + +[project.scripts] +nixreflect = "nixreflect.cli:main" + +[build-system] +requires = ["hatchling>=1.30"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/nixreflect"] + +[tool.ruff] +target-version = "py312" + +[dependency-groups] +dev = ["ruff>=0.15.4"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "PL", "SIM", "TC", "W"] +ignore = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/src/nixreflect/__init__.py b/src/nixreflect/__init__.py new file mode 100644 index 0000000..3bb2727 --- /dev/null +++ b/src/nixreflect/__init__.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: MIT + +"""NixReflect -- a general mutual-reference transpiler targeting Nix. + +Turns a template describing N nodes into N standalone Nix expressions, each able +to reconstruct the exact source of every node (itself included) from data +embedded in itself. + +Public API:: + + from nixreflect import parse_template, transpile + + template = parse_template(text) + nodes = transpile(template) # {node_id: source_code} +""" + +__version__ = "0.1.0" + +from .template import ( + Node, + Template, + node_ids, + parse_template, + validate_template, +) +from .transpiler import ( + FRAMEWORK, + HEADER, + build_blob, + build_bodies, + nix_str, + render_node, + rewrite_placeholders, + transpile, +) + +__all__ = [ + "__version__", + "Node", + "Template", + "parse_template", + "validate_template", + "node_ids", + "transpile", + "build_blob", + "build_bodies", + "nix_str", + "rewrite_placeholders", + "render_node", + "FRAMEWORK", + "HEADER", +] diff --git a/src/nixreflect/__main__.py b/src/nixreflect/__main__.py new file mode 100644 index 0000000..be14d5d --- /dev/null +++ b/src/nixreflect/__main__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MIT + +"""Allow ``python -m nixreflect`` invocation.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/nixreflect/cli.py b/src/nixreflect/cli.py new file mode 100644 index 0000000..8d0d51d --- /dev/null +++ b/src/nixreflect/cli.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MIT + +"""Command-line entry point for NixReflect.""" + +import argparse +import json +import os +import re +import sys + +from . import __version__ +from .template import Template, node_ids, parse_template +from .transpiler import transpile + + +def read_file(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def load_template(path: str) -> Template: + text = sys.stdin.read() if path == "-" else read_file(path) + return parse_template(text) + + +def node_filename(node_id: str) -> str: + return "node_" + re.sub(r"[^A-Za-z0-9_]", "_", node_id) + ".nix" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nixreflect", + description="Transpile a template into standalone Nix nodes.", + ) + parser.add_argument( + "template", + metavar="TEMPLATE.json", + help="template JSON file, or '-' to read from stdin", + ) + parser.add_argument( + "outdir", + metavar="OUTPUT_DIR", + help="directory to write node_.nix files and manifest.json into", + ) + parser.add_argument( + "--version", + action="version", + version="%(prog)s " + __version__, + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + template = load_template(args.template) + nodes = transpile(template) + ids = node_ids(template) + manifest = {nid: node_filename(nid) for nid in ids} + + os.makedirs(args.outdir, exist_ok=True) + for nid, filename in manifest.items(): + with open(os.path.join(args.outdir, filename), "w", encoding="utf-8") as f: + f.write(nodes[nid]) + with open( + os.path.join(args.outdir, "manifest.json"), "w", encoding="utf-8" + ) as f: + json.dump(manifest, f, indent=2) + except (ValueError, OSError) as exc: + sys.stderr.write("nixreflect: %s\n" % exc) + return 1 + + print("generated %d node(s) in %s:" % (len(manifest), os.path.abspath(args.outdir))) + for nid, filename in manifest.items(): + print(" %-14s -> %s" % (nid, filename)) + print(" manifest -> manifest.json") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/nixreflect/template.py b/src/nixreflect/template.py new file mode 100644 index 0000000..7d4e477 --- /dev/null +++ b/src/nixreflect/template.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT + +"""Template parsing and validation for NixReflect.""" + +import json +from typing import Required, TypedDict + + +class Node(TypedDict): + """A single template entry.""" + + node_id: Required[str] + code: Required[str] + + +# Internal canonical representation keeps the JSON key names ("node-id"), +# since the transpiler and manifest format are defined in terms of them. +Template = list[dict] + + +def parse_template(text: str) -> Template: + """Decode and validate a template from its JSON text. + + Args: + text: str JSON text encoding a list of node objects. + + Returns: + The validated template (a list of ``{"node-id", "code"}`` dicts). + + Raises: + ValueError: if the JSON is invalid or the template is malformed. + """ + return validate_template(json.loads(text)) + + +def validate_template[T](template: T) -> Template: + """Validate a decoded template JSON object, returning it unchanged. + + Args: + template: T The decoded JSON object to validate (expected to be a list of + node objects). + + Returns: + The same object, validated and typed as ``Template``. + + Raises: + ValueError: if the structure is malformed or node-ids collide. + """ + if not isinstance(template, list): + raise ValueError("template must be a JSON list") + + for entry in template: + if not isinstance(entry, dict): + raise ValueError("each template entry must be an object") + if "node-id" not in entry or "code" not in entry: + raise ValueError("each template entry needs 'node-id' and 'code'") + if not isinstance(entry["node-id"], str): + raise ValueError("'node-id' must be a string") + if not isinstance(entry["code"], str): + raise ValueError("'code' must be a string") + + ids = [entry["node-id"] for entry in template] + if len(set(ids)) != len(ids): + raise ValueError("duplicate node-id in template") + + return template + + +def node_ids(template: Template) -> list[str]: + """List the node ids of a template. + + Args: + template: Template The validated template. + + Returns: + The node ids, in the order they appear in the template. + """ + return [entry["node-id"] for entry in template] diff --git a/src/nixreflect/transpiler.py b/src/nixreflect/transpiler.py new file mode 100644 index 0000000..1249ba4 --- /dev/null +++ b/src/nixreflect/transpiler.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: MIT + +"""Core mutual-reference transpiler for NixReflect. + +NixReflect emits *Nix expressions* instead of Python programs. Each emitted +``.nix`` file is a ``let ... in `` expression whose bindings embed a JSON +data blob and a small framework able to reconstruct the exact source code of +every node (itself included) from that blob alone. +""" + +import json +import re + +from .template import Template, validate_template +from .template import node_ids as _template_node_ids + + +def nix_str(s: str) -> str: + """Encode ``s`` as a Nix double-quoted string literal. + + This escaping is mirrored verbatim inside ``FRAMEWORK`` (the + ``__nixreflect_nix_str__`` function, implemented with + ``builtins.replaceStrings``); the two MUST stay behaviourally identical. + ``replaceStrings`` substitutes in a single left-to-right pass and never + rescans replacement text; the sequential ``str.replace`` calls below are + equivalent because no replacement output re-triggers an earlier pattern. + + Args: + s: str The string value to encode. + + Returns: + A Nix string literal (including the surrounding double quotes) that + evaluates to exactly ``s``. + """ + return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("${", "\\${") + '"' + + +# Embedded framework (byte-identical in every node; written literally AND stored +# in the data blob from this one source value). It is a run of `let` bindings +# terminated by the `in` keyword; the node body follows as the file's result +# expression. +FRAMEWORK = r""" # ========== NixReflect FRAMEWORK ========== + + __nixreflect_blob__ = builtins.fromJSON __nixreflect_DATA__; + + # Nix double-quoted string literal for `s`; mirrors nixreflect.transpiler.nix_str. + __nixreflect_nix_str__ = s: "\"" + builtins.replaceStrings + [ "\\" "\"" "\${" ] [ "\\\\" "\\\"" "\\\${" ] s + "\""; + + # Exact source code (string) of node `target`, reconstructed from embedded data. + __nixreflect_render__ = target: + __nixreflect_blob__.header + + " __nixreflect_SELF__ = " + __nixreflect_nix_str__ target + ";\n" + + " __nixreflect_DATA__ = " + __nixreflect_nix_str__ __nixreflect_DATA__ + ";\n" + + "\n" + + __nixreflect_blob__.framework + "\n" + + __nixreflect_blob__.bodies.${target}; + + __nixreflect_node_ids__ = __nixreflect_blob__.nodes; + + __nixreflect_self_id__ = __nixreflect_SELF__; +in""" + +HEADER = "# auto-generated mutually-referential attestation node -- do not edit\nlet\n" + + +def render_node(target: str, blob: str, header: str, framework: str, body: str) -> str: + """Lay out the source of one node file. + + This expression is mirrored verbatim inside ``FRAMEWORK`` (the + ``__nixreflect_render__`` function); the two MUST stay structurally + identical. + + Args: + target: str The node-id this file is for. + blob: str The JSON data blob embedded in the node. + header: str The shared file header (opens the ``let``). + framework: str The embedded framework source (closes with ``in``). + body: str The rewritten node body (the result expression). + + Returns: + The complete source code of the node file. + """ + return ( + header + + " __nixreflect_SELF__ = " + + nix_str(target) + + ";\n" + + " __nixreflect_DATA__ = " + + nix_str(blob) + + ";\n" + + "\n" + + framework + + "\n" + + body + ) + + +def rewrite_placeholders(code: str, ids) -> str: + """Rewrite each bare node-id token into a ``__nixreflect_render__`` call. + + Args: + code: str A node body, possibly containing bare node-id tokens. + ids: Iterable[str] The node-ids to rewrite, matched on identifier boundaries. + + Returns: + The rewritten body, terminated by a single trailing newline. + """ + out = code + for nid in ids: + pat = r"(? dict[str, str]: + """Rewrite every node body in a template. + + Args: + template: Template The validated template. + + Returns: + A ``{node_id: rewritten_code}`` mapping. + """ + ids = _template_node_ids(template) + return { + entry["node-id"]: rewrite_placeholders(entry["code"], ids) for entry in template + } + + +def build_blob(template: Template, bodies: dict[str, str] | None = None) -> str: + """Build the JSON data blob embedded in every emitted node. + + Unlike PyReflect (which base64-encodes the blob so that Python's ``repr`` + stays trivial), NixReflect embeds the JSON text directly: pure Nix has no + base64 decoder, but ``builtins.fromJSON`` is built in and the + ``__nixreflect_nix_str__``/``nix_str`` pair makes the string literal + round-trip exact. + + Args: + template: Template The validated template. + bodies: dict[str, str] | None Pre-rewritten bodies; rebuilt from + ``template`` when ``None``. + + Returns: + The JSON blob (ASCII str) embedded as ``__nixreflect_DATA__``. + """ + if bodies is None: + bodies = build_bodies(template) + blob_obj = { + "nodes": _template_node_ids(template), + "bodies": bodies, + "header": HEADER, + "framework": FRAMEWORK, + } + return json.dumps(blob_obj, sort_keys=True, separators=(",", ":")) + + +def transpile(template: Template) -> dict[str, str]: + """Transpile a template into standalone Nix nodes. + + Args: + template: Template The template to transpile (it is validated). + + Returns: + A ``{node_id: source_code}`` mapping, one entry per node. + + Raises: + ValueError: if the template is malformed or node-ids collide. + """ + template = validate_template(template) + ids = _template_node_ids(template) + bodies = build_bodies(template) + blob = build_blob(template, bodies) + + nodes = {nid: render_node(nid, blob, HEADER, FRAMEWORK, bodies[nid]) for nid in ids} + return nodes