Skip to main content

microvms_protocol/
identity.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The tunnel's identity proof: the seed's wire contract, the Noise pattern, and the pin.
3//!
4//! Here rather than on either side for ARCH-2's reason, and the stakes are higher than for
5//! the close codes: the host derives a public key from a seed and the daemon derives what
6//! must be the *same* public key from the *same* seed. A derivation spelled twice is a
7//! derivation that can disagree, and a disagreement surfaces as a handshake that fails with
8//! no way to tell a wrong pin from a wrong derivation.
9//!
10//! Following this crate's own rule (see the crate docs), the base64 *encoding* is not here:
11//! the seed travels as a `String` field and each side encodes with the shared `base64` crate,
12//! exactly as the exec payloads do. What is here is the part that must agree — the key names,
13//! the length, the pattern, and which seeds are refused.
14//!
15//! # What this proves, and what platform-layer mTLS cannot
16//!
17//! TLS terminates at the AWS-managed endpoint proxy and the service model has no
18//! client-certificate parameter anywhere: `CreateMicrovmAuthToken` takes
19//! `microvmIdentifier`, `allowedPorts`, and `expirationInMinutes`, and nothing else. So there
20//! is no way to present a certificate to the *VM* through the platform, and endpoint auth is
21//! the JWE alone.
22//!
23//! The JWE is scoped to one `microvmId` by the endpoint proxy. The additional
24//! Noise proof terminates in the daemon and checks possession of the launch-pinned key.
25//! Its trust root includes the AWS control plane and run-hook delivery: the host supplies
26//! the VM seed through `RunMicrovm`. The proxy carries the agent token used for root
27//! control, so key protection also assumes trusted authorized control and guest-root access.
28//! This is an application-layer proof under those assumptions, not independence from them.
29//!
30//! # Noise KK rather than TLS, on a measured constraint
31//!
32//! Issue #70's layer 3 called for rustls with a per-VM certificate pinned by SPKI. That is
33//! not available to the daemon, and the reason is the shipping target rather than a
34//! preference. `agentd` ships as a static `aarch64-unknown-linux-musl` binary, and **both**
35//! rustls crypto providers compile C: `aws-lc-sys` and `ring` each drive `cc-rs`, which looks
36//! for `aarch64-linux-musl-gcc`. Measured 2026-08-29 on the devbox, and `.github/workflows/ci.yml`
37//! installs `gcc-aarch64-linux-gnu` — a *gnu* cross compiler, not the musl one `cc-rs` asks
38//! for. Both providers fail the daemon's release build with `error occurred in cc-rs: failed
39//! to find tool "aarch64-linux-musl-gcc"`, so putting rustls in the daemon would trade a
40//! working shipping artifact for an identity feature.
41//!
42//! Noise KK gives the same three properties with no PKI and no C:
43//!
44//! * **Mutual authentication.** `KK` means both statics are known to both sides before the
45//!   handshake, which is exactly the situation here — the host generated the seed, so it
46//!   knows the daemon's key, and the daemon is told the host's key in the same payload. Each
47//!   side proves possession of its own static, so the daemon learns the peer is the launching
48//!   host rather than another token holder.
49//! * **Pinning by construction.** There is no name, no CA, and no chain to get wrong: the
50//!   remote static key *is* the pin. A wrong pin cannot be "accepted anyway" by a verifier
51//!   that forgot a check, because the key is mixed into the handshake hash and a mismatch
52//!   fails decryption. That is a stronger position than a custom certificate verifier, which
53//!   is code that can be written wrong.
54//! * **Confidentiality past the proxy.** ChaCha20-Poly1305 over the whole stream, so the
55//!   bytes the relay carries are opaque to the endpoint that carries them.
56//!
57//! `snow` is the crate, at 26M downloads the most-used Noise implementation in the ecosystem,
58//! and it cross-compiles to the shipping target with **no C compiler** — measured, with
59//! `default-features = false`, which is load-bearing: snow's `std` feature reads `ring/std`
60//! rather than `ring?/std`, so enabling it force-enables the very C dependency the daemon
61//! cannot build.
62//!
63//! # The end of stream (#342)
64//!
65//! Every relayed chunk rides one Noise transport message, and so does the end of a stream: a
66//! side that has sent its last byte sends one more message with an empty plaintext. A relayed
67//! chunk is never empty, so the empty message is unambiguous, and it authenticates under the
68//! session's keys like any other. It also counts: each transport message decrypts only at its
69//! own position (snow's nonce is a counter), so the end of stream proves every message before
70//! it arrived, in order, with none added. That's why it carries no byte counts.
71//!
72//! The WebSocket close frame that follows it stays plaintext, since it has to reach a caller
73//! whose handshake failed. After the handshake it's a diagnosis and never the verdict: anything
74//! on the path can send a close frame or drop the connection, and only the end of stream says a
75//! stream finished rather than being cut short.
76//!
77//! Each side offers the end of stream in its handshake message's payload
78//! ([`HANDSHAKE_PAYLOAD`]), and a side holds its peer to it only when the peer offered it. The
79//! payloads are inside the handshake's encryption and its hash, so an on-path party can't strip
80//! the offer the way it could strip a query flag. An older peer sends an empty payload and no
81//! end of stream, and it reads the newer peer's end of stream as a message with nothing in it,
82//! so a release that predates the end of stream and one that sends it still work together in
83//! either order within the same handshake version. Protocol 2 requires a prologue
84//! binding the route and guest port, then a separate transport confirmation before
85//! the guest dial; protocol 1 verified tunnels are refused. `docs/PROTOCOL.md`
86//! has the complete version and end-of-stream rules.
87//!
88//! # The honest limit, restated here because this is where a reader will look
89//!
90//! Under the launch-material and privileged-access assumptions above, this proves
91//! 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
92//! daemon and read the derived key out of its memory. Same class as the other entries on
93//! `docs/TRUST.md`'s unenforced list. It is documented rather than overclaimed.
94
95/// The Noise handshake pattern, spelled once for both sides.
96///
97/// `KK` because both static keys are known in advance, which is the whole shape of this
98/// problem: the host *generated* the daemon's key, so pre-knowledge is not an assumption to
99/// arrange but the starting condition. The alternatives are worse fits — `XX` transmits the
100/// statics during the handshake, which is what you want when the peers have never met, and it
101/// would let a responder present *any* key and be believed until a later check caught it.
102/// Under `KK` a wrong key fails the handshake itself.
103///
104/// `25519_ChaChaPoly_BLAKE2s`: all three have pure-Rust implementations in snow's default
105/// resolver, which is what keeps the daemon's build free of a C compiler. AES-GCM would pull
106/// that constraint back in on some targets for no security gain here.
107pub const NOISE_PATTERN: &str = "Noise_KK_25519_ChaChaPoly_BLAKE2s";
108
109/// The route bound into every verified tunnel handshake.
110pub const TUNNEL_ROUTE: &str = "/v1/tcp";
111
112/// The first transport message, consumed before the daemon connects to the guest.
113///
114/// A separate confirmation permits a guest protocol to send first without waiting for
115/// application bytes from the caller. It occupies transport position zero and is never relayed.
116pub const TUNNEL_CONFIRMATION: &[u8] = b"microvms-tunnel-ready";
117
118/// The context both peers authenticate alongside their pinned keys.
119///
120/// NUL-separated fixed fields and a big-endian port make the encoding unambiguous. The
121/// protocol version changes when this handshake contract changes; peers never retry without it.
122pub fn tunnel_prologue(guest_port: u16) -> Vec<u8> {
123    let mut context = format!(
124        "microvms-tunnel\0{NOISE_PATTERN}\0{}\0{TUNNEL_ROUTE}\0",
125        crate::PROTOCOL_VERSION
126    )
127    .into_bytes();
128    context.extend_from_slice(&guest_port.to_be_bytes());
129    context
130}
131
132/// The length of a seed, a static secret, and a public key. All 32 bytes, on x25519.
133///
134/// One constant rather than three, because they are the same number for the same reason: an
135/// x25519 static secret *is* 32 bytes of seed, so the derivation is a type change rather than
136/// a KDF. That is why this design needs no HKDF step and no rejection loop — the thing the
137/// platform can carry is already the thing the handshake needs.
138pub const SEED_BYTES: usize = 32;
139
140/// The payload key carrying the VM's own seed.
141///
142/// Named beside the pattern so a reader finds the whole wire contract in one file, and
143/// spelled as a constant because it appears in the host's composition and the daemon's parse
144/// — a literal in each place is a typo that fails as "the VM has no identity" rather than as
145/// a compile error.
146pub const SEED_KEY: &str = "identity_seed";
147
148/// The payload key carrying the host's public key, which the daemon pins in return.
149///
150/// The other half of mutual authentication. Without it the daemon would accept a handshake
151/// from anyone holding the agent token, and the token is a bearer credential the proxy
152/// transports on every request — so "knows the token" is a weaker claim than "holds the
153/// launching host's private key".
154pub const HOST_PUBLIC_KEY_KEY: &str = "identity_host_public_key";
155
156/// How many bytes the identity material adds to the run-hook payload.
157///
158/// Two 32-byte values, each base64'd to 44 characters, plus the JSON keys, quotes, colons,
159/// and commas. Stated as a constant because the payload shares one measured 4096-byte ceiling
160/// with the agent token and the launch environment, and a caller near that ceiling needs the
161/// number rather than a warning. `microvms-core` asserts the real composition against it.
162pub const IDENTITY_PAYLOAD_BYTES: usize = 137;
163
164/// The handshake payload's flag for the end of stream: the sender sends one, and checks the
165/// peer's when the peer offered it too.
166pub const END_OF_STREAM: u8 = 0b0000_0001;
167
168/// The payload each side writes into its handshake message: the flags it offers.
169///
170/// One byte of flags, so a later release can offer something more in another bit. The
171/// initiator's first message and the responder's reply both carry it, and both are encrypted
172/// under keys only the two pinned statics can derive, so the offer can't be forged or stripped.
173pub const HANDSHAKE_PAYLOAD: [u8; 1] = [END_OF_STREAM];
174
175/// Whether a peer's handshake payload offers the end of stream.
176///
177/// An empty payload is a peer that predates it. A flag or a byte this release doesn't know is
178/// ignored rather than refused, so a later release's payload still reads here.
179pub fn offers_end_of_stream(payload: &[u8]) -> bool {
180    payload
181        .first()
182        .is_some_and(|flags| flags & END_OF_STREAM != 0)
183}
184
185/// Whether a transport message's plaintext is the end of stream.
186///
187/// The one message with nothing in it: neither side relays an empty read, so an empty
188/// plaintext can't be a chunk of the stream.
189pub fn is_end_of_stream(plaintext: &[u8]) -> bool {
190    plaintext.is_empty()
191}
192
193/// Why some bytes are not a usable identity seed.
194///
195/// Named variants rather than a string for the same reason [`super::hook::RunHookError`] has
196/// them: this material is secret, and a message that quoted what it rejected would put key
197/// material into a log line and a response body.
198#[derive(Debug, Eq, PartialEq)]
199pub enum SeedError {
200    /// Not exactly [`SEED_BYTES`] bytes. Carries what was found, because a truncated payload
201    /// and a padded one are different bugs on the composition side.
202    WrongLength(usize),
203    /// Every byte is zero. A caller that produced it has a broken random source, and
204    /// accepting it would install an identity every other broken caller shares.
205    AllZero,
206}
207
208impl std::fmt::Display for SeedError {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            SeedError::WrongLength(found) => write!(
212                f,
213                "the identity seed is {found} bytes, and an x25519 static secret is exactly \
214                 {SEED_BYTES}. A seed of another length is a different derivation, not a \
215                 shorter one. It travels as standard base64 beside the agent token in the \
216                 run-hook payload."
217            ),
218            SeedError::AllZero => f.write_str(
219                "the identity seed is all zero bytes, which every caller with a broken random \
220                 source would produce identically. Refused rather than installed: a shared \
221                 identity proves nothing about which VM answered.",
222            ),
223        }
224    }
225}
226
227impl std::error::Error for SeedError {}
228
229/// Checks decoded bytes into a seed, refusing the two shapes that are not one.
230///
231/// Takes bytes rather than a base64 string on purpose: this crate carries no encoder (crate
232/// docs), so each side decodes with the shared `base64` crate and hands the result here. That
233/// keeps one *validation* for both sides, which is the half that has to agree — a host that
234/// accepted a seed the daemon refuses would report a launch as identity-capable and then fail
235/// every handshake against it.
236pub fn seed_from_bytes(bytes: &[u8]) -> Result<[u8; SEED_BYTES], SeedError> {
237    if bytes.len() != SEED_BYTES {
238        return Err(SeedError::WrongLength(bytes.len()));
239    }
240    // A zero seed is a working x25519 secret and a useless identity, so it is refused here
241    // rather than at the handshake, where the failure would read as a wrong pin.
242    if bytes.iter().all(|byte| *byte == 0) {
243        return Err(SeedError::AllZero);
244    }
245    let mut seed = [0_u8; SEED_BYTES];
246    seed.copy_from_slice(bytes);
247    Ok(seed)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// The pattern is `KK` and nothing else, because the choice *is* the security property.
255    ///
256    /// `XX` would transmit the statics during the handshake, which is the pattern for peers
257    /// that have never met — and it would let a responder present any key and be believed
258    /// until a later check caught it. An edit to this string is a change of threat model, so
259    /// it is pinned rather than merely used.
260    #[test]
261    fn the_pattern_is_mutually_authenticated_and_pure_rust() {
262        assert_eq!(NOISE_PATTERN, "Noise_KK_25519_ChaChaPoly_BLAKE2s");
263        assert!(
264            NOISE_PATTERN.starts_with("Noise_KK_"),
265            "only KK pre-knows both statics, which is what makes the pin unforgeable"
266        );
267        // ChaChaPoly and BLAKE2s both have pure-Rust implementations in snow's default
268        // resolver. That is what keeps the daemon's aarch64-musl build free of a C compiler,
269        // which is the constraint that ruled rustls out in the first place.
270        assert!(NOISE_PATTERN.contains("ChaChaPoly"), "{NOISE_PATTERN}");
271        assert!(NOISE_PATTERN.contains("BLAKE2s"), "{NOISE_PATTERN}");
272    }
273
274    #[test]
275    fn the_tunnel_context_includes_the_version_route_and_network_order_port() {
276        let mut expected = format!(
277            "microvms-tunnel\0{NOISE_PATTERN}\0{}\0/v1/tcp\0",
278            crate::PROTOCOL_VERSION
279        )
280        .into_bytes();
281        expected.extend_from_slice(&[0x12, 0x34]);
282        assert_eq!(tunnel_prologue(0x1234), expected);
283        assert_ne!(tunnel_prologue(0x1234), tunnel_prologue(0x3412));
284        assert_eq!(TUNNEL_CONFIRMATION, b"microvms-tunnel-ready");
285    }
286
287    #[test]
288    fn a_seed_of_the_right_length_is_accepted_byte_for_byte() {
289        let mut ascending = [0_u8; SEED_BYTES];
290        for (index, byte) in ascending.iter_mut().enumerate() {
291            *byte = index as u8;
292        }
293        assert_eq!(seed_from_bytes(&ascending).expect("accepted"), ascending);
294    }
295
296    /// A seed one byte short and one byte long are both refused, and the message says which.
297    ///
298    /// The pair matters because a truncated payload and a padded one are different bugs on the
299    /// composition side, and a single "bad seed" message would send a reader to the wrong one.
300    #[test]
301    fn a_seed_of_the_wrong_length_reports_the_length_it_found() {
302        assert_eq!(
303            seed_from_bytes(&[7_u8; 31]),
304            Err(SeedError::WrongLength(31))
305        );
306        assert_eq!(
307            seed_from_bytes(&[7_u8; 33]),
308            Err(SeedError::WrongLength(33))
309        );
310        assert_eq!(seed_from_bytes(&[]), Err(SeedError::WrongLength(0)));
311        assert!(
312            SeedError::WrongLength(31).to_string().contains("31"),
313            "the found length is the diagnosis"
314        );
315    }
316
317    /// A zero seed decodes fine and is refused anyway, because it is a shared identity.
318    #[test]
319    fn an_all_zero_seed_is_refused() {
320        assert_eq!(
321            seed_from_bytes(&[0_u8; SEED_BYTES]),
322            Err(SeedError::AllZero)
323        );
324        // One non-zero byte is enough to be a real seed: the refusal is about a broken RNG
325        // producing an all-zero buffer, not about weak-looking keys in general.
326        let mut nearly = [0_u8; SEED_BYTES];
327        nearly[SEED_BYTES - 1] = 1;
328        assert!(seed_from_bytes(&nearly).is_ok());
329    }
330
331    /// No refusal may carry the material it rejected.
332    ///
333    /// The payload this validates sits beside the agent token, and `docs/TRUST.md` promises
334    /// no secret reaches a log line or a response body.
335    #[test]
336    fn a_refusal_never_quotes_the_bytes_it_rejected() {
337        let secret = [0xAB_u8; 31];
338        let message = seed_from_bytes(&secret).expect_err("refused").to_string();
339        assert!(!message.contains("171"), "{message}");
340        assert!(!message.contains("ab"), "{message}");
341        assert!(!message.contains("AB"), "{message}");
342    }
343
344    /// The two payload keys are distinct, and neither collides with the payload's other keys.
345    ///
346    /// A collision would make one value overwrite the other in the payload object, and the
347    /// symptom would be a failed handshake rather than anything naming a key.
348    #[test]
349    fn the_payload_keys_are_distinct_from_each_other_and_from_the_token() {
350        assert_ne!(SEED_KEY, HOST_PUBLIC_KEY_KEY);
351        for key in [SEED_KEY, HOST_PUBLIC_KEY_KEY] {
352            assert_ne!(key, "agent_token");
353            assert_ne!(key, "env");
354        }
355    }
356
357    /// The offer reads from the flags byte alone, and an older peer's empty payload offers
358    /// nothing.
359    ///
360    /// The empty payload is the skew case: every release before #342 wrote one, and reading it
361    /// as an offer would hold that peer to an end of stream it never sends, failing every
362    /// tunnel to an older image. A later release's extra flag or byte must not turn the offer
363    /// off either.
364    #[test]
365    fn the_end_of_stream_is_offered_by_its_flag_and_not_by_an_empty_payload() {
366        assert!(offers_end_of_stream(&HANDSHAKE_PAYLOAD));
367        assert!(!offers_end_of_stream(&[]));
368        assert!(!offers_end_of_stream(&[0b0000_0010]));
369        assert!(offers_end_of_stream(&[END_OF_STREAM | 0b1000_0000, 0xff]));
370        assert!(is_end_of_stream(&[]));
371        assert!(!is_end_of_stream(&[0]));
372    }
373
374    /// The identity payload budget is what a caller near the ceiling needs, so it is asserted
375    /// against the real composition rather than trusted as a comment.
376    ///
377    /// The number is checked here against its two 44-character base64 values and JSON framing;
378    /// `microvms-core` asserts the same constant against the payload it actually builds. Two
379    /// independent checks of one number, because the ceiling it is spent against is measured
380    /// and hard — 4096 bytes, where 4097 fails.
381    #[test]
382    fn the_identity_payload_budget_matches_its_parts() {
383        // Two keys, two quoted 44-char base64 values, two colons, two commas, four quote pairs.
384        let value_chars = 44 * 2;
385        let key_chars = SEED_KEY.len() + HOST_PUBLIC_KEY_KEY.len();
386        // Per pair: quotes around the key (2), a colon (1), quotes around the value (2), and a
387        // leading comma (1) — six characters of framing each.
388        let framing = 6 * 2;
389        assert_eq!(
390            IDENTITY_PAYLOAD_BYTES,
391            value_chars + key_chars + framing,
392            "the stated budget must equal the bytes the two halves really cost"
393        );
394    }
395}