2 Commits

Author SHA1 Message Date
34ecf6928e feat(server): integrate X25519 unseal handler in UserAgentActor
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
2026-02-16 20:52:59 +01:00
29b3739e19 feat(server): add X25519 ECDH for secure unseal protocol 2026-02-16 20:45:12 +01:00
6 changed files with 383 additions and 2 deletions

View File

@@ -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
View File

@@ -101,6 +101,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tonic", "tonic",
"tracing", "tracing",
"x25519-dalek",
"zeroize", "zeroize",
] ]
@@ -487,6 +488,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"
@@ -497,7 +513,7 @@ dependencies = [
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.11.0", "digest 0.11.0",
"fiat-crypto", "fiat-crypto 0.3.0",
"rustc_version", "rustc_version",
"subtle", "subtle",
"zeroize", "zeroize",
@@ -735,7 +751,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",
@@ -771,6 +787,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"
@@ -3071,6 +3093,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"
@@ -3103,6 +3137,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.116",
]
[[package]] [[package]]
name = "zmij" name = "zmij"

View File

@@ -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"] }

View File

@@ -95,6 +95,8 @@ pub struct UserAgentActor {
bootstapper: ActorRef<BootstrapActor>, bootstapper: ActorRef<BootstrapActor>,
state: UserAgentStateMachine<DummyContext>, state: UserAgentStateMachine<DummyContext>,
tx: Sender<Result<UserAgentResponse, Status>>, tx: Sender<Result<UserAgentResponse, Status>>,
context: ServerContext,
ephemeral_key: Option<crate::context::unseal::EphemeralKeyPair>,
} }
impl UserAgentActor { impl UserAgentActor {
@@ -107,12 +109,15 @@ impl UserAgentActor {
bootstapper: context.bootstrapper.clone(), bootstapper: context.bootstrapper.clone(),
state: UserAgentStateMachine::new(DummyContext), state: UserAgentStateMachine::new(DummyContext),
tx, tx,
context,
ephemeral_key: None,
} }
} }
pub(crate) fn new_manual( pub(crate) fn new_manual(
db: db::DatabasePool, db: db::DatabasePool,
bootstapper: ActorRef<BootstrapActor>, bootstapper: ActorRef<BootstrapActor>,
context: ServerContext,
tx: Sender<Result<UserAgentResponse, Status>>, tx: Sender<Result<UserAgentResponse, Status>>,
) -> Self { ) -> Self {
Self { Self {
@@ -120,6 +125,8 @@ impl UserAgentActor {
bootstapper, bootstapper,
state: UserAgentStateMachine::new(DummyContext), state: UserAgentStateMachine::new(DummyContext),
tx, tx,
context,
ephemeral_key: None,
} }
} }
@@ -307,6 +314,121 @@ impl UserAgentActor {
Err(Status::unauthenticated("Invalid challenge solution")) Err(Status::unauthenticated("Invalid challenge solution"))
} }
} }
#[message(ctx)]
pub async fn handle_unseal_request(
&mut self,
request: arbiter_proto::proto::UnsealRequest,
ctx: &mut Context<Self, Output>,
) -> Output {
use arbiter_proto::proto::{
EphemeralKeyResponse, SealedPassword, UnsealResponse, UnsealResult,
unseal_request::Payload as ReqPayload,
unseal_response::Payload as RespPayload,
};
match request.payload {
Some(ReqPayload::EphemeralKeyRequest(_)) => {
// Generate new ephemeral keypair
let keypair = crate::context::unseal::EphemeralKeyPair::generate();
let expires_at = keypair.expires_at() as i64;
let public_bytes = keypair.public_bytes();
// Store for later use
self.ephemeral_key = Some(keypair);
info!("Generated ephemeral X25519 keypair for unseal, expires at {}", expires_at);
Ok(UserAgentResponse {
payload: Some(UserAgentResponsePayload::UnsealResponse(UnsealResponse {
payload: Some(RespPayload::EphemeralKeyResponse(EphemeralKeyResponse {
server_pubkey: public_bytes,
expires_at,
})),
})),
})
}
Some(ReqPayload::SealedPassword(sealed)) => {
// Get and consume ephemeral key
let keypair = self
.ephemeral_key
.take()
.ok_or_else(|| Status::failed_precondition("No ephemeral key generated"))?;
// Check expiration
if keypair.is_expired() {
error!("Ephemeral key expired before sealed password was received");
return Err(Status::deadline_exceeded("Ephemeral key expired"));
}
// Perform ECDH
let shared_secret = keypair
.perform_dh(&sealed.client_pubkey)
.map_err(|e| Status::invalid_argument(format!("Invalid client pubkey: {}", e)))?;
// Decrypt password
let nonce: [u8; 12] = sealed
.nonce
.as_slice()
.try_into()
.map_err(|_| Status::invalid_argument("Nonce must be 12 bytes"))?;
let password_bytes = crate::crypto::aead::decrypt(
&sealed.encrypted_password,
&shared_secret,
&nonce,
)
.map_err(|e| {
error!("Failed to decrypt password: {}", e);
Status::internal(format!("Decryption failed: {}", e))
})?;
let password = String::from_utf8(password_bytes).map_err(|_| {
error!("Password is not valid UTF-8");
Status::invalid_argument("Password must be UTF-8")
})?;
// Call unseal on context
info!("Attempting to unseal vault with decrypted password");
let result = self.context.unseal(&password).await;
match result {
Ok(()) => {
info!("Vault unsealed successfully");
Ok(UserAgentResponse {
payload: Some(UserAgentResponsePayload::UnsealResponse(
UnsealResponse {
payload: Some(RespPayload::UnsealResult(UnsealResult {
success: true,
error_message: None,
})),
},
)),
})
}
Err(e) => {
error!("Unseal failed: {}", e);
Ok(UserAgentResponse {
payload: Some(UserAgentResponsePayload::UnsealResponse(
UnsealResponse {
payload: Some(RespPayload::UnsealResult(UnsealResult {
success: false,
error_message: Some(e.to_string()),
})),
},
)),
})
}
}
}
None => {
error!("Received empty unseal request");
Err(Status::invalid_argument("Empty unseal request"))
}
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -333,9 +455,11 @@ mod tests {
let token = bootstrapper.get_token().unwrap(); let token = bootstrapper.get_token().unwrap();
let bootstrapper_ref = BootstrapActor::spawn(bootstrapper); let bootstrapper_ref = BootstrapActor::spawn(bootstrapper);
let context = crate::ServerContext::new(db.clone()).await.unwrap();
let user_agent = UserAgentActor::new_manual( let user_agent = UserAgentActor::new_manual(
db.clone(), db.clone(),
bootstrapper_ref, bootstrapper_ref,
context,
tokio::sync::mpsc::channel(1).0, // dummy channel, we won't actually send responses in this test tokio::sync::mpsc::channel(1).0, // dummy channel, we won't actually send responses in this test
); );
let user_agent_ref = UserAgentActor::spawn(user_agent); let user_agent_ref = UserAgentActor::spawn(user_agent);

View File

@@ -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 {

View 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);
}
}