merge: feat-lints into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed

This commit was merged in pull request #87.
This commit is contained in:
Skipper
2026-04-18 13:55:45 +02:00
204 changed files with 14668 additions and 3007 deletions

View File

@@ -1,24 +1,21 @@
use super::common::ChannelTransport;
use arbiter_crypto::{
authn::{self, USERAGENT_CONTEXT, format_challenge},
authn::{self, AuthChallenge, USERAGENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
use arbiter_server::{
actors::{
GlobalActors,
bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{UserAgentConnection, UserAgentCredentials, auth},
},
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
crypto::integrity,
db::{self, schema},
peers::user_agent::{self, Credentials, UserAgentConnection, auth, vault_gate},
};
use async_trait::async_trait;
use diesel::{ExpressionMethods as _, QueryDsl, insert_into};
use diesel_async::RunQueryDsl;
use ml_dsa::{KeyGen, MlDsa87, SigningKey, VerifyingKey, signature::Keypair};
use super::common::ChannelTransport;
use tokio::sync::mpsc;
fn verifying_key(key: &SigningKey<MlDsa87>) -> VerifyingKey<MlDsa87> {
<SigningKey<MlDsa87> as Keypair>::verifying_key(key)
@@ -26,23 +23,139 @@ fn verifying_key(key: &SigningKey<MlDsa87>) -> VerifyingKey<MlDsa87> {
fn sign_useragent_challenge(
key: &SigningKey<MlDsa87>,
nonce: i32,
pubkey_bytes: &[u8],
challenge: &AuthChallenge,
) -> authn::Signature {
let challenge = format_challenge(nonce, pubkey_bytes);
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, USERAGENT_CONTEXT)
.unwrap()
.into()
}
fn tamper_challenge(challenge: &AuthChallenge) -> AuthChallenge {
let mut challenge = challenge.clone();
challenge.nonce[0] ^= 1;
challenge
}
struct NullOobSender;
#[async_trait]
impl Sender<user_agent::OutOfBand> for NullOobSender {
async fn send(&mut self, _item: user_agent::OutOfBand) -> Result<(), TransportError> {
Ok(())
}
}
struct StartServerTransport {
auth_rx: mpsc::Receiver<auth::Inbound>,
auth_tx: mpsc::Sender<Result<auth::Outbound, auth::Error>>,
vault_rx: mpsc::Receiver<vault_gate::Inbound>,
vault_tx: mpsc::Sender<Result<vault_gate::Outbound, vault_gate::Error>>,
}
struct StartTestTransport {
auth_rx: mpsc::Receiver<Result<auth::Outbound, auth::Error>>,
auth_tx: mpsc::Sender<auth::Inbound>,
}
fn start_transport_pair() -> (StartServerTransport, StartTestTransport) {
let (auth_in_tx, auth_in_rx) = mpsc::channel(10);
let (auth_out_tx, auth_out_rx) = mpsc::channel(10);
let (_vault_in_tx, vault_in_rx) = mpsc::channel(10);
let (vault_out_tx, _vault_out_rx) = mpsc::channel(10);
(
StartServerTransport {
auth_rx: auth_in_rx,
auth_tx: auth_out_tx,
vault_rx: vault_in_rx,
vault_tx: vault_out_tx,
},
StartTestTransport {
auth_rx: auth_out_rx,
auth_tx: auth_in_tx,
},
)
}
#[async_trait]
impl Receiver<auth::Inbound> for StartServerTransport {
async fn recv(&mut self) -> Option<auth::Inbound> {
self.auth_rx.recv().await
}
}
#[async_trait]
impl Sender<Result<auth::Outbound, auth::Error>> for StartServerTransport {
async fn send(
&mut self,
item: Result<auth::Outbound, auth::Error>,
) -> Result<(), TransportError> {
self.auth_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
impl arbiter_proto::transport::Bi<auth::Inbound, Result<auth::Outbound, auth::Error>>
for StartServerTransport
{
}
#[async_trait]
impl Receiver<vault_gate::Inbound> for StartServerTransport {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
self.vault_rx.recv().await
}
}
#[async_trait]
impl Sender<Result<vault_gate::Outbound, vault_gate::Error>> for StartServerTransport {
async fn send(
&mut self,
item: Result<vault_gate::Outbound, vault_gate::Error>,
) -> Result<(), TransportError> {
self.vault_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
impl
arbiter_proto::transport::Bi<
vault_gate::Inbound,
Result<vault_gate::Outbound, vault_gate::Error>,
> for StartServerTransport
{
}
#[async_trait]
impl Receiver<Result<auth::Outbound, auth::Error>> for StartTestTransport {
async fn recv(&mut self) -> Option<Result<auth::Outbound, auth::Error>> {
self.auth_rx.recv().await
}
}
#[async_trait]
impl Sender<auth::Inbound> for StartTestTransport {
async fn send(&mut self, item: auth::Inbound) -> Result<(), TransportError> {
self.auth_tx
.send(item)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
#[tokio::test]
#[test_log::test]
pub async fn bootstrap_token_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
@@ -50,11 +163,11 @@ pub async fn bootstrap_token_auth() {
.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new();
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
auth::authenticate(&mut props, &mut server_transport).await
});
let new_key = MlDsa87::key_gen(&mut rand::rng());
@@ -66,14 +179,29 @@ pub async fn bootstrap_token_auth() {
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
match response {
Ok(auth::Outbound::AuthSuccess) => {}
other => panic!("Expected AuthSuccess, got {other:?}"),
}
assert!(matches!(response, Ok(auth::Outbound::AuthSuccess)));
task.await.unwrap().unwrap();
@@ -92,11 +220,11 @@ pub async fn bootstrap_invalid_token_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new();
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
auth::authenticate(&mut props, &mut server_transport).await
});
let new_key = MlDsa87::key_gen(&mut rand::rng());
@@ -108,6 +236,23 @@ pub async fn bootstrap_invalid_token_auth() {
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidBootstrapToken)
@@ -128,7 +273,7 @@ pub async fn challenge_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
@@ -141,20 +286,17 @@ pub async fn challenge_auth() {
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&UserAgentCredentials {
&actors.vault,
&Credentials {
id,
pubkey: verifying_key(&new_key).into(),
nonce: 1,
},
id,
)
@@ -162,11 +304,11 @@ pub async fn challenge_auth() {
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
auth::authenticate(&mut props, &mut server_transport).await
});
test_transport
@@ -183,13 +325,13 @@ pub async fn challenge_auth() {
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
auth::Outbound::AuthChallenge { challenge } => challenge,
auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got AuthSuccess"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, challenge, &pubkey_bytes);
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
@@ -217,7 +359,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
@@ -230,81 +372,17 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let (server_transport, mut test_transport) = start_transport_pair();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
bootstrap_token: None,
})
.await
.unwrap();
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::Internal { .. })
));
}
#[tokio::test]
#[test_log::test]
pub async fn challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = MlDsa87::key_gen(&mut rand::rng());
let pubkey_bytes = authn::PublicKey::from(verifying_key(&new_key)).to_bytes();
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&UserAgentCredentials {
pubkey: verifying_key(&new_key).into(),
nonce: 1,
},
id,
)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, server_transport).await
user_agent::start(&mut props, server_transport, Box::new(NullOobSender)).await
});
test_transport
@@ -321,13 +399,98 @@ pub async fn challenge_auth_rejects_invalid_signature() {
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { nonce } => nonce,
auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
assert!(matches!(response, Ok(auth::Outbound::AuthSuccess)));
assert!(matches!(
task.await.unwrap(),
Err(user_agent::Error::Internal(_))
));
}
#[tokio::test]
#[test_log::test]
pub async fn challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = MlDsa87::key_gen(&mut rand::rng());
let pubkey_bytes = authn::PublicKey::from(verifying_key(&new_key)).to_bytes();
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.vault,
&Credentials {
id,
pubkey: verifying_key(&new_key).into(),
},
id,
)
.await
.unwrap();
}
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
bootstrap_token: None,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge,
auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got AuthSuccess"),
},
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, challenge + 1, &pubkey_bytes);
let signature = sign_useragent_challenge(&new_key, &tamper_challenge(&challenge));
test_transport
.send(auth::Inbound::AuthChallengeSolution {

View File

@@ -1,49 +1,62 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_server::{
actors::{
GlobalActors,
keyholder::{Bootstrap, Seal},
user_agent::{
UserAgentSession,
session::connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError},
},
vault::{Bootstrap, Seal},
},
db,
peers::user_agent::{
Credentials,
vault_gate::{
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,
},
},
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use kameo::actor::Spawn as _;
use tokio::sync::oneshot;
use x25519_dalek::{EphemeralSecret, PublicKey};
async fn setup_sealed_user_agent(
async fn setup_sealed_gate(
seal_key: &[u8],
) -> (db::DatabasePool, kameo::actor::ActorRef<UserAgentSession>) {
) -> (
db::DatabasePool,
kameo::actor::ActorRef<VaultGate>,
oneshot::Receiver<Result<(), VaultGateError>>,
) {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(seal_key.to_vec()),
})
.await
.unwrap();
actors.key_holder.ask(Seal).await.unwrap();
actors.vault.ask(Seal).await.unwrap();
let session = UserAgentSession::spawn(UserAgentSession::new_test(db.clone(), actors));
let (promotion_tx, promotion_rx) = oneshot::channel();
let pubkey = authn::SigningKey::generate().public_key();
let auth_creds = Credentials { id: 1, pubkey };
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
(db, session)
(db, gate, promotion_rx)
}
async fn client_dh_encrypt(
user_agent: &kameo::actor::ActorRef<UserAgentSession>,
gate: &kameo::actor::ActorRef<VaultGate>,
key_to_send: &[u8],
) -> HandleUnsealEncryptedKey {
let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret);
let response = user_agent
.ask(HandleUnsealRequest {
let response = gate
.ask(HandleHandshake {
client_pubkey: client_public,
})
.await
@@ -71,26 +84,27 @@ async fn client_dh_encrypt(
#[test_log::test]
pub async fn unseal_success() {
let seal_key = b"test-seal-key";
let (_db, user_agent) = setup_sealed_user_agent(seal_key).await;
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await;
let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
let response = user_agent.ask(encrypted_key).await;
let response = gate.ask(encrypted_key).await;
assert!(matches!(response, Ok(())));
}
#[tokio::test]
#[test_log::test]
pub async fn unseal_wrong_seal_key() {
let (_db, user_agent) = setup_sealed_user_agent(b"correct-key").await;
let seal_key = b"test-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let encrypted_key = client_dh_encrypt(&user_agent, b"wrong-key").await;
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
let response = user_agent.ask(encrypted_key).await;
let response = gate.ask(encrypted_key).await;
assert!(matches!(
response,
Err(kameo::error::SendError::HandlerError(
UnsealError::InvalidKey
VaultGateError::InvalidKey
))
));
}
@@ -98,19 +112,19 @@ pub async fn unseal_wrong_seal_key() {
#[tokio::test]
#[test_log::test]
pub async fn unseal_corrupted_ciphertext() {
let (_db, user_agent) = setup_sealed_user_agent(b"test-key").await;
let seal_key = b"test-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret);
user_agent
.ask(HandleUnsealRequest {
client_pubkey: client_public,
})
.await
.unwrap();
gate.ask(HandleHandshake {
client_pubkey: client_public,
})
.await
.unwrap();
let response = user_agent
let response = gate
.ask(HandleUnsealEncryptedKey {
nonce: vec![0u8; 24],
ciphertext: vec![0u8; 32],
@@ -121,7 +135,7 @@ pub async fn unseal_corrupted_ciphertext() {
assert!(matches!(
response,
Err(kameo::error::SendError::HandlerError(
UnsealError::InvalidKey
VaultGateError::InvalidKey
))
));
}
@@ -130,24 +144,24 @@ pub async fn unseal_corrupted_ciphertext() {
#[test_log::test]
pub async fn unseal_retry_after_invalid_key() {
let seal_key = b"real-seal-key";
let (_db, user_agent) = setup_sealed_user_agent(seal_key).await;
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
{
let encrypted_key = client_dh_encrypt(&user_agent, b"wrong-key").await;
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
let response = user_agent.ask(encrypted_key).await;
let response = gate.ask(encrypted_key).await;
assert!(matches!(
response,
Err(kameo::error::SendError::HandlerError(
UnsealError::InvalidKey
VaultGateError::InvalidKey
))
));
}
{
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await;
let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
let response = user_agent.ask(encrypted_key).await;
let response = gate.ask(encrypted_key).await;
assert!(matches!(response, Ok(())));
}
}