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_totick.
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.