Skip to main content

Module identity

Module identity 

Source
Expand description

The tunnel’s identity proof: the seed’s wire contract, the Noise pattern, and the pin.

Here rather than on either side for ARCH-2’s reason, and the stakes are higher than for the close codes: the host derives a public key from a seed and the daemon derives what must be the same public key from the same seed. A derivation spelled twice is a derivation that can disagree, and a disagreement surfaces as a handshake that fails with no way to tell a wrong pin from a wrong derivation.

Following this crate’s own rule (see the crate docs), the base64 encoding is not here: the seed travels as a String field and each side encodes with the shared base64 crate, exactly as the exec payloads do. What is here is the part that must agree — the key names, the length, the pattern, and which seeds are refused.

§What this proves, and what platform-layer mTLS cannot

TLS terminates at the AWS-managed endpoint proxy and the service model has no client-certificate parameter anywhere: CreateMicrovmAuthToken takes microvmIdentifier, allowedPorts, and expirationInMinutes, and nothing else. So there is no way to present a certificate to the VM through the platform, and endpoint auth is the JWE alone.

The JWE is scoped to one microvmId by the endpoint proxy. The additional Noise proof terminates in the daemon and checks possession of the launch-pinned key. Its trust root includes the AWS control plane and run-hook delivery: the host supplies the VM seed through RunMicrovm. The proxy carries the agent token used for root control, so key protection also assumes trusted authorized control and guest-root access. This is an application-layer proof under those assumptions, not independence from them.

§Noise KK rather than TLS, on a measured constraint

Issue #70’s layer 3 called for rustls with a per-VM certificate pinned by SPKI. That is not available to the daemon, and the reason is the shipping target rather than a preference. agentd ships as a static aarch64-unknown-linux-musl binary, and both rustls crypto providers compile C: aws-lc-sys and ring each drive cc-rs, which looks for aarch64-linux-musl-gcc. Measured 2026-08-29 on the devbox, and .github/workflows/ci.yml installs gcc-aarch64-linux-gnu — a gnu cross compiler, not the musl one cc-rs asks for. Both providers fail the daemon’s release build with error occurred in cc-rs: failed to find tool "aarch64-linux-musl-gcc", so putting rustls in the daemon would trade a working shipping artifact for an identity feature.

Noise KK gives the same three properties with no PKI and no C:

  • Mutual authentication. KK means both statics are known to both sides before the handshake, which is exactly the situation here — the host generated the seed, so it knows the daemon’s key, and the daemon is told the host’s key in the same payload. Each side proves possession of its own static, so the daemon learns the peer is the launching host rather than another token holder.
  • Pinning by construction. There is no name, no CA, and no chain to get wrong: the remote static key is the pin. A wrong pin cannot be “accepted anyway” by a verifier that forgot a check, because the key is mixed into the handshake hash and a mismatch fails decryption. That is a stronger position than a custom certificate verifier, which is code that can be written wrong.
  • Confidentiality past the proxy. ChaCha20-Poly1305 over the whole stream, so the bytes the relay carries are opaque to the endpoint that carries them.

snow is the crate, at 26M downloads the most-used Noise implementation in the ecosystem, and it cross-compiles to the shipping target with no C compiler — measured, with default-features = false, which is load-bearing: snow’s std feature reads ring/std rather than ring?/std, so enabling it force-enables the very C dependency the daemon cannot build.

§The end of stream (#342)

Every relayed chunk rides one Noise transport message, and so does the end of a stream: a side that has sent its last byte sends one more message with an empty plaintext. A relayed chunk is never empty, so the empty message is unambiguous, and it authenticates under the session’s keys like any other. It also counts: each transport message decrypts only at its own position (snow’s nonce is a counter), so the end of stream proves every message before it arrived, in order, with none added. That’s why it carries no byte counts.

The WebSocket close frame that follows it stays plaintext, since it has to reach a caller whose handshake failed. After the handshake it’s a diagnosis and never the verdict: anything on the path can send a close frame or drop the connection, and only the end of stream says a stream finished rather than being cut short.

Each side offers the end of stream in its handshake message’s payload (HANDSHAKE_PAYLOAD), and a side holds its peer to it only when the peer offered it. The payloads are inside the handshake’s encryption and its hash, so an on-path party can’t strip the offer the way it could strip a query flag. An older peer sends an empty payload and no end of stream, and it reads the newer peer’s end of stream as a message with nothing in it, so a release that predates the end of stream and one that sends it still work together in either order within the same handshake version. Protocol 2 requires a prologue binding the route and guest port, then a separate transport confirmation before the guest dial; protocol 1 verified tunnels are refused. docs/PROTOCOL.md has the complete version and end-of-stream rules.

§The honest limit, restated here because this is where a reader will look

Under the launch-material and privileged-access assumptions above, this proves possession of the key pinned for the VM. It does not prove the VM is uncompromised: the workload runs as uid 0 and can in principle ptrace the daemon and read the derived key out of its memory. Same class as the other entries on docs/TRUST.md’s unenforced list. It is documented rather than overclaimed.

Enums§

SeedError
Why some bytes are not a usable identity seed.

Constants§

END_OF_STREAM
The handshake payload’s flag for the end of stream: the sender sends one, and checks the peer’s when the peer offered it too.
HANDSHAKE_PAYLOAD
The payload each side writes into its handshake message: the flags it offers.
HOST_PUBLIC_KEY_KEY
The payload key carrying the host’s public key, which the daemon pins in return.
IDENTITY_PAYLOAD_BYTES
How many bytes the identity material adds to the run-hook payload.
NOISE_PATTERN
The Noise handshake pattern, spelled once for both sides.
SEED_BYTES
The length of a seed, a static secret, and a public key. All 32 bytes, on x25519.
SEED_KEY
The payload key carrying the VM’s own seed.
TUNNEL_CONFIRMATION
The first transport message, consumed before the daemon connects to the guest.
TUNNEL_ROUTE
The route bound into every verified tunnel handshake.

Functions§

is_end_of_stream
Whether a transport message’s plaintext is the end of stream.
offers_end_of_stream
Whether a peer’s handshake payload offers the end of stream.
seed_from_bytes
Checks decoded bytes into a seed, refusing the two shapes that are not one.
tunnel_prologue
The context both peers authenticate alongside their pinned keys.