microvms-core 0.10.0

The MicroVMs control-plane and in-VM client: every trap closure, the cost engine, the sizing model
Documentation
// SPDX-License-Identifier: Apache-2.0
//! The host's half of the tunnel identity proof: seed generation, derivation, and the pin.
//!
//! `protocol::identity` carries the wire contract and the full design argument (why Noise KK
//! rather than rustls — a measured cross-compile constraint on the daemon's shipping target —
//! and the honest ptrace limit). This module is what a *launcher* holds: the two seeds it
//! generated, the payload fields they become, and the pin it stores to recognise its VM later.
//!
//! # Two seeds, and who learns what
//!
//! At launch the host generates two 32-byte x25519 static secrets:
//!
//! * the **VM seed**, delivered to the daemon in the run-hook payload. After the launch the
//!   host needs only its *public* half — the pin — and the secret half should not outlive the
//!   launch call on the host side.
//! * the **host seed**, which never leaves this process's trust domain. Its public half is
//!   delivered to the daemon, which pins it in return. Holding this secret is what
//!   distinguishes the launching host from anyone else with the agent token.
//!
//! Mutual authentication falls out: the client proves it holds the host secret, the daemon
//! proves it holds the VM seed, and each side pinned the other's public half before the first
//! byte moved.

use protocol::identity::{SEED_BYTES, seed_from_bytes};

use crate::error::{Error, ErrorKind};

/// One launch's identity material, generated by [`LaunchIdentity::generate`].
///
/// Holds both secret halves, so it lives exactly as long as the launch needs it: the payload
/// fields are read out of it before the call, and [`LaunchIdentity::keep`] converts it into
/// the durable [`TunnelIdentity`] — which keeps the host secret and the *public* VM pin, and
/// drops the VM secret on the floor where it belongs.
pub struct LaunchIdentity {
    vm_seed: [u8; SEED_BYTES],
    host_seed: [u8; SEED_BYTES],
}

/// Prints nothing but the fact that material exists. Both fields are private keys, and a
/// derived `Debug` would put them into any log line that formats a launch request.
impl std::fmt::Debug for LaunchIdentity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("LaunchIdentity(<two seeds>)")
    }
}

impl LaunchIdentity {
    /// Fresh material from the OS random pool.
    ///
    /// The all-zero retry is not reachable from a working pool; it exists because the
    /// protocol crate refuses a zero seed (a shared identity proves nothing), and this
    /// constructor must never hand out material the daemon would reject at bootstrap.
    pub fn generate() -> Result<Self, Error> {
        let mut vm_seed = [0_u8; SEED_BYTES];
        let mut host_seed = [0_u8; SEED_BYTES];
        getrandom::fill(&mut vm_seed)
            .and_then(|()| getrandom::fill(&mut host_seed))
            .map_err(|error| {
                Error::new(
                    ErrorKind::Unexpected,
                    format!("the OS random pool is unavailable: {error}"),
                )
            })?;
        // `seed_from_bytes` is the shared validation the daemon runs at bootstrap; running it
        // here makes "the host accepted what the daemon will refuse" unrepresentable.
        seed_from_bytes(&vm_seed)
            .and_then(|_| seed_from_bytes(&host_seed))
            .map_err(|error| Error::new(ErrorKind::Unexpected, error.to_string()))?;
        Ok(Self { vm_seed, host_seed })
    }

    /// A deterministic instance for tests, never for launches: both seeds are the caller's.
    #[cfg(test)]
    pub fn from_seeds(vm_seed: [u8; SEED_BYTES], host_seed: [u8; SEED_BYTES]) -> Self {
        Self { vm_seed, host_seed }
    }

    /// The payload value under [`protocol::identity::SEED_KEY`]: the VM seed, base64.
    pub fn seed_field(&self) -> String {
        use base64::Engine as _;
        base64::engine::general_purpose::STANDARD.encode(self.vm_seed)
    }

    /// The payload value under [`protocol::identity::HOST_PUBLIC_KEY_KEY`]: the host public
    /// key, base64. The *public* half — the secret never appears in any payload.
    pub fn host_public_field(&self) -> String {
        use base64::Engine as _;
        base64::engine::general_purpose::STANDARD.encode(public_of(&self.host_seed))
    }

    /// Converts into what outlives the launch: the host secret and the VM's public pin.
    ///
    /// The VM's *secret* half ends here. After this call the host can verify its VM forever
    /// and impersonate it never, which is the least-privilege shape for a record that will
    /// sit in a ledger file: a stolen record lets an attacker *check* a VM's identity, not
    /// forge one.
    pub fn keep(self) -> TunnelIdentity {
        TunnelIdentity {
            host_seed: self.host_seed,
            vm_public_key: public_of(&self.vm_seed),
        }
    }
}

