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}