feat(server): add X25519 ECDH for secure unseal protocol
This commit is contained in:
@@ -22,12 +22,14 @@ message UserAgentRequest {
|
|||||||
oneof payload {
|
oneof payload {
|
||||||
arbiter.auth.ClientMessage auth_message = 1;
|
arbiter.auth.ClientMessage auth_message = 1;
|
||||||
CertRotationAck cert_rotation_ack = 2;
|
CertRotationAck cert_rotation_ack = 2;
|
||||||
|
UnsealRequest unseal_request = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message UserAgentResponse {
|
message UserAgentResponse {
|
||||||
oneof payload {
|
oneof payload {
|
||||||
arbiter.auth.ServerMessage auth_message = 1;
|
arbiter.auth.ServerMessage auth_message = 1;
|
||||||
CertRotationNotification cert_rotation_notification = 2;
|
CertRotationNotification cert_rotation_notification = 2;
|
||||||
|
UnsealResponse unseal_response = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,50 @@ message CertRotationAck {
|
|||||||
bool cert_saved = 3;
|
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 {
|
service ArbiterService {
|
||||||
rpc Client(stream ClientRequest) returns (stream ClientResponse);
|
rpc Client(stream ClientRequest) returns (stream ClientResponse);
|
||||||
rpc UserAgent(stream UserAgentRequest) returns (stream UserAgentResponse);
|
rpc UserAgent(stream UserAgentRequest) returns (stream UserAgentResponse);
|
||||||
|
|||||||
52
server/Cargo.lock
generated
52
server/Cargo.lock
generated
@@ -102,6 +102,7 @@ dependencies = [
|
|||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -488,6 +489,21 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"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]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "5.0.0-pre.6"
|
version = "5.0.0-pre.6"
|
||||||
@@ -498,7 +514,7 @@ dependencies = [
|
|||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest 0.11.1",
|
"digest 0.11.1",
|
||||||
"fiat-crypto",
|
"fiat-crypto 0.3.0",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -736,7 +752,7 @@ version = "3.0.0-pre.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
|
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek 5.0.0-pre.6",
|
||||||
"ed25519",
|
"ed25519",
|
||||||
"rand_core 0.10.0",
|
"rand_core 0.10.0",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -772,6 +788,12 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -3079,6 +3101,18 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "x509-parser"
|
name = "x509-parser"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -3111,6 +3145,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ argon2 = { version = "0.5", features = ["std"] }
|
|||||||
kameo.workspace = true
|
kameo.workspace = true
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
chacha20poly1305 = "0.10.1"
|
chacha20poly1305 = "0.10.1"
|
||||||
|
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
test-log = { version = "0.2", default-features = false, features = ["trace"] }
|
test-log = { version = "0.2", default-features = false, features = ["trace"] }
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use crate::{
|
|||||||
pub(crate) mod bootstrap;
|
pub(crate) mod bootstrap;
|
||||||
pub(crate) mod lease;
|
pub(crate) mod lease;
|
||||||
pub(crate) mod tls;
|
pub(crate) mod tls;
|
||||||
|
pub(crate) mod unseal;
|
||||||
|
|
||||||
#[derive(Error, Debug, Diagnostic)]
|
#[derive(Error, Debug, Diagnostic)]
|
||||||
pub enum InitError {
|
pub enum InitError {
|
||||||
|
|||||||
161
server/crates/arbiter-server/src/context/unseal.rs
Normal file
161
server/crates/arbiter-server/src/context/unseal.rs
Normal file
@@ -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<StaticSecret>,
|
||||||
|
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<u8> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user