Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

src/receipt.rs

audience: ai

The encrypted SSH access receipt. Shape:

  • instance host + port,
  • user,
  • per-grant SSH private key (PEM),
  • instance host key (for known_hosts),
  • grant id + valid_to tick.

Sealing uses a standard X25519-ChaCha20-Poly1305 construction: generate an ephemeral x25519 keypair, derive a shared secret with the requester’s static x25519 public key (published in the zipnet envelope), encrypt the JSON-serialised receipt with ChaCha20-Poly1305 under the shared secret, prepend the ephemeral public key. The zero-nonce is safe because the shared secret is fresh per receipt.

The requester — the only holder of the matching x25519 private key — is the only party that can decrypt. A curious provider, host, or intermediate zipnet peer cannot recover the private SSH key, the instance host, or even the grant id without breaking the construction.

//! Encrypted SSH access receipts.
//!
//! Shape of a receipt:
//!
//! ```text
//!   SshAccessReceipt {
//!       instance_host:   String,         // public DNS or IP
//!       instance_port:   u16,            // usually 22
//!       user:            String,         // usually "ec2-user" or similar
//!       ssh_key_private: Vec<u8>,        // PEM of the per-grant key
//!       ssh_host_key:    Vec<u8>,        // host public key for known_hosts
//!       grant_id:        UniqueId,
//!       valid_to:        AlmanacTick,
//!   }
//! ```
//!
//! The receipt is serialised and sealed to the
//! requester's x25519 public key (published inside the
//! zipnet envelope). Sealing uses the standard
//! X25519-ChaCha20Poly1305 construction:
//!
//! - generate an ephemeral x25519 keypair,
//! - derive a shared secret with the requester's
//!   static x25519 public key,
//! - use the shared secret as the symmetric key,
//! - ChaCha20-Poly1305 over the serialised receipt
//!   with a zero nonce (safe because each shared
//!   secret is fresh).
//!
//! The sealed blob is published by the provider via
//! `ZipnetChannel::reply`. The requester — the only
//! holder of the matching x25519 private key — is the
//! only party that can decrypt.

use anyhow::Context;
use chacha20poly1305::{
    aead::{Aead, KeyInit},
    ChaCha20Poly1305, Nonce,
};
use serde::{Deserialize, Serialize};
use x25519_dalek::{EphemeralSecret, PublicKey};

use coalition_compute::{AlmanacTick, ComputeGrant, UniqueId};

use crate::backends::ProvisionedInstance;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SshAccessReceipt {
    pub instance_host:   String,
    pub instance_port:   u16,
    pub user:            String,
    pub ssh_key_private: Vec<u8>,
    pub ssh_host_key:    Vec<u8>,
    pub grant_id:        UniqueId,
    pub valid_to:        AlmanacTick,
}

impl SshAccessReceipt {
    pub fn build(
        instance: &ProvisionedInstance,
        grant: &ComputeGrant<'_>,
    ) -> anyhow::Result<Self> {
        Ok(Self {
            instance_host:   instance.public_host.clone(),
            instance_port:   instance.ssh_port,
            user:            instance.user.clone(),
            ssh_key_private: instance.key_private.clone(),
            ssh_host_key:    instance.host_key.clone(),
            grant_id:        grant.request_id,
            valid_to:        grant.valid_to,
        })
    }

    /// Seal this receipt to the requester's x25519
    /// public key. Output is an opaque byte string
    /// suitable for publishing on zipnet.
    ///
    /// Format:
    ///
    /// ```text
    ///   [0..32]   ephemeral_x25519_public
    ///   [32..]    ChaCha20-Poly1305(shared_secret, receipt_bytes)
    /// ```
    pub fn seal_to(&self, peer_x25519_public: &[u8; 32]) -> anyhow::Result<Vec<u8>> {
        let ephemeral = EphemeralSecret::random_from_rng(rand::rngs::OsRng);
        let ephemeral_public = PublicKey::from(&ephemeral);
        let peer = PublicKey::from(*peer_x25519_public);
        let shared = ephemeral.diffie_hellman(&peer);

        let cipher = ChaCha20Poly1305::new_from_slice(shared.as_bytes())
            .context("constructing ChaCha20-Poly1305 from x25519 shared secret")?;

        let receipt_bytes = serde_json::to_vec(self)
            .context("serialising receipt")?;

        // Zero nonce is safe because shared secret is fresh.
        let nonce = Nonce::from_slice(&[0u8; 12]);
        let ct = cipher.encrypt(nonce, receipt_bytes.as_ref())
            .map_err(|e| anyhow::anyhow!("encrypt failed: {e}"))?;

        let mut out = Vec::with_capacity(32 + ct.len());
        out.extend_from_slice(ephemeral_public.as_bytes());
        out.extend_from_slice(&ct);
        Ok(out)
    }

    /// Inverse of `seal_to`. Only the requester, the
    /// holder of the x25519 static private key, can
    /// call this successfully. Provided as a test
    /// helper; the provider never calls it.
    #[cfg(test)]
    pub fn unseal_with(
        sealed: &[u8],
        static_private: &x25519_dalek::StaticSecret,
    ) -> anyhow::Result<Self> {
        anyhow::ensure!(
            sealed.len() > 32,
            "sealed blob shorter than x25519 public key",
        );
        let mut ephemeral_public = [0u8; 32];
        ephemeral_public.copy_from_slice(&sealed[..32]);
        let shared = static_private.diffie_hellman(
            &PublicKey::from(ephemeral_public),
        );

        let cipher = ChaCha20Poly1305::new_from_slice(shared.as_bytes())?;
        let nonce = Nonce::from_slice(&[0u8; 12]);
        let pt = cipher.decrypt(nonce, &sealed[32..])
            .map_err(|e| anyhow::anyhow!("decrypt failed: {e}"))?;

        let receipt: SshAccessReceipt = serde_json::from_slice(&pt)?;
        Ok(receipt)
    }
}

Up: compute-bridge.