Compare commits

...

3 Commits

Author SHA1 Message Date
CleverWild
a30bef11da fix: terminate VaultGate connection after vault lockout
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-30 20:07:01 +02:00
357726bc5d Merge pull request 'security: batch of fixes' (#95) from zeroized-bootstrap-token into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #95
2026-06-29 18:00:14 +00:00
Clippy Bot
23827c613e test(client-auth): update metadata test to reflect frozen-metadata behavior
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-06-28 12:06:06 +02:00
3 changed files with 61 additions and 11 deletions

View File

@@ -11,7 +11,7 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use state::State;
use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce};
use kameo::{Actor, error::SendError, messages, prelude::Message};
use kameo::{Actor, error::SendError, messages, prelude::{Context, Message}};
use kameo_actors::message_bus::Register;
use tokio::sync::oneshot;
use tracing::{error, info};
@@ -143,12 +143,13 @@ impl VaultGate {
})
}
#[message]
#[message(ctx)]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
@@ -172,7 +173,12 @@ impl VaultGate {
Ok(())
}
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(vault::Error::LockedOut)) => Err(Error::LockedOut),
Err(SendError::HandlerError(vault::Error::LockedOut)) => {
// Vault is permanently locked — terminate this gate so the
// run_vault_gate loop breaks and the connection is closed.
ctx.stop();
Err(Error::LockedOut)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey)
@@ -245,7 +251,7 @@ impl Message<events::Bootstrapped> for VaultGate {
async fn handle(
&mut self,
_: events::Bootstrapped,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
let result = async {
let mut conn = self
@@ -281,7 +287,7 @@ impl Message<events::Unsealed> for VaultGate {
async fn handle(
&mut self,
_: events::Unsealed,
ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
if let Some(tx) = self.promotion_tx.take() {
let _ = tx.send(Ok(()));

View File

@@ -266,7 +266,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
#[tokio::test]
#[test_log::test]
pub async fn metadata_change_appends_history_and_repoints_binding() {
pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng());
@@ -287,6 +287,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
connect_client(props, &mut server_transport).await;
});
// Reconnect presenting different metadata — must be silently ignored.
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
@@ -313,6 +314,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
client_metadata, client_metadata_history, program_client,
};
let mut conn = db.get().await.unwrap();
// Metadata is frozen: no new row, no history entry.
let metadata_count: i64 = client_metadata::table
.count()
.get_result(&mut conn)
@@ -338,15 +340,16 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
.first::<(String, Option<String>, Option<String>)>(&mut conn)
.await
.unwrap();
assert_eq!(metadata_count, 2);
assert_eq!(history_count, 1);
assert_eq!(metadata_count, 1, "frozen: no new metadata row on reconnect");
assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
assert_eq!(
current,
(
"client".to_owned(),
Some("new".to_owned()),
Some("2.0.0".to_owned())
)
Some("old".to_owned()),
Some("1.0.0".to_owned())
),
"frozen: original metadata must be preserved"
);
}
}

View File

@@ -140,6 +140,47 @@ pub async fn unseal_corrupted_ciphertext() {
));
}
/// After MAX_UNSEAL_ATTEMPTS wrong keys, the vault locks and the VaultGate
/// must stop itself so the connection is dropped.
#[tokio::test]
#[test_log::test]
pub async fn lockout_stops_vault_gate() {
use kameo::error::SendError;
let seal_key = b"real-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
// Exhaust all MAX_UNSEAL_ATTEMPTS (5) with wrong keys; each returns InvalidKey.
for _ in 0..5 {
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
assert!(matches!(
gate.ask(encrypted_key).await,
Err(SendError::HandlerError(VaultGateError::InvalidKey))
));
}
// Sixth attempt: vault is now locked, returns LockedOut, and the gate stops itself.
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
assert!(matches!(
gate.ask(encrypted_key).await,
Err(SendError::HandlerError(VaultGateError::LockedOut))
));
// Give the actor scheduler time to process the stop signal.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Any subsequent message must be rejected because the gate is stopped.
let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret);
assert!(
matches!(
gate.ask(HandleHandshake { client_pubkey: client_public }).await,
Err(SendError::ActorNotRunning(_))
),
"VaultGate must be stopped after LockedOut"
);
}
#[tokio::test]
#[test_log::test]
pub async fn unseal_retry_after_invalid_key() {