/// What a launcher keeps to verify its VM later: its own secret, and the VM's public pin.
#[derive(Clone)]
pub struct TunnelIdentity {
    host_seed: [u8; SEED_BYTES],
    vm_public_key: [u8; SEED_BYTES],
}

/// The pin is printable; the host seed is not.
impl std::fmt::Debug for TunnelIdentity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "TunnelIdentity(vm_public_key: {}, host_seed: <secret>)",
            const_hex::encode(self.vm_public_key)
        )
    }
}

impl TunnelIdentity {
    /// Rebuilds from the two values a ledger record carries.
    ///
    /// `host_seed` is validated with the shared rules; the public key only for length,
    /// because a public key has no all-zero hazard — it is derived, not generated, and the
    /// zero check exists to catch a broken RNG at generation time.
    pub fn from_parts(host_seed: &[u8], vm_public_key: &[u8]) -> Result<Self, Error> {
        let host_seed = seed_from_bytes(host_seed)
            .map_err(|error| Error::invalid_arg(format!("the stored host seed: {error}")))?;
        let vm_public_key: [u8; SEED_BYTES] = vm_public_key.try_into().map_err(|_| {
            Error::invalid_arg(format!(
                "the stored VM public key is {} bytes, and an x25519 public key is exactly \
                 {SEED_BYTES}",
                vm_public_key.len()
            ))
        })?;
        Ok(Self {
            host_seed,
            vm_public_key,
        })
    }

    /// The VM's public key — the pin. Safe to store, print, and compare.
    pub fn vm_public_key(&self) -> [u8; SEED_BYTES] {
        self.vm_public_key
    }

    /// The host's secret half, for a ledger record.
    ///
    /// Exposed because the CLI's registry must persist it (0600, beside the agent token it
    /// already holds) for `tunnel --name` to verify in a later process. Callers other than a
    /// persistence layer have no business with it — the initiator below is the way to *use*
    /// the identity.
    pub fn host_seed(&self) -> [u8; SEED_BYTES] {
        self.host_seed
    }

    /// The host secret in its wire spelling — standard base64 — for a registry record.
    ///
    /// Here rather than in each consumer, so the CLI (whose manifest deliberately carries no
    /// encoding crates) and the record it writes cannot disagree with
    /// [`TunnelIdentity::from_encoded_parts`] about alphabet or padding.
    pub fn host_seed_base64(&self) -> String {
        use base64::Engine as _;
        base64::engine::general_purpose::STANDARD.encode(self.host_seed)
    }

    /// The pin in its wire spelling.
    pub fn vm_public_key_base64(&self) -> String {
        use base64::Engine as _;
        base64::engine::general_purpose::STANDARD.encode(self.vm_public_key)
    }

    /// [`TunnelIdentity::from_parts`], from the base64 spellings a record or a flag carries.
    pub fn from_encoded_parts(host_seed: &str, vm_public_key: &str) -> Result<Self, Error> {
        use base64::Engine as _;
        let b64 = base64::engine::general_purpose::STANDARD;
        let host = b64.decode(host_seed).map_err(|_| {
            Error::invalid_arg(
                "the identity host seed is not valid base64. It is the identityHostSeed \
                 value from `run --identity`'s envelope, or the registry record's \
                 identityHostSeed field."
                    .to_string(),
            )
        })?;
        let vm = b64.decode(vm_public_key).map_err(|_| {
            Error::invalid_arg(
                "the identity VM public key is not valid base64. It is the \
                 identityVmPublicKey value from `run --identity`'s envelope."
                    .to_string(),
            )
        })?;
        Self::from_parts(&host, &vm)
    }

    /// A Noise initiator bound to this identity: proves the host, verifies the pin.
    ///
    /// Built per connection — a `HandshakeState` carries nonces and must never be reused.
    pub fn initiator(&self) -> Result<snow::HandshakeState, Error> {
        snow::Builder::new(
            protocol::identity::NOISE_PATTERN
                .parse()
                .expect("the pattern is a compile-time constant the protocol crate tests"),
        )
        .local_private_key(&self.host_seed)
        .and_then(|builder| builder.remote_public_key(&self.vm_public_key))
        .and_then(|builder| builder.build_initiator())
        .map_err(|error| {
            Error::new(
                ErrorKind::Unexpected,
                format!("could not build the identity initiator: {error}"),
            )
        })
    }
}

