Full Rust implementation with Ed25519 identity, X25519 ECDH handshake, ChaCha20-Poly1305 encrypted peer connections, IPC server, and relay server. Builds on Windows (MSVC), Linux, and macOS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
7.2 KiB
Rust
187 lines
7.2 KiB
Rust
/// Cryptography primitives for WASTE-rs.
|
|
///
|
|
/// Identity: Ed25519 keypair (sign/verify peer identity)
|
|
/// Session: X25519 ECDH → shared secret → ChaCha20-Poly1305 session key
|
|
/// Storage: Keys persisted as JSON in the data directory
|
|
use anyhow::{Context, Result};
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
use chrono::Utc;
|
|
use proto::{PeerId, PeerInfo};
|
|
use ring::{
|
|
aead::{self, BoundKey, Nonce, NonceSequence, CHACHA20_POLY1305},
|
|
agreement::{self, EphemeralPrivateKey, UnparsedPublicKey, X25519},
|
|
rand::{SecureRandom, SystemRandom},
|
|
signature::{self, Ed25519KeyPair, KeyPair},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use tokio::fs;
|
|
|
|
// ── Identity keypair ──────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone)]
|
|
pub struct Identity {
|
|
/// PKCS#8 Ed25519 keypair bytes (secret!)
|
|
pkcs8: Vec<u8>,
|
|
alias: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct IdentityFile {
|
|
pkcs8_base64: String,
|
|
alias: String,
|
|
}
|
|
|
|
impl Identity {
|
|
/// Load from disk, or generate a new keypair if none exists.
|
|
pub async fn load_or_create(data_dir: &str, alias: &str) -> Result<Self> {
|
|
let path = Path::new(data_dir).join("identity.json");
|
|
if path.exists() {
|
|
let raw = fs::read_to_string(&path)
|
|
.await
|
|
.context("reading identity file")?;
|
|
let file: IdentityFile = serde_json::from_str(&raw)?;
|
|
let pkcs8 = URL_SAFE_NO_PAD.decode(&file.pkcs8_base64)?;
|
|
tracing::info!("Loaded existing identity from {:?}", path);
|
|
Ok(Self { pkcs8, alias: file.alias })
|
|
} else {
|
|
tracing::info!("Generating new Ed25519 identity keypair");
|
|
let rng = SystemRandom::new();
|
|
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
|
|
.map_err(|e| anyhow::anyhow!("keygen failed: {:?}", e))?;
|
|
let identity = Self {
|
|
pkcs8: pkcs8.as_ref().to_vec(),
|
|
alias: alias.to_string(),
|
|
};
|
|
let file = IdentityFile {
|
|
pkcs8_base64: URL_SAFE_NO_PAD.encode(&identity.pkcs8),
|
|
alias: alias.to_string(),
|
|
};
|
|
fs::create_dir_all(data_dir).await?;
|
|
fs::write(&path, serde_json::to_string_pretty(&file)?).await?;
|
|
tracing::info!("Saved new identity to {:?}", path);
|
|
Ok(identity)
|
|
}
|
|
}
|
|
|
|
fn keypair(&self) -> Result<Ed25519KeyPair> {
|
|
Ed25519KeyPair::from_pkcs8(&self.pkcs8)
|
|
.map_err(|e| anyhow::anyhow!("loading keypair: {:?}", e))
|
|
}
|
|
|
|
/// Our stable peer ID (base64url of the public key).
|
|
pub fn peer_id(&self) -> PeerId {
|
|
let kp = self.keypair().expect("identity keypair valid");
|
|
PeerId(URL_SAFE_NO_PAD.encode(kp.public_key().as_ref()))
|
|
}
|
|
|
|
/// Full peer info for the Hello handshake.
|
|
pub fn peer_info(&self) -> PeerInfo {
|
|
PeerInfo {
|
|
id: self.peer_id(),
|
|
alias: self.alias.clone(),
|
|
public_key: self.peer_id().0.clone(), // public key == peer id encoding
|
|
created_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// Sign arbitrary bytes. Returns base64url signature.
|
|
pub fn sign(&self, data: &[u8]) -> Result<String> {
|
|
let kp = self.keypair()?;
|
|
let sig = kp.sign(data);
|
|
Ok(URL_SAFE_NO_PAD.encode(sig.as_ref()))
|
|
}
|
|
|
|
/// Verify a signature from a peer (given their base64url public key).
|
|
pub fn verify(public_key_b64: &str, data: &[u8], sig_b64: &str) -> Result<()> {
|
|
let pk_bytes = URL_SAFE_NO_PAD.decode(public_key_b64)?;
|
|
let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64)?;
|
|
let peer_pk = signature::UnparsedPublicKey::new(&signature::ED25519, &pk_bytes);
|
|
peer_pk
|
|
.verify(data, &sig_bytes)
|
|
.map_err(|_| anyhow::anyhow!("signature verification failed"))
|
|
}
|
|
}
|
|
|
|
// ── X25519 ECDH session key derivation ───────────────────────────────────────
|
|
|
|
/// Generate an ephemeral X25519 keypair. Returns (private_key, public_key_b64).
|
|
pub fn generate_ephemeral() -> Result<(EphemeralPrivateKey, String)> {
|
|
let rng = SystemRandom::new();
|
|
let private = EphemeralPrivateKey::generate(&X25519, &rng)
|
|
.map_err(|e| anyhow::anyhow!("X25519 keygen: {:?}", e))?;
|
|
let public_b64 = URL_SAFE_NO_PAD.encode(
|
|
private
|
|
.compute_public_key()
|
|
.map_err(|e| anyhow::anyhow!("pubkey: {:?}", e))?
|
|
.as_ref(),
|
|
);
|
|
Ok((private, public_b64))
|
|
}
|
|
|
|
/// Complete ECDH: our ephemeral private key + their ephemeral public key → 32-byte shared secret.
|
|
pub fn ecdh(our_private: EphemeralPrivateKey, their_public_b64: &str) -> Result<Vec<u8>> {
|
|
let their_bytes = URL_SAFE_NO_PAD.decode(their_public_b64)?;
|
|
let their_pub = UnparsedPublicKey::new(&X25519, &their_bytes);
|
|
agreement::agree_ephemeral(our_private, &their_pub, |key_material| {
|
|
// In production: run through HKDF here
|
|
key_material.to_vec()
|
|
})
|
|
.map_err(|e| anyhow::anyhow!("ECDH failed: {:?}", e))
|
|
}
|
|
|
|
// ── ChaCha20-Poly1305 AEAD ────────────────────────────────────────────────────
|
|
|
|
/// Encrypt plaintext with a 32-byte key. Returns (nonce_b64, ciphertext_b64).
|
|
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> {
|
|
let rng = SystemRandom::new();
|
|
let mut nonce_bytes = [0u8; 12];
|
|
rng.fill(&mut nonce_bytes)
|
|
.map_err(|_| anyhow::anyhow!("RNG fill failed"))?;
|
|
|
|
let unbound = aead::UnboundKey::new(&CHACHA20_POLY1305, key)
|
|
.map_err(|e| anyhow::anyhow!("key: {:?}", e))?;
|
|
let mut sealing = aead::SealingKey::new(unbound, OneNonce(Some(nonce_bytes)));
|
|
|
|
let mut in_out = plaintext.to_vec();
|
|
sealing
|
|
.seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)
|
|
.map_err(|e| anyhow::anyhow!("encrypt: {:?}", e))?;
|
|
|
|
Ok((
|
|
URL_SAFE_NO_PAD.encode(&nonce_bytes),
|
|
URL_SAFE_NO_PAD.encode(&in_out),
|
|
))
|
|
}
|
|
|
|
/// Decrypt ciphertext with a 32-byte key.
|
|
pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
|
let nonce_bytes: [u8; 12] = URL_SAFE_NO_PAD
|
|
.decode(nonce_b64)?
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("nonce length"))?;
|
|
|
|
let unbound = aead::UnboundKey::new(&CHACHA20_POLY1305, key)
|
|
.map_err(|e| anyhow::anyhow!("key: {:?}", e))?;
|
|
let mut opening = aead::OpeningKey::new(unbound, OneNonce(Some(nonce_bytes)));
|
|
|
|
let mut in_out = URL_SAFE_NO_PAD.decode(ciphertext_b64)?;
|
|
let plaintext = opening
|
|
.open_in_place(aead::Aad::empty(), &mut in_out)
|
|
.map_err(|_| anyhow::anyhow!("decryption failed (bad key or tampered)"))?;
|
|
|
|
Ok(plaintext.to_vec())
|
|
}
|
|
|
|
/// A single-use nonce holder for ring's NonceSequence trait.
|
|
struct OneNonce(Option<[u8; 12]>);
|
|
|
|
impl NonceSequence for OneNonce {
|
|
fn advance(&mut self) -> std::result::Result<Nonce, ring::error::Unspecified> {
|
|
self.0
|
|
.take()
|
|
.map(Nonce::assume_unique_for_key)
|
|
.ok_or(ring::error::Unspecified)
|
|
}
|
|
}
|