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` and the proxy enforces that scope, so an
24//! authenticated connection already binds to VM identity **as attested by AWS**. A caller who
25//! trusts the proxy needs nothing here. This module is for the caller who wants that binding
26//! without trusting the proxy, and it works because we control both endpoints: the proof
27//! terminates in the daemon rather than at the proxy.
28//!
29//! # Noise KK rather than TLS, on a measured constraint
30//!
31//! Issue #70's layer 3 called for rustls with a per-VM certificate pinned by SPKI. That is
32//! not available to the daemon, and the reason is the shipping target rather than a
33//! preference. `agentd` ships as a static `aarch64-unknown-linux-musl` binary, and **both**
34//! rustls crypto providers compile C: `aws-lc-sys` and `ring` each drive `cc-rs`, which looks
35//! for `aarch64-linux-musl-gcc`. Measured 2026-08-29 on the devbox, and `.github/workflows/ci.yml`
36//! installs `gcc-aarch64-linux-gnu` — a *gnu* cross compiler, not the musl one `cc-rs` asks
37//! for. Both providers fail the daemon's release build with `error occurred in cc-rs: failed
38//! to find tool "aarch64-linux-musl-gcc"`, so putting rustls in the daemon would trade a
39//! working shipping artifact for an identity feature.
40//!
41//! Noise KK gives the same three properties with no PKI and no C:
42//!
43//! * **Mutual authentication.** `KK` means both statics are known to both sides before the
44//! handshake, which is exactly the situation here — the host generated the seed, so it
45//! knows the daemon's key, and the daemon is told the host's key in the same payload. Each
46//! side proves possession of its own static, so the daemon learns the peer is the launching
47//! host rather than another token holder.
48//! * **Pinning by construction.** There is no name, no CA, and no chain to get wrong: the
49//! remote static key *is* the pin. A wrong pin cannot be "accepted anyway" by a verifier
50//! that forgot a check, because the key is mixed into the handshake hash and a mismatch
51//! fails decryption. That is a stronger position than a custom certificate verifier, which
52//! is code that can be written wrong.
53//! * **Confidentiality past the proxy.** ChaCha20-Poly1305 over the whole stream, so the
54//! bytes the relay carries are opaque to the endpoint that carries them.
55//!
56//! `snow` is the crate, at 26M downloads the most-used Noise implementation in the ecosystem,
57//! and it cross-compiles to the shipping target with **no C compiler** — measured, with
58//! `default-features = false`, which is load-bearing: snow's `std` feature reads `ring/std`
59//! rather than `ring?/std`, so enabling it force-enables the very C dependency the daemon
60//! cannot build.
61//!
62//! # The honest limit, restated here because this is where a reader will look
63//!
64//! This proves the far end is **`agentd` in the VM the caller launched**. It does not prove
65//! the VM is uncompromised: the workload runs as uid 0 and can in principle `ptrace` the
66//! daemon and read the derived key out of its memory. Same class as the other entries on
67//! `docs/TRUST.md`'s unenforced list. It is documented rather than overclaimed.
68
69/// The Noise handshake pattern, spelled once for both sides.
70///
71/// `KK` because both static keys are known in advance, which is the whole shape of this
72/// problem: the host *generated* the daemon's key, so pre-knowledge is not an assumption to
73/// arrange but the starting condition. The alternatives are worse fits — `XX` transmits the
74/// statics during the handshake, which is what you want when the peers have never met, and it
75/// would let a responder present *any* key and be believed until a later check caught it.
76/// Under `KK` a wrong key fails the handshake itself.
77///
78/// `25519_ChaChaPoly_BLAKE2s`: all three have pure-Rust implementations in snow's default
79/// resolver, which is what keeps the daemon's build free of a C compiler. AES-GCM would pull
80/// that constraint back in on some targets for no security gain here.
81pub const NOISE_PATTERN: &str = "Noise_KK_25519_ChaChaPoly_BLAKE2s";
82
83/// The length of a seed, a static secret, and a public key. All 32 bytes, on x25519.
84///
85/// One constant rather than three, because they are the same number for the same reason: an
86/// x25519 static secret *is* 32 bytes of seed, so the derivation is a type change rather than
87/// a KDF. That is why this design needs no HKDF step and no rejection loop — the thing the
88/// platform can carry is already the thing the handshake needs.
89pub const SEED_BYTES: usize = 32;
90
91/// The payload key carrying the VM's own seed.
92///
93/// Named beside the pattern so a reader finds the whole wire contract in one file, and
94/// spelled as a constant because it appears in the host's composition and the daemon's parse
95/// — a literal in each place is a typo that fails as "the VM has no identity" rather than as
96/// a compile error.
97pub const SEED_KEY: &str = "identity_seed";
98
99/// The payload key carrying the host's public key, which the daemon pins in return.
100///
101/// The other half of mutual authentication. Without it the daemon would accept a handshake
102/// from anyone holding the agent token, and the token is a bearer credential the proxy
103/// transports on every request — so "knows the token" is a weaker claim than "holds the
104/// launching host's private key".
105pub const HOST_PUBLIC_KEY_KEY: &str = "identity_host_public_key";
106
107/// How many bytes the identity material adds to the run-hook payload.
108///
109/// Two 32-byte values, each base64'd to 44 characters, plus the JSON keys, quotes, colons,
110/// and commas. Stated as a constant because the payload shares one measured 4096-byte ceiling
111/// with the agent token and the launch environment, and a caller near that ceiling needs the
112/// number rather than a warning. `microvms-core` asserts the real composition against it.
113pub const IDENTITY_PAYLOAD_BYTES: usize = 137;
114
115/// Why some bytes are not a usable identity seed.
116///
117/// Named variants rather than a string for the same reason [`super::hook::RunHookError`] has
118/// them: this material is secret, and a message that quoted what it rejected would put key
119/// material into a log line and a response body.
120#[derive(Debug, Eq, PartialEq)]
121pub enum SeedError {
122 /// Not exactly [`SEED_BYTES`] bytes. Carries what was found, because a truncated payload
123 /// and a padded one are different bugs on the composition side.
124 WrongLength(usize),
125 /// Every byte is zero. A caller that produced it has a broken random source, and
126 /// accepting it would install an identity every other broken caller shares.
127 AllZero,
128}
129
130impl std::fmt::Display for SeedError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match self {
133 SeedError::WrongLength(found) => write!(
134 f,
135 "the identity seed is {found} bytes, and an x25519 static secret is exactly \
136 {SEED_BYTES}. A seed of another length is a different derivation, not a \
137 shorter one. It travels as standard base64 beside the agent token in the \
138 run-hook payload."
139 ),
140 SeedError::AllZero => f.write_str(
141 "the identity seed is all zero bytes, which every caller with a broken random \
142 source would produce identically. Refused rather than installed: a shared \
143 identity proves nothing about which VM answered.",
144 ),
145 }
146 }
147}
148
149impl std::error::Error for SeedError {}
150
151/// Checks decoded bytes into a seed, refusing the two shapes that are not one.
152///
153/// Takes bytes rather than a base64 string on purpose: this crate carries no encoder (crate
154/// docs), so each side decodes with the shared `base64` crate and hands the result here. That
155/// keeps one *validation* for both sides, which is the half that has to agree — a host that
156/// accepted a seed the daemon refuses would report a launch as identity-capable and then fail
157/// every handshake against it.
158pub fn seed_from_bytes(bytes: &[u8]) -> Result<[u8; SEED_BYTES], SeedError> {
159 if bytes.len() != SEED_BYTES {
160 return Err(SeedError::WrongLength(bytes.len()));
161 }
162 // A zero seed is a working x25519 secret and a useless identity, so it is refused here
163 // rather than at the handshake, where the failure would read as a wrong pin.
164 if bytes.iter().all(|byte| *byte == 0) {
165 return Err(SeedError::AllZero);
166 }
167 let mut seed = [0_u8; SEED_BYTES];
168 seed.copy_from_slice(bytes);
169 Ok(seed)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 /// The pattern is `KK` and nothing else, because the choice *is* the security property.
177 ///
178 /// `XX` would transmit the statics during the handshake, which is the pattern for peers
179 /// that have never met — and it would let a responder present any key and be believed
180 /// until a later check caught it. An edit to this string is a change of threat model, so
181 /// it is pinned rather than merely used.
182 #[test]
183 fn the_pattern_is_mutually_authenticated_and_pure_rust() {
184 assert_eq!(NOISE_PATTERN, "Noise_KK_25519_ChaChaPoly_BLAKE2s");
185 assert!(
186 NOISE_PATTERN.starts_with("Noise_KK_"),
187 "only KK pre-knows both statics, which is what makes the pin unforgeable"
188 );
189 // ChaChaPoly and BLAKE2s both have pure-Rust implementations in snow's default
190 // resolver. That is what keeps the daemon's aarch64-musl build free of a C compiler,
191 // which is the constraint that ruled rustls out in the first place.
192 assert!(NOISE_PATTERN.contains("ChaChaPoly"), "{NOISE_PATTERN}");
193 assert!(NOISE_PATTERN.contains("BLAKE2s"), "{NOISE_PATTERN}");
194 }
195
196 #[test]
197 fn a_seed_of_the_right_length_is_accepted_byte_for_byte() {
198 let mut ascending = [0_u8; SEED_BYTES];
199 for (index, byte) in ascending.iter_mut().enumerate() {
200 *byte = index as u8;
201 }
202 assert_eq!(seed_from_bytes(&ascending).expect("accepted"), ascending);
203 }
204
205 /// A seed one byte short and one byte long are both refused, and the message says which.
206 ///
207 /// The pair matters because a truncated payload and a padded one are different bugs on the
208 /// composition side, and a single "bad seed" message would send a reader to the wrong one.
209 #[test]
210 fn a_seed_of_the_wrong_length_reports_the_length_it_found() {
211 assert_eq!(
212 seed_from_bytes(&[7_u8; 31]),
213 Err(SeedError::WrongLength(31))
214 );
215 assert_eq!(
216 seed_from_bytes(&[7_u8; 33]),
217 Err(SeedError::WrongLength(33))
218 );
219 assert_eq!(seed_from_bytes(&[]), Err(SeedError::WrongLength(0)));
220 assert!(
221 SeedError::WrongLength(31).to_string().contains("31"),
222 "the found length is the diagnosis"
223 );
224 }
225
226 /// A zero seed decodes fine and is refused anyway, because it is a shared identity.
227 #[test]
228 fn an_all_zero_seed_is_refused() {
229 assert_eq!(
230 seed_from_bytes(&[0_u8; SEED_BYTES]),
231 Err(SeedError::AllZero)
232 );
233 // One non-zero byte is enough to be a real seed: the refusal is about a broken RNG
234 // producing an all-zero buffer, not about weak-looking keys in general.
235 let mut nearly = [0_u8; SEED_BYTES];
236 nearly[SEED_BYTES - 1] = 1;
237 assert!(seed_from_bytes(&nearly).is_ok());
238 }
239
240 /// No refusal may carry the material it rejected.
241 ///
242 /// The payload this validates sits beside the agent token, and `docs/TRUST.md` promises
243 /// no secret reaches a log line or a response body.
244 #[test]
245 fn a_refusal_never_quotes_the_bytes_it_rejected() {
246 let secret = [0xAB_u8; 31];
247 let message = seed_from_bytes(&secret).expect_err("refused").to_string();
248 assert!(!message.contains("171"), "{message}");
249 assert!(!message.contains("ab"), "{message}");
250 assert!(!message.contains("AB"), "{message}");
251 }
252
253 /// The two payload keys are distinct, and neither collides with the payload's other keys.
254 ///
255 /// A collision would make one value overwrite the other in the payload object, and the
256 /// symptom would be a failed handshake rather than anything naming a key.
257 #[test]
258 fn the_payload_keys_are_distinct_from_each_other_and_from_the_token() {
259 assert_ne!(SEED_KEY, HOST_PUBLIC_KEY_KEY);
260 for key in [SEED_KEY, HOST_PUBLIC_KEY_KEY] {
261 assert_ne!(key, "agent_token");
262 assert_ne!(key, "env");
263 }
264 }
265
266 /// The identity payload budget is what a caller near the ceiling needs, so it is asserted
267 /// against the real composition rather than trusted as a comment.
268 ///
269 /// The number is checked here against its two 44-character base64 values and JSON framing;
270 /// `microvms-core` asserts the same constant against the payload it actually builds. Two
271 /// independent checks of one number, because the ceiling it is spent against is measured
272 /// and hard — 4096 bytes, where 4097 fails.
273 #[test]
274 fn the_identity_payload_budget_matches_its_parts() {
275 // Two keys, two quoted 44-char base64 values, two colons, two commas, four quote pairs.
276 let value_chars = 44 * 2;
277 let key_chars = SEED_KEY.len() + HOST_PUBLIC_KEY_KEY.len();
278 // Per pair: quotes around the key (2), a colon (1), quotes around the value (2), and a
279 // leading comma (1) — six characters of framing each.
280 let framing = 6 * 2;
281 assert_eq!(
282 IDENTITY_PAYLOAD_BYTES,
283 value_chars + key_chars + framing,
284 "the stated budget must equal the bytes the two halves really cost"
285 );
286 }
287}