diff --git a/protobufs/arbiter.proto b/protobufs/arbiter.proto index ad58016..21db1fa 100644 --- a/protobufs/arbiter.proto +++ b/protobufs/arbiter.proto @@ -22,12 +22,14 @@ message UserAgentRequest { oneof payload { arbiter.auth.ClientMessage auth_message = 1; CertRotationAck cert_rotation_ack = 2; + UnsealRequest unseal_request = 3; } } message UserAgentResponse { oneof payload { arbiter.auth.ServerMessage auth_message = 1; CertRotationNotification cert_rotation_notification = 2; + UnsealResponse unseal_response = 3; } } @@ -62,6 +64,50 @@ message CertRotationAck { bool cert_saved = 3; } +// Vault Unseal Protocol (X25519 ECDH + ChaCha20Poly1305) +message UnsealRequest { + oneof payload { + EphemeralKeyRequest ephemeral_key_request = 1; + SealedPassword sealed_password = 2; + } +} + +message UnsealResponse { + oneof payload { + EphemeralKeyResponse ephemeral_key_response = 1; + UnsealResult unseal_result = 2; + } +} + +message EphemeralKeyRequest {} + +message EphemeralKeyResponse { + // Server's X25519 ephemeral public key (32 bytes) + bytes server_pubkey = 1; + + // Unix timestamp when this key expires (60 seconds from generation) + int64 expires_at = 2; +} + +message SealedPassword { + // Client's X25519 ephemeral public key (32 bytes) + bytes client_pubkey = 1; + + // ChaCha20Poly1305 encrypted password (ciphertext + tag) + bytes encrypted_password = 2; + + // 12-byte nonce for ChaCha20Poly1305 + bytes nonce = 3; +} + +message UnsealResult { + // Whether unseal was successful + bool success = 1; + + // Error message if unseal failed + optional string error_message = 2; +} + service ArbiterService { rpc Client(stream ClientRequest) returns (stream ClientResponse); rpc UserAgent(stream UserAgentRequest) returns (stream UserAgentResponse); diff --git a/server/Cargo.lock b/server/Cargo.lock index 7d49aea..572694a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -102,6 +102,7 @@ dependencies = [ "tokio-stream", "tonic", "tracing", + "x25519-dalek", "zeroize", ] @@ -488,6 +489,21 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0-pre.6" @@ -498,7 +514,7 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.11.1", - "fiat-crypto", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -736,7 +752,7 @@ version = "3.0.0-pre.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0-pre.6", "ed25519", "rand_core 0.10.0", "sha2", @@ -772,6 +788,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -3079,6 +3101,18 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -3111,6 +3145,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zmij" diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index fc950d5..324ea89 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -47,6 +47,7 @@ argon2 = { version = "0.5", features = ["std"] } kameo.workspace = true hex = "0.4.3" chacha20poly1305 = "0.10.1" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } [dev-dependencies] test-log = { version = "0.2", default-features = false, features = ["trace"] } diff --git a/server/crates/arbiter-server/src/context.rs b/server/crates/arbiter-server/src/context.rs index af9e1e8..68c86a3 100644 --- a/server/crates/arbiter-server/src/context.rs +++ b/server/crates/arbiter-server/src/context.rs @@ -30,6 +30,7 @@ use crate::{ pub(crate) mod bootstrap; pub(crate) mod lease; pub(crate) mod tls; +pub(crate) mod unseal; #[derive(Error, Debug, Diagnostic)] pub enum InitError { diff --git a/server/crates/arbiter-server/src/context/unseal.rs b/server/crates/arbiter-server/src/context/unseal.rs new file mode 100644 index 0000000..6df3b9e --- /dev/null +++ b/server/crates/arbiter-server/src/context/unseal.rs @@ -0,0 +1,161 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use miette::Diagnostic; +use secrecy::{ExposeSecret, SecretBox}; +use thiserror::Error; +use x25519_dalek::{PublicKey, StaticSecret}; + +const EPHEMERAL_KEY_LIFETIME_SECS: u64 = 60; + +#[derive(Error, Debug, Diagnostic)] +pub enum UnsealError { + #[error("Invalid public key")] + #[diagnostic(code(arbiter_server::unseal::invalid_pubkey))] + InvalidPublicKey, +} + +/// Ephemeral X25519 keypair for secure password transmission +/// +/// Generated on-demand when client requests unseal. Expires after 60 seconds. +/// Uses StaticSecret stored in SecretBox for automatic zeroization on drop. +pub struct EphemeralKeyPair { + /// Secret key stored securely + secret: SecretBox, + public: PublicKey, + expires_at: u64, +} + +impl EphemeralKeyPair { + /// Generate new ephemeral X25519 keypair + pub fn generate() -> Self { + // Generate random 32 bytes + let secret_bytes = rand::random::<[u8; 32]>(); + let secret = StaticSecret::from(secret_bytes); + let public = PublicKey::from(&secret); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time before UNIX epoch") + .as_secs(); + + Self { + secret: SecretBox::new(Box::new(secret)), + public, + expires_at: now + EPHEMERAL_KEY_LIFETIME_SECS, + } + } + + /// Check if this ephemeral key has expired + pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time before UNIX epoch") + .as_secs(); + + now > self.expires_at + } + + /// Get expiration timestamp (Unix epoch seconds) + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Get public key as bytes for transmission to client + pub fn public_bytes(&self) -> Vec { + self.public.as_bytes().to_vec() + } + + /// Perform Diffie-Hellman key exchange with client's public key + /// + /// Returns 32-byte shared secret for ChaCha20Poly1305 encryption + pub fn perform_dh(&self, client_pubkey: &[u8]) -> Result<[u8; 32], UnsealError> { + // Parse client public key + let client_public = PublicKey::from( + <[u8; 32]>::try_from(client_pubkey).map_err(|_| UnsealError::InvalidPublicKey)?, + ); + + // Perform ECDH + let shared_secret = self.secret.expose_secret().diffie_hellman(&client_public); + + Ok(shared_secret.to_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ephemeral_keypair_generation() { + let keypair = EphemeralKeyPair::generate(); + + // Public key should be 32 bytes + assert_eq!(keypair.public_bytes().len(), 32); + + // Should not be expired immediately + assert!(!keypair.is_expired()); + + // Expiration should be ~60 seconds in future + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let time_until_expiry = keypair.expires_at() - now; + assert!(time_until_expiry >= 59 && time_until_expiry <= 61); + } + + #[test] + fn test_perform_dh_with_valid_key() { + let server_keypair = EphemeralKeyPair::generate(); + let client_secret_bytes = rand::random::<[u8; 32]>(); + let client_secret = StaticSecret::from(client_secret_bytes); + let client_public = PublicKey::from(&client_secret); + + // Server performs DH + let server_shared_secret = server_keypair + .perform_dh(client_public.as_bytes()) + .expect("DH should succeed"); + + // Client performs DH + let client_shared_secret = client_secret.diffie_hellman(&server_keypair.public); + + // Shared secrets should match + assert_eq!(server_shared_secret, client_shared_secret.to_bytes()); + assert_eq!(server_shared_secret.len(), 32); + } + + #[test] + fn test_perform_dh_with_invalid_key() { + let keypair = EphemeralKeyPair::generate(); + + // Try with invalid length + let invalid_key = vec![1, 2, 3]; + let result = keypair.perform_dh(&invalid_key); + assert!(result.is_err()); + + // Try with wrong length (not 32 bytes) + let invalid_key = vec![0u8; 16]; + let result = keypair.perform_dh(&invalid_key); + assert!(result.is_err()); + } + + #[test] + fn test_different_keypairs_produce_different_shared_secrets() { + let server_keypair1 = EphemeralKeyPair::generate(); + let server_keypair2 = EphemeralKeyPair::generate(); + + let client_secret_bytes = rand::random::<[u8; 32]>(); + let client_secret = StaticSecret::from(client_secret_bytes); + let client_public = PublicKey::from(&client_secret); + + let shared1 = server_keypair1 + .perform_dh(client_public.as_bytes()) + .unwrap(); + let shared2 = server_keypair2 + .perform_dh(client_public.as_bytes()) + .unwrap(); + + // Different server keys should produce different shared secrets + assert_ne!(shared1, shared2); + } +}