Skip to main content

microvms_domain/
identity.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The host's half of the tunnel identity proof: derivation and the pin.
3//!
4//! `protocol::identity` carries the wire contract and the full design argument. This module
5//! is what a *launcher* holds: the two seeds it generated, the payload fields they become,
6//! and the pin it stores to recognize its VM later. Drawing the seeds and building the Noise
7//! initiator are `microvms_core::prelude`'s, because both need the OS random pool.
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 shouldn't 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
20use protocol::identity::{SEED_BYTES, seed_from_bytes};
21
22use crate::error::Error;
23
24/// One launch's identity material, built by [`LaunchIdentity::from_seeds`].
25///
26/// Holds both secret halves, so it lives exactly as long as the launch needs it: the payload
27/// fields are read out of it before the call, and [`LaunchIdentity::keep`] converts it into
28/// the durable [`TunnelIdentity`] — which keeps the host secret and the *public* VM pin, and
29/// drops the VM secret on the floor where it belongs.
30pub struct LaunchIdentity {
31    vm_seed: [u8; SEED_BYTES],
32    host_seed: [u8; SEED_BYTES],
33}
34
35/// Prints nothing but the fact that material exists. Both fields are private keys, and a
36/// derived `Debug` would put them into any log line that formats a launch request.
37impl std::fmt::Debug for LaunchIdentity {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str("LaunchIdentity(<two seeds>)")
40    }
41}
42
43impl LaunchIdentity {
44    /// The material for `vm_seed` and `host_seed`, validated the way the daemon validates
45    /// them at bootstrap.
46    ///
47    /// The seeds are the caller's: this crate doesn't read the OS random pool (ARCH-6).
48    /// `microvms_core::prelude::LaunchIdentityExt::generate` draws fresh ones, which is what
49    /// a launch wants. `seed_from_bytes` is the shared validation the daemon runs at
50    /// bootstrap; running it here makes "the host accepted what the daemon will refuse"
51    /// unrepresentable.
52    pub fn from_seeds(
53        vm_seed: [u8; SEED_BYTES],
54        host_seed: [u8; SEED_BYTES],
55    ) -> Result<Self, Error> {
56        seed_from_bytes(&vm_seed)
57            .and_then(|_| seed_from_bytes(&host_seed))
58            .map_err(|error| Error::invalid_arg(format!("an identity seed: {error}")))?;
59        Ok(Self { vm_seed, host_seed })
60    }
61
62    /// The payload value under [`protocol::identity::SEED_KEY`]: the VM seed, base64.
63    pub fn seed_field(&self) -> String {
64        use base64::Engine as _;
65        base64::engine::general_purpose::STANDARD.encode(self.vm_seed)
66    }
67
68    /// The payload value under [`protocol::identity::HOST_PUBLIC_KEY_KEY`]: the host public
69    /// key, base64. The *public* half — the secret never appears in any payload.
70    pub fn host_public_field(&self) -> String {
71        use base64::Engine as _;
72        base64::engine::general_purpose::STANDARD.encode(public_of(&self.host_seed))
73    }
74
75    /// Converts into what outlives the launch: the host secret and the VM's public pin.
76    ///
77    /// The VM's *secret* half ends here. After this call the host can verify its VM forever
78    /// and impersonate it never, which is the least-privilege shape for a record that will
79    /// sit in a ledger file: a stolen record lets an attacker *check* a VM's identity, not
80    /// forge one.
81    pub fn keep(self) -> TunnelIdentity {
82        TunnelIdentity {
83            host_seed: self.host_seed,
84            vm_public_key: public_of(&self.vm_seed),
85        }
86    }
87}
88
89/// What a launcher keeps to verify its VM later: its own secret, and the VM's public pin.
90#[derive(Clone)]
91pub struct TunnelIdentity {
92    host_seed: [u8; SEED_BYTES],
93    vm_public_key: [u8; SEED_BYTES],
94}
95
96/// The pin is printable; the host seed is not.
97impl std::fmt::Debug for TunnelIdentity {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(
100            f,
101            "TunnelIdentity(vm_public_key: {}, host_seed: <secret>)",
102            const_hex::encode(self.vm_public_key)
103        )
104    }
105}
106
107impl TunnelIdentity {
108    /// Rebuilds from the two values a ledger record carries.
109    ///
110    /// `host_seed` is validated with the shared rules; the public key only for length,
111    /// because a public key has no all-zero hazard — it is derived, not generated, and the
112    /// zero check exists to catch a broken RNG at generation time.
113    pub fn from_parts(host_seed: &[u8], vm_public_key: &[u8]) -> Result<Self, Error> {
114        let host_seed = seed_from_bytes(host_seed)
115            .map_err(|error| Error::invalid_arg(format!("the stored host seed: {error}")))?;
116        let vm_public_key: [u8; SEED_BYTES] = vm_public_key.try_into().map_err(|_| {
117            Error::invalid_arg(format!(
118                "the stored VM public key is {} bytes, and an x25519 public key is exactly \
119                 {SEED_BYTES}",
120                vm_public_key.len()
121            ))
122        })?;
123        Ok(Self {
124            host_seed,
125            vm_public_key,
126        })
127    }
128
129    /// The VM's public key — the pin. Safe to store, print, and compare.
130    pub fn vm_public_key(&self) -> [u8; SEED_BYTES] {
131        self.vm_public_key
132    }
133
134    /// The host's secret half, for a ledger record.
135    ///
136    /// Exposed because the CLI's registry must persist it (0600, beside the agent token it
137    /// already holds) for `tunnel --name` to verify in a later process. Callers other than a
138    /// persistence layer have no business with it — the initiator below is the way to *use*
139    /// the identity.
140    pub fn host_seed(&self) -> [u8; SEED_BYTES] {
141        self.host_seed
142    }
143
144    /// The host secret in its wire spelling — standard base64 — for a registry record.
145    ///
146    /// Here rather than in each consumer, so the CLI (whose manifest deliberately carries no
147    /// encoding crates) and the record it writes cannot disagree with
148    /// [`TunnelIdentity::from_encoded_parts`] about alphabet or padding.
149    pub fn host_seed_base64(&self) -> String {
150        use base64::Engine as _;
151        base64::engine::general_purpose::STANDARD.encode(self.host_seed)
152    }
153
154    /// The pin in its wire spelling.
155    pub fn vm_public_key_base64(&self) -> String {
156        use base64::Engine as _;
157        base64::engine::general_purpose::STANDARD.encode(self.vm_public_key)
158    }
159
160    /// [`TunnelIdentity::from_parts`], from the base64 spellings a record or a flag carries.
161    pub fn from_encoded_parts(host_seed: &str, vm_public_key: &str) -> Result<Self, Error> {
162        use base64::Engine as _;
163        let b64 = base64::engine::general_purpose::STANDARD;
164        let host = b64.decode(host_seed).map_err(|_| {
165            Error::invalid_arg(
166                "the identity host seed is not valid base64. It is the identityHostSeed \
167                 value from `run --identity`'s envelope, or the registry record's \
168                 identityHostSeed field."
169                    .to_string(),
170            )
171        })?;
172        let vm = b64.decode(vm_public_key).map_err(|_| {
173            Error::invalid_arg(
174                "the identity VM public key is not valid base64. It is the \
175                 identityVmPublicKey value from `run --identity`'s envelope."
176                    .to_string(),
177            )
178        })?;
179        Self::from_parts(&host, &vm)
180    }
181}
182
183/// The x25519 public half of a 32-byte static secret.
184fn public_of(seed: &[u8; SEED_BYTES]) -> [u8; SEED_BYTES] {
185    *x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(*seed)).as_bytes()
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn launch(vm_seed: [u8; SEED_BYTES], host_seed: [u8; SEED_BYTES]) -> LaunchIdentity {
193        LaunchIdentity::from_seeds(vm_seed, host_seed).expect("valid seeds")
194    }
195
196    /// A zero seed is a working x25519 secret and a useless identity, and the daemon refuses
197    /// it at bootstrap, so the host refuses it first.
198    #[test]
199    fn an_all_zero_seed_is_refused_on_either_side() {
200        assert!(LaunchIdentity::from_seeds([0_u8; SEED_BYTES], [9_u8; SEED_BYTES]).is_err());
201        let error = LaunchIdentity::from_seeds([7_u8; SEED_BYTES], [0_u8; SEED_BYTES])
202            .expect_err("a zero host seed is refused");
203        assert_eq!(error.kind(), crate::error::ErrorKind::InvalidArg);
204    }
205
206    /// `keep` retains exactly the two values that verify and drops the one that impersonates.
207    #[test]
208    fn keeping_an_identity_drops_the_vm_secret() {
209        let vm_seed = [7_u8; SEED_BYTES];
210        let host_seed = [9_u8; SEED_BYTES];
211        let kept = launch(vm_seed, host_seed).keep();
212
213        assert_eq!(kept.vm_public_key(), public_of(&vm_seed));
214        assert_eq!(kept.host_seed(), host_seed);
215        // The pin is the *public* key: a record holding the seed itself would let anyone who
216        // read the file impersonate the VM to its own launcher.
217        assert_ne!(kept.vm_public_key(), vm_seed);
218    }
219
220    #[test]
221    fn a_kept_identity_round_trips_through_its_stored_parts() {
222        let kept = launch([7_u8; 32], [9_u8; 32]).keep();
223        let restored = TunnelIdentity::from_parts(&kept.host_seed(), &kept.vm_public_key())
224            .expect("what was stored restores");
225        assert_eq!(restored.vm_public_key(), kept.vm_public_key());
226        assert_eq!(restored.host_seed(), kept.host_seed());
227    }
228
229    #[test]
230    fn stored_parts_of_the_wrong_shape_are_refused_with_the_length() {
231        let error = TunnelIdentity::from_parts(&[1_u8; 16], &[2_u8; 32])
232            .expect_err("a short host seed is refused");
233        assert!(error.to_string().contains("16"), "{error}");
234
235        let error = TunnelIdentity::from_parts(&[1_u8; 32], &[2_u8; 31])
236            .expect_err("a short public key is refused");
237        assert!(error.to_string().contains("31"), "{error}");
238    }
239
240    /// The debug renderings never contain a secret, in hex or in base64.
241    #[test]
242    fn debug_prints_the_pin_and_never_a_secret() {
243        let launch = launch([0x5A_u8; 32], [0x3C_u8; 32]);
244        let rendered = format!("{launch:?}");
245        assert!(!rendered.to_lowercase().contains("5a5a"), "{rendered}");
246        assert!(!rendered.to_lowercase().contains("3c3c"), "{rendered}");
247
248        let kept = launch.keep();
249        let rendered = format!("{kept:?}");
250        // The pin is there — it is what a human correlates against `NameRecord` — and the
251        // host seed is not.
252        assert!(
253            rendered.contains(&const_hex::encode(kept.vm_public_key())),
254            "{rendered}"
255        );
256        assert!(!rendered.to_lowercase().contains("3c3c3c"), "{rendered}");
257    }
258}