/// The x25519 public half of a 32-byte static secret.
fn public_of(seed: &[u8; SEED_BYTES]) -> [u8; SEED_BYTES] {
    *x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(*seed)).as_bytes()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generated_material_is_fresh_and_valid() {
        let first = LaunchIdentity::generate().expect("the pool is available");
        let second = LaunchIdentity::generate().expect("the pool is available");
        // Distinct across calls — the property a seeded identity exists to provide.
        assert_ne!(first.seed_field(), second.seed_field());
        assert_ne!(first.host_public_field(), second.host_public_field());
        // And the fields are the size the payload budget accounts for: 44 chars of base64.
        assert_eq!(first.seed_field().len(), 44);
        assert_eq!(first.host_public_field().len(), 44);
    }

    /// `keep` retains exactly the two values that verify and drops the one that impersonates.
    #[test]
    fn keeping_an_identity_drops_the_vm_secret() {
        let vm_seed = [7_u8; SEED_BYTES];
        let host_seed = [9_u8; SEED_BYTES];
        let kept = LaunchIdentity::from_seeds(vm_seed, host_seed).keep();

        assert_eq!(kept.vm_public_key(), public_of(&vm_seed));
        assert_eq!(kept.host_seed(), host_seed);
        // The pin is the *public* key: a record holding the seed itself would let anyone who
        // read the file impersonate the VM to its own launcher.
        assert_ne!(kept.vm_public_key(), vm_seed);
    }

    #[test]
    fn a_kept_identity_round_trips_through_its_stored_parts() {
        let kept = LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]).keep();
        let restored = TunnelIdentity::from_parts(&kept.host_seed(), &kept.vm_public_key())
            .expect("what was stored restores");
        assert_eq!(restored.vm_public_key(), kept.vm_public_key());
        assert_eq!(restored.host_seed(), kept.host_seed());
    }

    #[test]
    fn stored_parts_of_the_wrong_shape_are_refused_with_the_length() {
        let error = TunnelIdentity::from_parts(&[1_u8; 16], &[2_u8; 32])
            .expect_err("a short host seed is refused");
        assert!(error.to_string().contains("16"), "{error}");

        let error = TunnelIdentity::from_parts(&[1_u8; 32], &[2_u8; 31])
            .expect_err("a short public key is refused");
        assert!(error.to_string().contains("31"), "{error}");
    }

    /// The debug renderings never contain a secret, in hex or in base64.
    #[test]
    fn debug_prints_the_pin_and_never_a_secret() {
        let launch = LaunchIdentity::from_seeds([0x5A_u8; 32], [0x3C_u8; 32]);
        let rendered = format!("{launch:?}");
        assert!(!rendered.to_lowercase().contains("5a5a"), "{rendered}");
        assert!(!rendered.to_lowercase().contains("3c3c"), "{rendered}");

        let kept = launch.keep();
        let rendered = format!("{kept:?}");
        // The pin is there — it is what a human correlates against `NameRecord` — and the
        // host seed is not.
        assert!(
            rendered.contains(&const_hex::encode(kept.vm_public_key())),
            "{rendered}"
        );
        assert!(!rendered.to_lowercase().contains("3c3c3c"), "{rendered}");
    }

    /// The client's derivation agrees with the daemon's, end to end in one process.
    ///
    /// The two sides derive independently — this crate with `x25519-dalek` directly, the
    /// daemon through the same crate but its own code path — and this is the assertion that
    /// the payload fields this module emits produce a daemon whose handshake this module's
    /// initiator completes.
    #[test]
    fn the_initiator_completes_against_a_responder_built_from_the_payload_fields() {
        use base64::Engine as _;
        let b64 = base64::engine::general_purpose::STANDARD;

        let launch = LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
        // What the daemon receives: the two payload fields, decoded its way.
        let vm_seed_bytes: [u8; 32] = b64
            .decode(launch.seed_field())
            .expect("valid base64")
            .try_into()
            .expect("32 bytes");
        let host_public_bytes: [u8; 32] = b64
            .decode(launch.host_public_field())
            .expect("valid base64")
            .try_into()
            .expect("32 bytes");

        let mut responder =
            snow::Builder::new(protocol::identity::NOISE_PATTERN.parse().expect("parses"))
                .local_private_key(&vm_seed_bytes)
                .expect("a 32-byte secret")
                .remote_public_key(&host_public_bytes)
                .expect("a 32-byte key")
                .build_responder()
                .expect("builds");

        let kept = launch.keep();
        let mut initiator = kept.initiator().expect("builds");

        let mut buffer = [0_u8; 1024];
        let mut scratch = [0_u8; 1024];
        let written = initiator.write_message(&[], &mut buffer).expect("writes");
        responder
            .read_message(&buffer[..written], &mut scratch)
            .expect("the daemon accepts the launching host");
        let written = responder.write_message(&[], &mut buffer).expect("writes");
        initiator
            .read_message(&buffer[..written], &mut scratch)
            .expect("the host accepts its own VM");

        // Both reach transport mode: the handshake genuinely completed.
        let mut host_transport = initiator.into_transport_mode().expect("transport");
        let mut vm_transport = responder.into_transport_mode().expect("transport");
        let sent = host_transport
            .write_message(b"ping", &mut buffer)
            .expect("encrypts");
        let received = vm_transport
            .read_message(&buffer[..sent], &mut scratch)
            .expect("decrypts");
        assert_eq!(&scratch[..received], b"ping");
    }
}