Skip to main content

microvms_core/
identity.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The host's half of the tunnel identity proof: seed generation, derivation, and the pin.
3//!
4//! `protocol::identity` carries the wire contract and the full design argument (why Noise KK
5//! rather than rustls — a measured cross-compile constraint on the daemon's shipping target —
6//! and the honest ptrace limit). This module is what a *launcher* holds: the two seeds it
7//! generated, the payload fields they become, and the pin it stores to recognise its VM later.
8//!
9//! # Two seeds, and who learns what
10//!
11//! At launch the host generates two 32-byte x25519 static secrets:
12//!
13//! * the **VM seed**, delivered to the daemon in the run-hook payload. After the launch the
14//!   host needs only its *public* half — the pin — and the secret half should not outlive the
15//!   launch call on the host side.
16//! * the **host seed**, which never leaves this process's trust domain. Its public half is
17//!   delivered to the daemon, which pins it in return. Holding this secret is what
18//!   distinguishes the launching host from anyone else with the agent token.
19//!
20//! Mutual authentication falls out: the client proves it holds the host secret, the daemon
21//! proves it holds the VM seed, and each side pinned the other's public half before the first
22//! byte moved.
23
24use protocol::identity::{SEED_BYTES, seed_from_bytes};
25
26use crate::error::{Error, ErrorKind};
27
28/// One launch's identity material, generated by [`LaunchIdentity::generate`].
29///
30/// Holds both secret halves, so it lives exactly as long as the launch needs it: the payload
31/// fields are read out of it before the call, and [`LaunchIdentity::keep`] converts it into
32/// the durable [`TunnelIdentity`] — which keeps the host secret and the *public* VM pin, and
33/// drops the VM secret on the floor where it belongs.
34pub struct LaunchIdentity {
35    vm_seed: [u8; SEED_BYTES],
36    host_seed: [u8; SEED_BYTES],
37}
38
39/// Prints nothing but the fact that material exists. Both fields are private keys, and a
40/// derived `Debug` would put them into any log line that formats a launch request.
41impl std::fmt::Debug for LaunchIdentity {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str("LaunchIdentity(<two seeds>)")
44    }
45}
46
47impl LaunchIdentity {
48    /// Fresh material from the OS random pool.
49    ///
50    /// The all-zero retry is not reachable from a working pool; it exists because the
51    /// protocol crate refuses a zero seed (a shared identity proves nothing), and this
52    /// constructor must never hand out material the daemon would reject at bootstrap.
53    pub fn generate() -> Result<Self, Error> {
54        let mut vm_seed = [0_u8; SEED_BYTES];
55        let mut host_seed = [0_u8; SEED_BYTES];
56        getrandom::fill(&mut vm_seed)
57            .and_then(|()| getrandom::fill(&mut host_seed))
58            .map_err(|error| {
59                Error::new(
60                    ErrorKind::Unexpected,
61                    format!("the OS random pool is unavailable: {error}"),
62                )
63            })?;
64        // `seed_from_bytes` is the shared validation the daemon runs at bootstrap; running it
65        // here makes "the host accepted what the daemon will refuse" unrepresentable.
66        seed_from_bytes(&vm_seed)
67            .and_then(|_| seed_from_bytes(&host_seed))
68            .map_err(|error| Error::new(ErrorKind::Unexpected, error.to_string()))?;
69        Ok(Self { vm_seed, host_seed })
70    }
71
72    /// A deterministic instance for tests, never for launches: both seeds are the caller's.
73    #[cfg(test)]
74    pub fn from_seeds(vm_seed: [u8; SEED_BYTES], host_seed: [u8; SEED_BYTES]) -> Self {
75        Self { vm_seed, host_seed }
76    }
77
78    /// The payload value under [`protocol::identity::SEED_KEY`]: the VM seed, base64.
79    pub fn seed_field(&self) -> String {
80        use base64::Engine as _;
81        base64::engine::general_purpose::STANDARD.encode(self.vm_seed)
82    }
83
84    /// The payload value under [`protocol::identity::HOST_PUBLIC_KEY_KEY`]: the host public
85    /// key, base64. The *public* half — the secret never appears in any payload.
86    pub fn host_public_field(&self) -> String {
87        use base64::Engine as _;
88        base64::engine::general_purpose::STANDARD.encode(public_of(&self.host_seed))
89    }
90
91    /// Converts into what outlives the launch: the host secret and the VM's public pin.
92    ///
93    /// The VM's *secret* half ends here. After this call the host can verify its VM forever
94    /// and impersonate it never, which is the least-privilege shape for a record that will
95    /// sit in a ledger file: a stolen record lets an attacker *check* a VM's identity, not
96    /// forge one.
97    pub fn keep(self) -> TunnelIdentity {
98        TunnelIdentity {
99            host_seed: self.host_seed,
100            vm_public_key: public_of(&self.vm_seed),
101        }
102    }
103}
104
105/// What a launcher keeps to verify its VM later: its own secret, and the VM's public pin.
106#[derive(Clone)]
107pub struct TunnelIdentity {
108    host_seed: [u8; SEED_BYTES],
109    vm_public_key: [u8; SEED_BYTES],
110}
111
112/// The pin is printable; the host seed is not.
113impl std::fmt::Debug for TunnelIdentity {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        write!(
116            f,
117            "TunnelIdentity(vm_public_key: {}, host_seed: <secret>)",
118            const_hex::encode(self.vm_public_key)
119        )
120    }
121}
122
123impl TunnelIdentity {
124    /// Rebuilds from the two values a ledger record carries.
125    ///
126    /// `host_seed` is validated with the shared rules; the public key only for length,
127    /// because a public key has no all-zero hazard — it is derived, not generated, and the
128    /// zero check exists to catch a broken RNG at generation time.
129    pub fn from_parts(host_seed: &[u8], vm_public_key: &[u8]) -> Result<Self, Error> {
130        let host_seed = seed_from_bytes(host_seed)
131            .map_err(|error| Error::invalid_arg(format!("the stored host seed: {error}")))?;
132        let vm_public_key: [u8; SEED_BYTES] = vm_public_key.try_into().map_err(|_| {
133            Error::invalid_arg(format!(
134                "the stored VM public key is {} bytes, and an x25519 public key is exactly \
135                 {SEED_BYTES}",
136                vm_public_key.len()
137            ))
138        })?;
139        Ok(Self {
140            host_seed,
141            vm_public_key,
142        })
143    }
144
145    /// The VM's public key — the pin. Safe to store, print, and compare.
146    pub fn vm_public_key(&self) -> [u8; SEED_BYTES] {
147        self.vm_public_key
148    }
149
150    /// The host's secret half, for a ledger record.
151    ///
152    /// Exposed because the CLI's registry must persist it (0600, beside the agent token it
153    /// already holds) for `tunnel --name` to verify in a later process. Callers other than a
154    /// persistence layer have no business with it — the initiator below is the way to *use*
155    /// the identity.
156    pub fn host_seed(&self) -> [u8; SEED_BYTES] {
157        self.host_seed
158    }
159
160    /// The host secret in its wire spelling — standard base64 — for a registry record.
161    ///
162    /// Here rather than in each consumer, so the CLI (whose manifest deliberately carries no
163    /// encoding crates) and the record it writes cannot disagree with
164    /// [`TunnelIdentity::from_encoded_parts`] about alphabet or padding.
165    pub fn host_seed_base64(&self) -> String {
166        use base64::Engine as _;
167        base64::engine::general_purpose::STANDARD.encode(self.host_seed)
168    }
169
170    /// The pin in its wire spelling.
171    pub fn vm_public_key_base64(&self) -> String {
172        use base64::Engine as _;
173        base64::engine::general_purpose::STANDARD.encode(self.vm_public_key)
174    }
175
176    /// [`TunnelIdentity::from_parts`], from the base64 spellings a record or a flag carries.
177    pub fn from_encoded_parts(host_seed: &str, vm_public_key: &str) -> Result<Self, Error> {
178        use base64::Engine as _;
179        let b64 = base64::engine::general_purpose::STANDARD;
180        let host = b64.decode(host_seed).map_err(|_| {
181            Error::invalid_arg(
182                "the identity host seed is not valid base64. It is the identityHostSeed \
183                 value from `run --identity`'s envelope, or the registry record's \
184                 identityHostSeed field."
185                    .to_string(),
186            )
187        })?;
188        let vm = b64.decode(vm_public_key).map_err(|_| {
189            Error::invalid_arg(
190                "the identity VM public key is not valid base64. It is the \
191                 identityVmPublicKey value from `run --identity`'s envelope."
192                    .to_string(),
193            )
194        })?;
195        Self::from_parts(&host, &vm)
196    }
197
198    /// A Noise initiator bound to this identity: proves the host, verifies the pin.
199    ///
200    /// Built per connection — a `HandshakeState` carries nonces and must never be reused.
201    pub fn initiator(&self) -> Result<snow::HandshakeState, Error> {
202        snow::Builder::new(
203            protocol::identity::NOISE_PATTERN
204                .parse()
205                .expect("the pattern is a compile-time constant the protocol crate tests"),
206        )
207        .local_private_key(&self.host_seed)
208        .and_then(|builder| builder.remote_public_key(&self.vm_public_key))
209        .and_then(|builder| builder.build_initiator())
210        .map_err(|error| {
211            Error::new(
212                ErrorKind::Unexpected,
213                format!("could not build the identity initiator: {error}"),
214            )
215        })
216    }
217}
218
219/// The x25519 public half of a 32-byte static secret.
220fn public_of(seed: &[u8; SEED_BYTES]) -> [u8; SEED_BYTES] {
221    *x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(*seed)).as_bytes()
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn generated_material_is_fresh_and_valid() {
230        let first = LaunchIdentity::generate().expect("the pool is available");
231        let second = LaunchIdentity::generate().expect("the pool is available");
232        // Distinct across calls — the property a seeded identity exists to provide.
233        assert_ne!(first.seed_field(), second.seed_field());
234        assert_ne!(first.host_public_field(), second.host_public_field());
235        // And the fields are the size the payload budget accounts for: 44 chars of base64.
236        assert_eq!(first.seed_field().len(), 44);
237        assert_eq!(first.host_public_field().len(), 44);
238    }
239
240    /// `keep` retains exactly the two values that verify and drops the one that impersonates.
241    #[test]
242    fn keeping_an_identity_drops_the_vm_secret() {
243        let vm_seed = [7_u8; SEED_BYTES];
244        let host_seed = [9_u8; SEED_BYTES];
245        let kept = LaunchIdentity::from_seeds(vm_seed, host_seed).keep();
246
247        assert_eq!(kept.vm_public_key(), public_of(&vm_seed));
248        assert_eq!(kept.host_seed(), host_seed);
249        // The pin is the *public* key: a record holding the seed itself would let anyone who
250        // read the file impersonate the VM to its own launcher.
251        assert_ne!(kept.vm_public_key(), vm_seed);
252    }
253
254    #[test]
255    fn a_kept_identity_round_trips_through_its_stored_parts() {
256        let kept = LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]).keep();
257        let restored = TunnelIdentity::from_parts(&kept.host_seed(), &kept.vm_public_key())
258            .expect("what was stored restores");
259        assert_eq!(restored.vm_public_key(), kept.vm_public_key());
260        assert_eq!(restored.host_seed(), kept.host_seed());
261    }
262
263    #[test]
264    fn stored_parts_of_the_wrong_shape_are_refused_with_the_length() {
265        let error = TunnelIdentity::from_parts(&[1_u8; 16], &[2_u8; 32])
266            .expect_err("a short host seed is refused");
267        assert!(error.to_string().contains("16"), "{error}");
268
269        let error = TunnelIdentity::from_parts(&[1_u8; 32], &[2_u8; 31])
270            .expect_err("a short public key is refused");
271        assert!(error.to_string().contains("31"), "{error}");
272    }
273
274    /// The debug renderings never contain a secret, in hex or in base64.
275    #[test]
276    fn debug_prints_the_pin_and_never_a_secret() {
277        let launch = LaunchIdentity::from_seeds([0x5A_u8; 32], [0x3C_u8; 32]);
278        let rendered = format!("{launch:?}");
279        assert!(!rendered.to_lowercase().contains("5a5a"), "{rendered}");
280        assert!(!rendered.to_lowercase().contains("3c3c"), "{rendered}");
281
282        let kept = launch.keep();
283        let rendered = format!("{kept:?}");
284        // The pin is there — it is what a human correlates against `NameRecord` — and the
285        // host seed is not.
286        assert!(
287            rendered.contains(&const_hex::encode(kept.vm_public_key())),
288            "{rendered}"
289        );
290        assert!(!rendered.to_lowercase().contains("3c3c3c"), "{rendered}");
291    }
292
293    /// The client's derivation agrees with the daemon's, end to end in one process.
294    ///
295    /// The two sides derive independently — this crate with `x25519-dalek` directly, the
296    /// daemon through the same crate but its own code path — and this is the assertion that
297    /// the payload fields this module emits produce a daemon whose handshake this module's
298    /// initiator completes.
299    #[test]
300    fn the_initiator_completes_against_a_responder_built_from_the_payload_fields() {
301        use base64::Engine as _;
302        let b64 = base64::engine::general_purpose::STANDARD;
303
304        let launch = LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
305        // What the daemon receives: the two payload fields, decoded its way.
306        let vm_seed_bytes: [u8; 32] = b64
307            .decode(launch.seed_field())
308            .expect("valid base64")
309            .try_into()
310            .expect("32 bytes");
311        let host_public_bytes: [u8; 32] = b64
312            .decode(launch.host_public_field())
313            .expect("valid base64")
314            .try_into()
315            .expect("32 bytes");
316
317        let mut responder =
318            snow::Builder::new(protocol::identity::NOISE_PATTERN.parse().expect("parses"))
319                .local_private_key(&vm_seed_bytes)
320                .expect("a 32-byte secret")
321                .remote_public_key(&host_public_bytes)
322                .expect("a 32-byte key")
323                .build_responder()
324                .expect("builds");
325
326        let kept = launch.keep();
327        let mut initiator = kept.initiator().expect("builds");
328
329        let mut buffer = [0_u8; 1024];
330        let mut scratch = [0_u8; 1024];
331        let written = initiator.write_message(&[], &mut buffer).expect("writes");
332        responder
333            .read_message(&buffer[..written], &mut scratch)
334            .expect("the daemon accepts the launching host");
335        let written = responder.write_message(&[], &mut buffer).expect("writes");
336        initiator
337            .read_message(&buffer[..written], &mut scratch)
338            .expect("the host accepts its own VM");
339
340        // Both reach transport mode: the handshake genuinely completed.
341        let mut host_transport = initiator.into_transport_mode().expect("transport");
342        let mut vm_transport = responder.into_transport_mode().expect("transport");
343        let sent = host_transport
344            .write_message(b"ping", &mut buffer)
345            .expect("encrypts");
346        let received = vm_transport
347            .read_message(&buffer[..sent], &mut scratch)
348            .expect("decrypts");
349        assert_eq!(&scratch[..received], b"ping");
350    }
351}