feat(server): implement KeyStorage and state machine lifecycle
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 kameo::actor::{ActorRef, Spawn};
|
||||||
use miette::Diagnostic;
|
use miette::Diagnostic;
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
|
use secrecy::{ExposeSecret, SecretBox};
|
||||||
use smlang::statemachine;
|
use smlang::statemachine;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::{watch, RwLock};
|
use tokio::sync::{watch, RwLock};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
context::{
|
context::{
|
||||||
@@ -56,8 +58,66 @@ pub enum InitError {
|
|||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Placeholder for secure root key cell implementation
|
#[derive(Error, Debug, Diagnostic)]
|
||||||
pub struct KeyStorage;
|
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! {
|
statemachine! {
|
||||||
name: Server,
|
name: Server,
|
||||||
@@ -69,14 +129,20 @@ statemachine! {
|
|||||||
}
|
}
|
||||||
pub struct _Context;
|
pub struct _Context;
|
||||||
impl ServerStateMachineContext for _Context {
|
impl ServerStateMachineContext for _Context {
|
||||||
fn move_key(&mut self, _event_data: KeyStorage) -> Result<KeyStorage, ()> {
|
/// Move key from unseal event into Ready state
|
||||||
todo!()
|
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(missing_docs)]
|
||||||
#[allow(clippy::unused_unit)]
|
#[allow(clippy::unused_unit)]
|
||||||
fn dispose_key(&mut self, _state_data: &KeyStorage) -> Result<(), ()> {
|
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))
|
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,
|
ciphertext,
|
||||||
tag,
|
tag,
|
||||||
schema_version: 1, // Current version
|
schema_version: 1, // Current version
|
||||||
|
argon2_salt: Some(salt.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((aead_encrypted, salt))
|
Ok((aead_encrypted, salt))
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ pub mod types {
|
|||||||
pub struct SqliteTimestamp(DateTime<Utc>);
|
pub struct SqliteTimestamp(DateTime<Utc>);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Queryable, Debug, Insertable)]
|
#[derive(Queryable, Selectable, Debug, Insertable)]
|
||||||
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
|
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
|
||||||
pub struct AeadEncrypted {
|
pub struct AeadEncrypted {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
pub current_nonce: i32,
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
pub tag: Vec<u8>,
|
pub tag: Vec<u8>,
|
||||||
pub current_nonce: i32,
|
|
||||||
pub schema_version: i32,
|
pub schema_version: i32,
|
||||||
|
pub argon2_salt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Queryable, Debug, Insertable)]
|
#[derive(Queryable, Debug, Insertable)]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ diesel::table! {
|
|||||||
ciphertext -> Binary,
|
ciphertext -> Binary,
|
||||||
tag -> Binary,
|
tag -> Binary,
|
||||||
schema_version -> Integer,
|
schema_version -> Integer,
|
||||||
|
argon2_salt -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user