feat(server): implement KeyStorage and state machine lifecycle
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Remove argon2_salt column
|
||||
ALTER TABLE aead_encrypted DROP COLUMN argon2_salt;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add argon2_salt column to store password derivation salt
|
||||
ALTER TABLE aead_encrypted ADD COLUMN argon2_salt TEXT;
|
||||
@@ -8,9 +8,11 @@ use ed25519_dalek::VerifyingKey;
|
||||
use kameo::actor::{ActorRef, Spawn};
|
||||
use miette::Diagnostic;
|
||||
use rand::rngs::StdRng;
|
||||
use secrecy::{ExposeSecret, SecretBox};
|
||||
use smlang::statemachine;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{watch, RwLock};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{
|
||||
context::{
|
||||
@@ -56,8 +58,66 @@ pub enum InitError {
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
// TODO: Placeholder for secure root key cell implementation
|
||||
pub struct KeyStorage;
|
||||
#[derive(Error, Debug, Diagnostic)]
|
||||
pub enum UnsealError {
|
||||
#[error("Database error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::unseal::database_pool))]
|
||||
Database(#[from] db::PoolError),
|
||||
|
||||
#[error("Query error: {0}")]
|
||||
#[diagnostic(code(arbiter_server::unseal::database_query))]
|
||||
Query(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Decryption failed: {0}")]
|
||||
#[diagnostic(code(arbiter_server::unseal::decryption))]
|
||||
DecryptionFailed(#[from] crate::crypto::CryptoError),
|
||||
|
||||
#[error("Invalid state for unseal")]
|
||||
#[diagnostic(code(arbiter_server::unseal::invalid_state))]
|
||||
InvalidState,
|
||||
|
||||
#[error("Missing salt in database")]
|
||||
#[diagnostic(code(arbiter_server::unseal::missing_salt))]
|
||||
MissingSalt,
|
||||
|
||||
#[error("No root key configured in database")]
|
||||
#[diagnostic(code(arbiter_server::unseal::no_root_key))]
|
||||
NoRootKey,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Diagnostic)]
|
||||
pub enum SealError {
|
||||
#[error("Invalid state for seal")]
|
||||
#[diagnostic(code(arbiter_server::seal::invalid_state))]
|
||||
InvalidState,
|
||||
}
|
||||
|
||||
/// Secure in-memory storage for root encryption key
|
||||
///
|
||||
/// Uses `secrecy` crate for automatic zeroization on drop to prevent key material
|
||||
/// from remaining in memory after use. SecretBox provides heap-allocated secret
|
||||
/// storage that implements Send + Sync for safe use in async contexts.
|
||||
pub struct KeyStorage {
|
||||
/// 32-byte root key protected by SecretBox
|
||||
key: SecretBox<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl KeyStorage {
|
||||
/// Create new KeyStorage from a 32-byte root key
|
||||
pub fn new(key: [u8; 32]) -> Self {
|
||||
Self {
|
||||
key: SecretBox::new(Box::new(key)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the key for cryptographic operations
|
||||
pub fn key(&self) -> &[u8; 32] {
|
||||
self.key.expose_secret()
|
||||
}
|
||||
}
|
||||
|
||||
// Drop автоматически реализован через secrecy::Zeroize
|
||||
// который зануляет память при освобождении
|
||||
|
||||
statemachine! {
|
||||
name: Server,
|
||||
@@ -69,14 +129,20 @@ statemachine! {
|
||||
}
|
||||
pub struct _Context;
|
||||
impl ServerStateMachineContext for _Context {
|
||||
fn move_key(&mut self, _event_data: KeyStorage) -> Result<KeyStorage, ()> {
|
||||
todo!()
|
||||
/// Move key from unseal event into Ready state
|
||||
fn move_key(&mut self, event_data: KeyStorage) -> Result<KeyStorage, ()> {
|
||||
// Просто перемещаем KeyStorage из event в state
|
||||
// Без клонирования - event data consumed
|
||||
Ok(event_data)
|
||||
}
|
||||
|
||||
/// Securely dispose of key when sealing
|
||||
#[allow(missing_docs)]
|
||||
#[allow(clippy::unused_unit)]
|
||||
fn dispose_key(&mut self, _state_data: &KeyStorage) -> Result<(), ()> {
|
||||
todo!()
|
||||
// KeyStorage будет dropped после state transition
|
||||
// secrecy::Zeroize зануляет память автоматически
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,4 +266,137 @@ impl ServerContext {
|
||||
|
||||
Ok(Self(context))
|
||||
}
|
||||
|
||||
/// Unseal vault with password
|
||||
pub async fn unseal(&self, password: &str) -> Result<(), UnsealError> {
|
||||
use crate::crypto::root_key;
|
||||
use diesel::QueryDsl as _;
|
||||
|
||||
// 1. Get root_key_id from settings
|
||||
let mut conn = self.db.get().await?;
|
||||
|
||||
let settings: db::models::ArbiterSetting = schema::arbiter_settings::table
|
||||
.first(&mut conn)
|
||||
.await?;
|
||||
|
||||
let root_key_id = settings.root_key_id.ok_or(UnsealError::NoRootKey)?;
|
||||
|
||||
// 2. Load encrypted root key
|
||||
let encrypted: db::models::AeadEncrypted = schema::aead_encrypted::table
|
||||
.find(root_key_id)
|
||||
.first(&mut conn)
|
||||
.await?;
|
||||
|
||||
let salt = encrypted
|
||||
.argon2_salt
|
||||
.as_ref()
|
||||
.ok_or(UnsealError::MissingSalt)?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
// 3. Decrypt root key using password
|
||||
let root_key = root_key::decrypt_root_key(&encrypted, password, salt)
|
||||
.map_err(UnsealError::DecryptionFailed)?;
|
||||
|
||||
// 4. Create secure storage
|
||||
let key_storage = KeyStorage::new(root_key);
|
||||
|
||||
// 5. Transition state machine
|
||||
let mut state = self.state.write().await;
|
||||
state
|
||||
.process_event(ServerEvents::Unsealed(key_storage))
|
||||
.map_err(|_| UnsealError::InvalidState)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seal the server (lock the key)
|
||||
pub async fn seal(&self) -> Result<(), SealError> {
|
||||
let mut state = self.state.write().await;
|
||||
state
|
||||
.process_event(ServerEvents::Sealed)
|
||||
.map_err(|_| SealError::InvalidState)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_keystorage_creation() {
|
||||
let key = [42u8; 32];
|
||||
let storage = KeyStorage::new(key);
|
||||
assert_eq!(storage.key()[0], 42);
|
||||
assert_eq!(storage.key().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keystorage_zeroization() {
|
||||
let key = [99u8; 32];
|
||||
{
|
||||
let _storage = KeyStorage::new(key);
|
||||
// storage будет dropped здесь
|
||||
}
|
||||
// После drop SecretBox должен зануляеть память
|
||||
// Это проверяется автоматически через secrecy::Zeroize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_machine_transitions() {
|
||||
let mut state = ServerStateMachine::new(_Context);
|
||||
|
||||
// Начальное состояние
|
||||
assert!(matches!(state.state(), &ServerStates::NotBootstrapped));
|
||||
|
||||
// Bootstrapped transition
|
||||
state.process_event(ServerEvents::Bootstrapped).unwrap();
|
||||
assert!(matches!(state.state(), &ServerStates::Sealed));
|
||||
|
||||
// Unsealed transition
|
||||
let key_storage = KeyStorage::new([1u8; 32]);
|
||||
state
|
||||
.process_event(ServerEvents::Unsealed(key_storage))
|
||||
.unwrap();
|
||||
assert!(matches!(state.state(), &ServerStates::Ready(_)));
|
||||
|
||||
// Sealed transition
|
||||
state.process_event(ServerEvents::Sealed).unwrap();
|
||||
assert!(matches!(state.state(), &ServerStates::Sealed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_key_callback() {
|
||||
let mut ctx = _Context;
|
||||
let key_storage = KeyStorage::new([7u8; 32]);
|
||||
let result = ctx.move_key(key_storage);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().key()[0], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispose_key_callback() {
|
||||
let mut ctx = _Context;
|
||||
let key_storage = KeyStorage::new([13u8; 32]);
|
||||
let result = ctx.dispose_key(&key_storage);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_state_transitions() {
|
||||
let mut state = ServerStateMachine::new(_Context);
|
||||
|
||||
// Попытка unseal без bootstrap
|
||||
let key_storage = KeyStorage::new([1u8; 32]);
|
||||
let result = state.process_event(ServerEvents::Unsealed(key_storage));
|
||||
assert!(result.is_err());
|
||||
|
||||
// Правильный путь
|
||||
state.process_event(ServerEvents::Bootstrapped).unwrap();
|
||||
|
||||
// Попытка повторного bootstrap
|
||||
let result = state.process_event(ServerEvents::Bootstrapped);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn encrypt_root_key(
|
||||
ciphertext,
|
||||
tag,
|
||||
schema_version: 1, // Current version
|
||||
argon2_salt: Some(salt.clone()),
|
||||
};
|
||||
|
||||
Ok((aead_encrypted, salt))
|
||||
|
||||
@@ -9,14 +9,15 @@ pub mod types {
|
||||
pub struct SqliteTimestamp(DateTime<Utc>);
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug, Insertable)]
|
||||
#[derive(Queryable, Selectable, Debug, Insertable)]
|
||||
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
|
||||
pub struct AeadEncrypted {
|
||||
pub id: i32,
|
||||
pub current_nonce: i32,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub tag: Vec<u8>,
|
||||
pub current_nonce: i32,
|
||||
pub schema_version: i32,
|
||||
pub argon2_salt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Queryable, Debug, Insertable)]
|
||||
|
||||
@@ -7,6 +7,7 @@ diesel::table! {
|
||||
ciphertext -> Binary,
|
||||
tag -> Binary,
|
||||
schema_version -> Integer,
|
||||
argon2_salt -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user