From 19f19a56e5879f87e668f85cc5d2b60a9e96696e Mon Sep 17 00:00:00 2001 From: CleverWild Date: Mon, 16 Feb 2026 15:57:14 +0100 Subject: [PATCH] feat(server): implement KeyStorage and state machine lifecycle --- .../down.sql | 2 + .../up.sql | 2 + server/crates/arbiter-server/src/context.rs | 209 +++++++++++++++++- .../arbiter-server/src/crypto/root_key.rs | 1 + server/crates/arbiter-server/src/db/models.rs | 5 +- server/crates/arbiter-server/src/db/schema.rs | 1 + 6 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/down.sql create mode 100644 server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/up.sql diff --git a/server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/down.sql b/server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/down.sql new file mode 100644 index 0000000..560f00b --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/down.sql @@ -0,0 +1,2 @@ +-- Remove argon2_salt column +ALTER TABLE aead_encrypted DROP COLUMN argon2_salt; diff --git a/server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/up.sql b/server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/up.sql new file mode 100644 index 0000000..59e8793 --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-02-16-160000-0000_add_argon2_salt/up.sql @@ -0,0 +1,2 @@ +-- Add argon2_salt column to store password derivation salt +ALTER TABLE aead_encrypted ADD COLUMN argon2_salt TEXT; diff --git a/server/crates/arbiter-server/src/context.rs b/server/crates/arbiter-server/src/context.rs index 7f7dfc3..af9e1e8 100644 --- a/server/crates/arbiter-server/src/context.rs +++ b/server/crates/arbiter-server/src/context.rs @@ -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 { - todo!() + /// Move key from unseal event into Ready state + fn move_key(&mut self, event_data: KeyStorage) -> Result { + // Просто перемещаем 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()); + } } diff --git a/server/crates/arbiter-server/src/crypto/root_key.rs b/server/crates/arbiter-server/src/crypto/root_key.rs index 187bd91..43505dd 100644 --- a/server/crates/arbiter-server/src/crypto/root_key.rs +++ b/server/crates/arbiter-server/src/crypto/root_key.rs @@ -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)) diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 53375af..dfc4e64 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -9,14 +9,15 @@ pub mod types { pub struct SqliteTimestamp(DateTime); } -#[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, pub tag: Vec, - pub current_nonce: i32, pub schema_version: i32, + pub argon2_salt: Option, } #[derive(Queryable, Debug, Insertable)] diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index f560547..6124a2a 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -7,6 +7,7 @@ diesel::table! { ciphertext -> Binary, tag -> Binary, schema_version -> Integer, + argon2_salt -> Nullable, } }