merge: custom db ids for entities

This commit is contained in:
Skipper
2026-08-25 14:50:43 +02:00
32 changed files with 928 additions and 252 deletions

View File

@@ -1,29 +1,48 @@
use crate::db::{self, DatabasePool, schema};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use rand::{RngExt, distr::Alphanumeric, rngs::SysRng};
use rand_core::UnwrapErr;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use tracing::warn;
const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng();
async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
tokio::fs::write(path, content.as_bytes()).await?;
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
String::default(),
|mut accum, char| {
accum += char.to_string().as_str();
accum
},
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
}
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
Ok(())
}
Ok(token)
async fn generate_token(path: &Path) -> Result<SafeCell<[u8; TOKEN_LENGTH]>, std::io::Error> {
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
{
let mut buf = cell.write();
for (slot, b) in buf
.iter_mut()
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
{
*slot = b;
}
}
let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
write_token_file(path, &token_str).await?;
Ok(cell)
}
#[derive(Error, Debug)]
@@ -40,7 +59,8 @@ pub enum Error {
#[derive(Actor)]
pub struct Bootstrapper {
token: Option<String>,
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>,
}
impl Bootstrapper {
@@ -54,34 +74,37 @@ impl Bootstrapper {
.await?
};
let token = if row_count == 0 {
let token = generate_token().await?;
Some(token)
let (token, token_path) = if row_count == 0 {
let path = home_path()?.join(BOOTSTRAP_PATH);
let token = generate_token(&path).await?;
(Some(token), Some(path))
} else {
None
(None, None)
};
Ok(Self { token })
Ok(Self { token, token_path })
}
}
impl Bootstrapper {
fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token)))
})
}
}
#[messages]
impl Bootstrapper {
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
self.token.as_ref().is_some_and(|expected| {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
})
}
#[message]
pub fn consume_token(&mut self, token: String) -> bool {
if self.is_correct_token(token) {
pub async fn consume_token(&mut self, token: Vec<u8>) -> bool {
if self.is_correct_token(&token) {
self.token = None;
if let Some(path) = self.token_path.take()
&& let Err(e) = tokio::fs::remove_file(&path).await
{
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption");
}
true
} else {
false
@@ -92,7 +115,9 @@ impl Bootstrapper {
#[messages]
impl Bootstrapper {
#[message]
pub fn get_token(&self) -> Option<String> {
self.token.clone()
pub fn get_token(&mut self) -> Option<String> {
self.token
.as_mut()
.map(|cell| cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()))
}
}

View File

@@ -1,6 +1,6 @@
use crate::{
actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity,
crypto::integrity::{self, Integrable},
db::{
DatabaseError, DatabasePool,
models::{self, EvmWalletId},
@@ -25,14 +25,35 @@ use diesel::{
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
use tracing::error;
pub use crate::evm::safe_signer;
/// Integrity guard that binds a wallet's encrypted key ID to its Ethereum address.
/// Both fields are included in the HMAC — swapping `aead_encrypted_id` in the DB
/// invalidates the envelope MAC, and the AEAD ciphertext is also bound to `address`
/// as AAD, so decryption fails too.
#[derive(arbiter_macros::Hashable)]
struct EvmWalletIntegrity {
aead_encrypted_id: i32,
address: Address,
}
impl Integrable for EvmWalletIntegrity {
const KIND: &'static str = "evm_wallet";
}
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionError {
#[error("Wallet not found")]
WalletNotFound,
#[error("Decrypted key does not match requested wallet address")]
KeyAddressMismatch,
#[error("Internal signing error")]
Internal,
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
@@ -64,6 +85,12 @@ pub enum Error {
Integrity(#[from] integrity::Error),
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}
#[derive(Actor)]
pub struct EvmActor {
pub vault: ActorRef<Vault>,
@@ -97,20 +124,39 @@ impl EvmActor {
let aead_id: i32 = self
.vault
.ask(CreateNew { plaintext })
.ask(CreateNew {
plaintext,
aad: address.as_slice().to_vec(),
})
.await
.map_err(|_| Error::VaultSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
let wallet_id = conn
.exclusive_transaction(async |conn| {
let wallet_id: i32 = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
})
.returning(schema::evm_wallet::id)
.get_result(conn)
.await
.map_err(DatabaseError::from)
.map_err(Error::Database)?;
integrity::sign_entity(
conn,
&self.vault,
&EvmWalletIntegrity { address, aead_encrypted_id: aead_id },
wallet_id,
)
.await
.map_err(Error::Integrity)?;
Ok::<i32, Error>(wallet_id)
})
.returning(schema::evm_wallet::id)
.get_result(&mut conn)
.await
.map_err(DatabaseError::from)?;
.await?;
Ok((wallet_id, address))
}
@@ -160,29 +206,14 @@ impl EvmActor {
}
#[message]
#[expect(clippy::unused_async, reason = "reserved for impl")]
pub async fn operator_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let vault = self.vault.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
pub async fn useragent_delete_grant(
&mut self,
grant_id: i32,
) -> Result<(), Error> {
self.engine
.revoke_grant(grant_id)
.await
.map_err(Error::from)
}
#[message]
@@ -256,16 +287,51 @@ impl EvmActor {
.ok_or(SignTransactionError::WalletNotFound)?;
drop(conn);
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let attestation = integrity::verify_entity(
&mut conn,
&self.vault,
&EvmWalletIntegrity {
address: wallet_address,
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id.to_raw(),
)
.await
.map_err(|e| {
error!(?e, ?wallet.id, "EVM wallet integrity check failed");
SignTransactionError::Internal
})?;
drop(conn);
if attestation != integrity::AttestationStatus::Attested {
error!(
?wallet.id,
"EVM wallet integrity unavailable; refusing to sign"
);
return Err(SignTransactionError::Internal);
}
let raw_key: SafeCell<Vec<u8>> = self
.vault
.ask(Decrypt {
aead_id: wallet.aead_encrypted_id,
aad: wallet.address.clone(),
})
.await
.map_err(|_| SignTransactionError::VaultSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
if signer.address() != wallet_address {
error!(
expected = %wallet_address,
actual = %signer.address(),
"Decrypted private key address does not match requested wallet"
);
return Err(SignTransactionError::KeyAddressMismatch);
}
self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;

View File

@@ -11,7 +11,9 @@ use kameo::{
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender,
};
use std::ops::ControlFlow;
use std::{ops::ControlFlow, time::Duration};
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args {
pub client: ClientProfile,
@@ -64,6 +66,14 @@ impl Actor for ClientApprovalController {
.await;
}
let weak = actor_ref.downgrade();
tokio::spawn(async move {
tokio::time::sleep(APPROVAL_TIMEOUT).await;
if let Some(r) = weak.upgrade() {
let _ = r.tell(OnApprovalTimeout {}).await;
}
});
Ok(this)
}
@@ -104,4 +114,14 @@ impl ClientApprovalController {
ctx.stop();
}
}
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
/// by then is treated as a denial to prevent zombie sessions from blocking the flow.
#[message(ctx)]
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
if self.pending > 0 {
self.send_reply(Ok(false));
ctx.stop();
}
}
}

View File

@@ -20,6 +20,8 @@ pub mod client_connect_approval;
pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
/// Maps DB `client_id` → `ActorId` for fast connected-client lookup.
client_ids: HashMap<i32, ActorId>,
operator_registry: ActorRef<OperatorRegistry>,
}
@@ -27,6 +29,7 @@ impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self {
clients: HashMap::default(),
client_ids: HashMap::default(),
operator_registry,
}
}
@@ -48,6 +51,7 @@ impl Actor for FlowCoordinator {
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() {
self.client_ids.retain(|_, actor_id| *actor_id != id);
info!(
?id,
actor = "FlowCoordinator",
@@ -75,14 +79,28 @@ impl FlowCoordinator {
#[message(ctx)]
pub async fn register_client(
&mut self,
client_id: i32,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
info!(id = %actor.id(), client_id, actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await;
self.client_ids.insert(client_id, actor.id());
self.clients.insert(actor.id(), actor);
}
#[message]
pub fn is_client_connected(&self, client_id: i32) -> bool {
self.client_ids.contains_key(&client_id)
}
/// Returns the DB `client_ids` of all currently connected SDK clients.
/// Used by operator sessions on startup to seed their approved-client set.
#[message]
pub fn get_connected_client_ids(&self) -> Vec<i32> {
self.client_ids.keys().copied().collect()
}
#[message(ctx)]
pub async fn request_client_approval(
&mut self,

View File

@@ -22,7 +22,7 @@ use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
use tracing::{error, info, warn};
pub mod events {
#[derive(Clone, Copy)]
@@ -45,6 +45,8 @@ pub enum Error {
Sealed,
#[error("Invalid key provided")]
InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("Requested aead entry not found")]
NotFound,
@@ -60,6 +62,9 @@ pub enum Error {
#[error("Broken database")]
BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope:?}, current key is {current:?}")]
KeyVersionMismatch { envelope: RootKeyHistoryId, current: RootKeyHistoryId },
}
struct Unsealed {
@@ -79,6 +84,8 @@ enum State {
Unsealed(Unsealed),
}
const MAX_UNSEAL_ATTEMPTS: u32 = 5;
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key.
@@ -88,6 +95,7 @@ pub struct Vault {
db: db::DatabasePool,
state: State,
events: ActorRef<MessageBus>,
unseal_failures: u32,
}
#[messages]
@@ -110,7 +118,7 @@ impl Vault {
}
};
Ok(Self { db, state, events })
Ok(Self { db, state, events, unseal_failures: 0 })
}
// Exclusive transaction to avoid race condtions if multiple vaults write
@@ -224,6 +232,10 @@ impl Vault {
#[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
return Err(Error::LockedOut);
}
let State::Sealed {
root_key_history_id,
} = &self.state
@@ -256,13 +268,27 @@ impl Vault {
Error::BrokenDatabase
})?;
seal_key
if seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey
})?;
.is_err()
{
self.unseal_failures += 1;
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
error!(
attempts = self.unseal_failures,
"Vault locked: maximum failed unseal attempts reached"
);
} else {
warn!(
attempts = self.unseal_failures,
remaining = MAX_UNSEAL_ATTEMPTS - self.unseal_failures,
"Failed unseal attempt"
);
}
return Err(Error::InvalidKey);
}
self.unseal_failures = 0;
self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| {
@@ -277,8 +303,10 @@ impl Vault {
Ok(())
}
/// Decrypts an AEAD entry. The `aad` must match the value used at encryption time;
/// a mismatch causes authentication failure, preventing cross-wallet key swaps.
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
let row: models::AeadEncrypted = {
@@ -300,13 +328,15 @@ impl Vault {
Error::BrokenDatabase
})?;
let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
root_key.decrypt_in_place(&nonce, &aad, &mut output)?;
Ok(output)
}
/// Creates a new `aead_encrypted` entry and returns its ID.
/// The `aad` is bound into the ciphertext and must be reproduced exactly at decryption time.
// Creates new `aead_encrypted` entry in the database and returns it's ID
#[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>, aad: Vec<u8>) -> Result<i32, Error> {
let Unsealed {
root_key,
root_key_history_id,
@@ -318,7 +348,7 @@ impl Vault {
let mut ciphertext_buffer = plaintext.write();
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
root_key.encrypt_in_place(&nonce, &aad, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer);
@@ -354,12 +384,10 @@ impl Vault {
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&root_key_history_id.to_raw().to_be_bytes());
hmac.update(&mac_input);
@@ -380,15 +408,16 @@ impl Vault {
} = Self::expect_unsealed(&mut self.state)?;
if *root_key_history_id != key_version {
return Ok(false);
return Err(Error::KeyVersionMismatch {
envelope: key_version,
current: *root_key_history_id,
});
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&key_version.to_raw().to_be_bytes());
hmac.update(&mac_input);
@@ -432,12 +461,13 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
State::Unsealed(Unsealed {
root_key_history_id,
..
}) => root_key_history_id,
_ => panic!("expected unsealed state"),
let State::Unsealed(Unsealed {
root_key_history_id,
..
}) = actor.state
else {
panic!("expected unsealed state")
};
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -457,7 +487,7 @@ mod tests {
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec()))
.create_new(SafeCell::new(b"post-interleave".to_vec()), b"test-aad".to_vec())
.await
.unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table