security(bootstrap): use SafeCell for token storage instead of zeroize
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
CleverWild
2026-06-19 22:53:28 +02:00
parent c12c12d73e
commit f0456157d5
7 changed files with 46 additions and 50 deletions

1
server/Cargo.lock generated
View File

@@ -788,7 +788,6 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"x25519-dalek 2.0.1", "x25519-dalek 2.0.1",
"zeroize",
] ]
[[package]] [[package]]

View File

@@ -37,7 +37,6 @@ chrono.workspace = true
kameo.workspace = true kameo.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] } argon2 = { version = "0.5.3", features = ["zeroize"] }
zeroize = "1.9"
restructed = "0.2.2" restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"

View File

@@ -1,4 +1,5 @@
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl; use diesel::QueryDsl;
@@ -10,7 +11,6 @@ use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _; use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use tracing::warn; use tracing::warn;
use zeroize::Zeroizing;
const TOKEN_LENGTH: usize = 64; const TOKEN_LENGTH: usize = 64;
@@ -26,18 +26,23 @@ async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Err
Ok(()) Ok(())
} }
async fn generate_token(path: &Path) -> Result<String, std::io::Error> { async fn generate_token(path: &Path) -> Result<SafeCell<[u8; TOKEN_LENGTH]>, std::io::Error> {
let token = UnwrapErr(SysRng) let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
.sample_iter(Alphanumeric) {
.take(TOKEN_LENGTH) let mut buf = cell.write();
.fold(String::default(), |mut accum, char| { for (slot, b) in buf
accum += char.to_string().as_str(); .iter_mut()
accum .zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
}); {
*slot = b;
}
}
write_token_file(path, &token).await?; let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
Ok(token) write_token_file(path, &token_str).await?;
Ok(cell)
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@@ -54,7 +59,7 @@ pub enum Error {
#[derive(Actor)] #[derive(Actor)]
pub struct Bootstrapper { pub struct Bootstrapper {
token: Option<Zeroizing<String>>, token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>, token_path: Option<PathBuf>,
} }
@@ -72,7 +77,7 @@ impl Bootstrapper {
let (token, token_path) = if row_count == 0 { let (token, token_path) = if row_count == 0 {
let path = home_path()?.join(BOOTSTRAP_PATH); let path = home_path()?.join(BOOTSTRAP_PATH);
let token = generate_token(&path).await?; let token = generate_token(&path).await?;
(Some(Zeroizing::new(token)), Some(path)) (Some(token), Some(path))
} else { } else {
(None, None) (None, None)
}; };
@@ -82,13 +87,9 @@ impl Bootstrapper {
} }
impl Bootstrapper { impl Bootstrapper {
fn is_correct_token(&self, token: &str) -> bool { fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_ref().is_some_and(|expected| { self.token.as_mut().is_some_and(|expected| {
let expected_bytes = expected.as_bytes(); expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token)))
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}) })
} }
} }
@@ -96,14 +97,14 @@ impl Bootstrapper {
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub async fn consume_token(&mut self, token: Zeroizing<String>) -> bool { pub async fn consume_token(&mut self, token: Vec<u8>) -> bool {
if self.is_correct_token(&token) { if self.is_correct_token(&token) {
self.token = None; self.token = None;
if let Some(path) = self.token_path.take() { if let Some(path) = self.token_path.take()
if let Err(e) = tokio::fs::remove_file(&path).await { && let Err(e) = tokio::fs::remove_file(&path).await
{
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption"); warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption");
} }
}
true true
} else { } else {
false false
@@ -114,7 +115,9 @@ impl Bootstrapper {
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn get_token(&self) -> Option<String> { pub fn get_token(&mut self) -> Option<String> {
self.token.as_ref().map(|token| token.to_string()) self.token
.as_mut()
.map(|cell| cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()))
} }
} }

View File

@@ -18,7 +18,6 @@ use arbiter_proto::{
use async_trait::async_trait; use async_trait::async_trait;
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
use zeroize::Zeroizing;
pub(super) struct AuthTransportAdapter<'a> { pub(super) struct AuthTransportAdapter<'a> {
pub(super) bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>, pub(super) bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>,
@@ -172,7 +171,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
Some(auth::Inbound::AuthChallengeRequest { Some(auth::Inbound::AuthChallengeRequest {
pubkey, pubkey,
bootstrap_token: bootstrap_token.map(Zeroizing::new), bootstrap_token: bootstrap_token.map(String::into_bytes),
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {

View File

@@ -1,7 +1,6 @@
use super::{Credentials, OperatorConnection}; use super::{Credentials, OperatorConnection};
use arbiter_crypto::authn::{self, AuthChallenge}; use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use zeroize::Zeroizing;
use state::{ use state::{
AuthContext, AuthError, AuthEvents, AuthStateMachine, AuthStates, ChallengeRequest, AuthContext, AuthError, AuthEvents, AuthStateMachine, AuthStates, ChallengeRequest,
@@ -15,7 +14,7 @@ mod state;
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: authn::PublicKey, pubkey: authn::PublicKey,
bootstrap_token: Option<Zeroizing<String>>, bootstrap_token: Option<Vec<u8>>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: Vec<u8>, signature: Vec<u8>,

View File

@@ -9,7 +9,6 @@ use crate::{
}; };
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use zeroize::Zeroizing;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
@@ -17,13 +16,12 @@ use tracing::error;
pub(super) struct ChallengeRequest { pub(super) struct ChallengeRequest {
pub(super) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<Zeroizing<String>>, pub(super) bootstrap_token: Option<Vec<u8>>,
} }
pub struct ChallengeContext { pub struct ChallengeContext {
pub(super) challenge: AuthChallenge, pub(super) challenge: AuthChallenge,
pub(super) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<Zeroizing<String>>,
} }
pub(super) struct ChallengeSolution { pub(super) struct ChallengeSolution {
@@ -80,11 +78,16 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
pub(super) struct AuthContext<'a, T: ?Sized> { pub(super) struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut OperatorConnection, pub(super) conn: &'a mut OperatorConnection,
pub(super) transport: &'a mut T, pub(super) transport: &'a mut T,
bootstrap_token: Option<Vec<u8>>,
} }
impl<'a, T: ?Sized> AuthContext<'a, T> { impl<'a, T: ?Sized> AuthContext<'a, T> {
pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self { pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self {
Self { conn, transport } Self {
conn,
transport,
bootstrap_token: None,
}
} }
} }
@@ -109,6 +112,8 @@ where
} }
} }
self.bootstrap_token = bootstrap_token;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport self.transport
@@ -121,20 +126,12 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(ChallengeContext { Ok(ChallengeContext { challenge, pubkey })
challenge,
pubkey,
bootstrap_token,
})
} }
async fn verify_solution( async fn verify_solution(
&mut self, &mut self,
ChallengeContext { ChallengeContext { challenge, pubkey }: &ChallengeContext,
challenge,
pubkey,
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution, ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> { ) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| {
@@ -153,7 +150,7 @@ where
} }
// Resolve client id: bootstrap (consume token + register) or lookup // Resolve client id: bootstrap (consume token + register) or lookup
let id = match bootstrap_token.clone() { let id = match self.bootstrap_token.take() {
Some(token) => { Some(token) => {
let token_ok: bool = self let token_ok: bool = self
.conn .conn

View File

@@ -174,7 +174,7 @@ pub async fn bootstrap_token_auth() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some(token), bootstrap_token: Some(token.into_bytes()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -231,7 +231,7 @@ pub async fn bootstrap_invalid_token_auth() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some("invalid_token".to_owned()), bootstrap_token: Some(b"invalid_token".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();