From 495b113dcd0953d418d5a0a0c114382fedb3db3e Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Fri, 4 Sep 2026 06:59:16 +0000 Subject: [PATCH 1/2] feat(rooms): add Secret Service generation anchors --- crates/omachat-store/Cargo.toml | 1 + crates/omachat-store/src/lib.rs | 4 +- crates/omachat-store/src/room_state.rs | 40 ++- crates/omachat-store/src/room_state_anchor.rs | 320 +++++++++++++++--- .../omachat-store/tests/nip29_room_state.rs | 107 +++--- .../tests/nip29_room_state_anchor.rs | 64 ++-- crates/omachatd/src/config.rs | 47 +++ crates/omachatd/src/core.rs | 14 + crates/omachatd/src/lib.rs | 1 + crates/omachatd/src/main.rs | 6 +- crates/omachatd/src/room_service.rs | 82 ++++- crates/omachatd/tests/rooms.rs | 1 + docs/installation.md | 13 +- 13 files changed, 563 insertions(+), 137 deletions(-) diff --git a/crates/omachat-store/Cargo.toml b/crates/omachat-store/Cargo.toml index e0e7c0e..429225f 100644 --- a/crates/omachat-store/Cargo.toml +++ b/crates/omachat-store/Cargo.toml @@ -16,6 +16,7 @@ omachat-registry = { path = "../omachat-registry", version = "=0.0.1" } secret-service = { version = "=5.2.0", default-features = false, features = ["rt-tokio-crypto-rust"] } serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" +tokio = { version = "=1.53.1", features = ["sync"] } zeroize = "=1.9.0" [dev-dependencies] diff --git a/crates/omachat-store/src/lib.rs b/crates/omachat-store/src/lib.rs index 4226ff2..f24fa93 100644 --- a/crates/omachat-store/src/lib.rs +++ b/crates/omachat-store/src/lib.rs @@ -46,7 +46,9 @@ pub use room_state::{ ROOM_STATE_RECORD_VERSION, RoomStateAnchorError, RoomStateGenerationAnchor, RoomStateLoad, RoomStateVault, RoomStateVaultError, }; -pub use room_state_anchor::{FileGenerationAnchor, ROOM_STATE_ANCHOR_VERSION}; +pub use room_state_anchor::{ + FileGenerationAnchor, ROOM_STATE_ANCHOR_VERSION, SecretServiceGenerationAnchor, +}; pub use sealed::{ MasterKey, ProviderKind, RequestedProvider, SealedStore, StoreError, StoreStatus, }; diff --git a/crates/omachat-store/src/room_state.rs b/crates/omachat-store/src/room_state.rs index 67d2e3c..cdf0a02 100644 --- a/crates/omachat-store/src/room_state.rs +++ b/crates/omachat-store/src/room_state.rs @@ -16,7 +16,7 @@ use omachat_nostr::{ nip29_room_state::{RelayRoomState, RelayRoomStateSnapshot, RoomStateError}, }; use serde::{Deserialize, Serialize}; -use std::{error::Error, fmt}; +use std::{error::Error, fmt, future::Future}; pub const ROOM_STATE_RECORD_VERSION: u16 = 1; const RECORD_PREFIX: &str = "nip29-rooms-v1-"; @@ -43,18 +43,18 @@ struct RecordHeader { /// Implementations must ensure a previously stored generation cannot be /// deleted or lowered by restoring the [`SealedStore`] from backup. pub trait RoomStateGenerationAnchor: Send + Sync { - fn load_generation( - &self, - store_context: &str, - relay_pubkey: &str, - ) -> Result, RoomStateAnchorError>; + fn load_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + ) -> impl Future, RoomStateAnchorError>> + Send + 'a; - fn store_generation( - &self, - store_context: &str, - relay_pubkey: &str, + fn store_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, generation: u64, - ) -> Result<(), RoomStateAnchorError>; + ) -> impl Future> + Send + 'a; } #[derive(Clone, Debug, Eq, PartialEq)] @@ -89,21 +89,21 @@ pub enum RoomStateLoad { } /// Persistence boundary for one relay's room state. -pub struct RoomStateVault<'store> { +pub struct RoomStateVault<'store, Anchor: RoomStateGenerationAnchor + ?Sized> { store: &'store SealedStore, - anchor: &'store dyn RoomStateGenerationAnchor, + anchor: &'store Anchor, store_context: String, relay_pubkey: String, record_name: String, generation: u64, } -impl<'store> RoomStateVault<'store> { +impl<'store, Anchor: RoomStateGenerationAnchor + ?Sized> RoomStateVault<'store, Anchor> { /// Bind a vault to a store, a caller-chosen store context (for example /// the local account or device public key), and one relay identity. pub fn open( store: &'store SealedStore, - anchor: &'store dyn RoomStateGenerationAnchor, + anchor: &'store Anchor, store_context: &str, relay_pubkey: &str, ) -> Result { @@ -141,7 +141,7 @@ impl<'store> RoomStateVault<'store> { /// Load and validate the persisted room state, returning an empty state /// only when the store provably never held one for this relay. - pub fn load_or_create( + pub async fn load_or_create( &mut self, now: u64, limits: &EventLimits, @@ -149,6 +149,7 @@ impl<'store> RoomStateVault<'store> { let anchor = self .anchor .load_generation(&self.store_context, &self.relay_pubkey) + .await .map_err(RoomStateVaultError::Anchor)?; let record = match self.store.read(&self.record_name) { Ok(bytes) => Some(bytes), @@ -162,6 +163,7 @@ impl<'store> RoomStateVault<'store> { if anchor.is_none() { self.anchor .store_generation(&self.store_context, &self.relay_pubkey, 0) + .await .map_err(RoomStateVaultError::Anchor)?; } self.generation = 0; @@ -210,6 +212,7 @@ impl<'store> RoomStateVault<'store> { if decoded.generation > anchor_generation { self.anchor .store_generation(&self.store_context, &self.relay_pubkey, decoded.generation) + .await .map_err(RoomStateVaultError::Anchor)?; } self.generation = decoded.generation; @@ -223,18 +226,20 @@ impl<'store> RoomStateVault<'store> { /// Persist a snapshot as the next generation. Either the previous valid /// state or the new one is on disk afterwards, never a mixture. - pub fn persist(&mut self, state: &RelayRoomState) -> Result { + pub async fn persist(&mut self, state: &RelayRoomState) -> Result { if state.relay_pubkey() != self.relay_pubkey { return Err(RoomStateVaultError::RelayMismatch); } let anchored = self .anchor .load_generation(&self.store_context, &self.relay_pubkey) + .await .map_err(RoomStateVaultError::Anchor)?; match anchored { None if self.generation == 0 => self .anchor .store_generation(&self.store_context, &self.relay_pubkey, 0) + .await .map_err(RoomStateVaultError::Anchor)?, Some(generation) if generation == self.generation => {} None => return Err(RoomStateVaultError::MissingAnchor), @@ -266,6 +271,7 @@ impl<'store> RoomStateVault<'store> { .map_err(RoomStateVaultError::Store)?; self.anchor .store_generation(&self.store_context, &self.relay_pubkey, generation) + .await .map_err(RoomStateVaultError::Anchor)?; self.generation = generation; Ok(generation) diff --git a/crates/omachat-store/src/room_state_anchor.rs b/crates/omachat-store/src/room_state_anchor.rs index ab4ab77..63a7e23 100644 --- a/crates/omachat-store/src/room_state_anchor.rs +++ b/crates/omachat-store/src/room_state_anchor.rs @@ -11,19 +11,25 @@ //! fsync so a crash leaves either the previous or the new generation. use crate::{RoomStateAnchorError, RoomStateGenerationAnchor}; +use secret_service::{EncryptionType, SecretService}; use serde::{Deserialize, Serialize}; use std::{ + collections::HashMap, fs::{self, File, OpenOptions}, io::{Read, Write}, os::unix::fs::{OpenOptionsExt, PermissionsExt}, path::{Path, PathBuf}, sync::Mutex, }; +use tokio::sync::Mutex as AsyncMutex; pub const ROOM_STATE_ANCHOR_VERSION: u16 = 1; const MAX_ANCHOR_FILE_BYTES: u64 = 4 * 1024; const MAX_ENCODED_CONTEXT_BYTES: usize = 200; const MAX_CONTEXT_BYTES: usize = 128; +const SECRET_APPLICATION: &str = "org.omachat.OmaChat"; +const SECRET_PURPOSE: &str = "nip29-room-state-generation"; +const SECRET_CONTENT_TYPE: &str = "application/json"; #[derive(Deserialize, Serialize)] struct AnchorFile { @@ -128,54 +134,260 @@ impl FileGenerationAnchor { } } +// Explicit RPIT preserves the trait's `Send` future guarantee. +#[allow(clippy::manual_async_fn)] impl RoomStateGenerationAnchor for FileGenerationAnchor { - fn load_generation( - &self, - store_context: &str, - relay_pubkey: &str, - ) -> Result, RoomStateAnchorError> { - let path = self.path_for(store_context, relay_pubkey)?; - let _guard = self.lock.lock().expect("anchor mutex poisoned"); - Self::read_file(&path, store_context, relay_pubkey) + fn load_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + ) -> impl std::future::Future, RoomStateAnchorError>> + Send + 'a + { + async move { + let path = self.path_for(store_context, relay_pubkey)?; + let _guard = self.lock.lock().expect("anchor mutex poisoned"); + Self::read_file(&path, store_context, relay_pubkey) + } } - fn store_generation( - &self, - store_context: &str, - relay_pubkey: &str, + fn store_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, generation: u64, - ) -> Result<(), RoomStateAnchorError> { - let path = self.path_for(store_context, relay_pubkey)?; - let _guard = self.lock.lock().expect("anchor mutex poisoned"); - if let Some(current) = Self::read_file(&path, store_context, relay_pubkey)? { - if current > generation { - return Err(RoomStateAnchorError::new(format!( - "anchored generation {current} cannot be lowered to {generation}" - ))); + ) -> impl std::future::Future> + Send + 'a { + async move { + let path = self.path_for(store_context, relay_pubkey)?; + let _guard = self.lock.lock().expect("anchor mutex poisoned"); + if let Some(current) = Self::read_file(&path, store_context, relay_pubkey)? { + if current > generation { + return Err(RoomStateAnchorError::new(format!( + "anchored generation {current} cannot be lowered to {generation}" + ))); + } + if current == generation { + return Ok(()); + } } - if current == generation { - return Ok(()); + let parent = path + .parent() + .ok_or_else(|| RoomStateAnchorError::new("anchor path has no parent"))?; + fs::create_dir_all(parent) + .map_err(|error| io_error("create anchor relay directory", &error))?; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|error| io_error("restrict anchor relay directory", &error))?; + let encoded = encode_anchor(store_context, relay_pubkey, generation)?; + atomic_write(&path, &encoded)?; + sync_directory(self.directory.as_path()) + } + } +} + +/// Monotonic per-(context, relay) generations stored as Secret Service items. +pub struct SecretServiceGenerationAnchor { + lock: AsyncMutex<()>, +} + +impl SecretServiceGenerationAnchor { + #[must_use] + pub fn new() -> Self { + Self { + lock: AsyncMutex::new(()), + } + } +} + +impl Default for SecretServiceGenerationAnchor { + fn default() -> Self { + Self::new() + } +} + +// Explicit RPIT preserves the trait's `Send` future guarantee. +#[allow(clippy::manual_async_fn)] +impl RoomStateGenerationAnchor for SecretServiceGenerationAnchor { + fn load_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + ) -> impl std::future::Future, RoomStateAnchorError>> + Send + 'a + { + async move { + validate_anchor_identity(store_context, relay_pubkey)?; + let _guard = self.lock.lock().await; + let service = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|_| secret_error("connect to Secret Service"))?; + let collection = service + .get_default_collection() + .await + .map_err(|_| secret_error("open the default collection"))?; + if collection + .is_locked() + .await + .map_err(|_| secret_error("inspect the default collection"))? + { + return Err(secret_error("use a locked default collection")); + } + let items = collection + .search_items(secret_attributes(store_context, relay_pubkey)) + .await + .map_err(|_| secret_error("search generation anchors"))?; + if items.len() > 1 { + return Err(secret_error("use duplicate generation anchors")); } + let Some(item) = items.first() else { + return Ok(None); + }; + let bytes = item + .get_secret() + .await + .map_err(|_| secret_error("read generation anchor"))?; + decode_anchor(&bytes, store_context, relay_pubkey).map(Some) + } + } + + fn store_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + generation: u64, + ) -> impl std::future::Future> + Send + 'a { + async move { + validate_anchor_identity(store_context, relay_pubkey)?; + let _guard = self.lock.lock().await; + let service = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|_| secret_error("connect to Secret Service"))?; + let collection = service + .get_default_collection() + .await + .map_err(|_| secret_error("open the default collection"))?; + if collection + .is_locked() + .await + .map_err(|_| secret_error("inspect the default collection"))? + { + return Err(secret_error("use a locked default collection")); + } + let attributes = secret_attributes(store_context, relay_pubkey); + let items = collection + .search_items(attributes.clone()) + .await + .map_err(|_| secret_error("search generation anchors"))?; + if items.len() > 1 { + return Err(secret_error("use duplicate generation anchors")); + } + let encoded = encode_anchor(store_context, relay_pubkey, generation)?; + if let Some(item) = items.first() { + let bytes = item + .get_secret() + .await + .map_err(|_| secret_error("read generation anchor"))?; + let current = decode_anchor(&bytes, store_context, relay_pubkey)?; + if current > generation { + return Err(RoomStateAnchorError::new(format!( + "anchored generation {current} cannot be lowered to {generation}" + ))); + } + if current == generation { + return Ok(()); + } + item.set_secret(&encoded, SECRET_CONTENT_TYPE) + .await + .map_err(|_| secret_error("update generation anchor"))?; + } else { + collection + .create_item( + "OmaChat NIP-29 room-state generation", + attributes, + &encoded, + false, + SECRET_CONTENT_TYPE, + ) + .await + .map_err(|_| secret_error("create generation anchor"))?; + } + Ok(()) } - let parent = path - .parent() - .ok_or_else(|| RoomStateAnchorError::new("anchor path has no parent"))?; - fs::create_dir_all(parent) - .map_err(|error| io_error("create anchor relay directory", &error))?; - fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) - .map_err(|error| io_error("restrict anchor relay directory", &error))?; - let encoded = serde_json::to_vec(&AnchorFile { - version: ROOM_STATE_ANCHOR_VERSION, - store_context: store_context.to_owned(), - relay_pubkey: relay_pubkey.to_owned(), - generation, - }) - .map_err(|_| RoomStateAnchorError::new("anchor encoding failed"))?; - atomic_write(&path, &encoded)?; - sync_directory(self.directory.as_path()) } } +fn secret_attributes<'a>( + store_context: &'a str, + relay_pubkey: &'a str, +) -> HashMap<&'a str, &'a str> { + HashMap::from([ + ("application", SECRET_APPLICATION), + ("purpose", SECRET_PURPOSE), + ("store-context", store_context), + ("relay-pubkey", relay_pubkey), + ]) +} + +fn validate_anchor_identity( + store_context: &str, + relay_pubkey: &str, +) -> Result<(), RoomStateAnchorError> { + if store_context.is_empty() || store_context.len() > MAX_CONTEXT_BYTES { + return Err(RoomStateAnchorError::new( + "anchor context must be 1 to 128 bytes", + )); + } + if relay_pubkey.len() != 64 + || !relay_pubkey + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(RoomStateAnchorError::new( + "anchor relay identity must be a lowercase 32-byte key", + )); + } + Ok(()) +} + +fn encode_anchor( + store_context: &str, + relay_pubkey: &str, + generation: u64, +) -> Result, RoomStateAnchorError> { + serde_json::to_vec(&AnchorFile { + version: ROOM_STATE_ANCHOR_VERSION, + store_context: store_context.to_owned(), + relay_pubkey: relay_pubkey.to_owned(), + generation, + }) + .map_err(|_| RoomStateAnchorError::new("anchor encoding failed")) +} + +fn decode_anchor( + bytes: &[u8], + store_context: &str, + relay_pubkey: &str, +) -> Result { + if bytes.len() as u64 > MAX_ANCHOR_FILE_BYTES { + return Err(RoomStateAnchorError::new("anchor record is too large")); + } + let decoded: AnchorFile = serde_json::from_slice(bytes) + .map_err(|_| RoomStateAnchorError::new("anchor record is malformed"))?; + if decoded.version != ROOM_STATE_ANCHOR_VERSION { + return Err(RoomStateAnchorError::new(format!( + "unsupported anchor version {}", + decoded.version + ))); + } + if decoded.store_context != store_context || decoded.relay_pubkey != relay_pubkey { + return Err(RoomStateAnchorError::new( + "anchor record belongs to another context or relay", + )); + } + Ok(decoded.generation) +} + +fn secret_error(action: &str) -> RoomStateAnchorError { + RoomStateAnchorError::new(format!("Secret Service could not {action}")) +} + fn encode_context(store_context: &str) -> Result { if store_context.is_empty() || store_context.len() > MAX_CONTEXT_BYTES { return Err(RoomStateAnchorError::new( @@ -244,3 +456,35 @@ fn sync_directory(path: &Path) -> Result<(), RoomStateAnchorError> { fn io_error(action: &str, error: &std::io::Error) -> RoomStateAnchorError { RoomStateAnchorError::new(format!("failed to {action}: {error}")) } + +#[cfg(test)] +mod tests { + use super::*; + + const CONTEXT: &str = "device:test"; + const RELAY: &str = "abababababababababababababababababababababababababababababababab"; + + #[test] + fn secret_record_is_exactly_bound_and_bounded() { + let encoded = encode_anchor(CONTEXT, RELAY, 42).expect("encode"); + assert_eq!(decode_anchor(&encoded, CONTEXT, RELAY).expect("decode"), 42); + assert!(decode_anchor(&encoded, "device:other", RELAY).is_err()); + assert!(decode_anchor(&encoded, CONTEXT, &"cd".repeat(32)).is_err()); + assert!( + decode_anchor(&vec![0; MAX_ANCHOR_FILE_BYTES as usize + 1], CONTEXT, RELAY).is_err() + ); + assert!(decode_anchor(b"not-json", CONTEXT, RELAY).is_err()); + } + + #[test] + fn secret_record_rejects_unknown_versions() { + let encoded = serde_json::to_vec(&AnchorFile { + version: ROOM_STATE_ANCHOR_VERSION + 1, + store_context: CONTEXT.to_owned(), + relay_pubkey: RELAY.to_owned(), + generation: 1, + }) + .expect("encode"); + assert!(decode_anchor(&encoded, CONTEXT, RELAY).is_err()); + } +} diff --git a/crates/omachat-store/tests/nip29_room_state.rs b/crates/omachat-store/tests/nip29_room_state.rs index 6967159..dbf8d46 100644 --- a/crates/omachat-store/tests/nip29_room_state.rs +++ b/crates/omachat-store/tests/nip29_room_state.rs @@ -48,31 +48,36 @@ impl TestGenerationAnchor { } } +// Explicit RPIT preserves the trait's `Send` future guarantee. +#[allow(clippy::manual_async_fn)] impl RoomStateGenerationAnchor for TestGenerationAnchor { - fn load_generation( - &self, - store_context: &str, - relay_pubkey: &str, - ) -> Result, RoomStateAnchorError> { - Ok(self.generation(store_context, relay_pubkey)) + fn load_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + ) -> impl std::future::Future, RoomStateAnchorError>> + Send + 'a + { + async move { Ok(self.generation(store_context, relay_pubkey)) } } - fn store_generation( - &self, - store_context: &str, - relay_pubkey: &str, + fn store_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, generation: u64, - ) -> Result<(), RoomStateAnchorError> { - let mut generations = self.generations.lock().expect("anchor lock"); - let key = (store_context.to_owned(), relay_pubkey.to_owned()); - if generations - .get(&key) - .is_some_and(|current| *current > generation) - { - return Err(RoomStateAnchorError::new("generation cannot decrease")); + ) -> impl std::future::Future> + Send + 'a { + async move { + let mut generations = self.generations.lock().expect("anchor lock"); + let key = (store_context.to_owned(), relay_pubkey.to_owned()); + if generations + .get(&key) + .is_some_and(|current| *current > generation) + { + return Err(RoomStateAnchorError::new("generation cannot decrease")); + } + generations.insert(key, generation); + Ok(()) } - generations.insert(key, generation); - Ok(()) } } @@ -334,16 +339,19 @@ async fn restart_preserves_exact_room_state_and_replay_is_idempotent() { { let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - let (fresh, load) = vault.load_or_create(NOW, &limits()).expect("fresh"); + let (fresh, load) = vault.load_or_create(NOW, &limits()).await.expect("fresh"); assert_eq!(load, RoomStateLoad::Fresh); assert_eq!(fresh, RelayRoomState::new(relay.clone()).expect("empty")); - assert_eq!(vault.persist(&state).expect("persist"), 1); - assert_eq!(vault.persist(&state).expect("persist again"), 2); + assert_eq!(vault.persist(&state).await.expect("persist"), 1); + assert_eq!(vault.persist(&state).await.expect("persist again"), 2); } let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - let (restored, load) = vault.load_or_create(NOW, &limits()).expect("restored"); + let (restored, load) = vault + .load_or_create(NOW, &limits()) + .await + .expect("restored"); assert_eq!(load, RoomStateLoad::Restored { generation: 2 }); assert_eq!(restored, state); assert_eq!(restored.snapshot(), state.snapshot()); @@ -428,7 +436,7 @@ async fn interrupted_write_preserves_the_previous_valid_state() { { let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - vault.persist(&state).expect("persist"); + vault.persist(&state).await.expect("persist"); } let orphan = directory .path() @@ -439,22 +447,26 @@ async fn interrupted_write_preserves_the_previous_valid_state() { let store = open_store(&directory).await; assert!(!orphan.exists(), "interrupted temporary must be discarded"); let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - let (restored, load) = vault.load_or_create(NOW, &limits()).expect("restored"); + let (restored, load) = vault + .load_or_create(NOW, &limits()) + .await + .expect("restored"); assert_eq!(load, RoomStateLoad::Restored { generation: 1 }); assert_eq!(restored, state); // A crash after the record but before the external anchor advances leaves // the authenticated record ahead. Load accepts it and heals the anchor. - vault.persist(&state).expect("second generation"); + vault.persist(&state).await.expect("second generation"); anchor.set_unchecked(CONTEXT, &relay, 1); let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); let (_, load) = vault .load_or_create(NOW, &limits()) + .await .expect("record ahead of anchor"); assert_eq!(load, RoomStateLoad::Restored { generation: 2 }); assert_eq!(anchor.generation(CONTEXT, &relay), Some(2)); - assert_eq!(vault.persist(&state).expect("heal"), 3); + assert_eq!(vault.persist(&state).await.expect("heal"), 3); } #[tokio::test] @@ -466,7 +478,7 @@ async fn truncation_corruption_and_authentication_failures_are_rejected() { { let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - vault.persist(&state).expect("persist"); + vault.persist(&state).await.expect("persist"); } let record = record_path(&directory, &relay); let original = fs::read(&record).expect("record"); @@ -479,25 +491,28 @@ async fn truncation_corruption_and_authentication_failures_are_rejected() { let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Store(StoreError::Authentication)) )); // Truncation. fs::write(&record, &original[..original.len() - 40]).expect("truncate"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Store(StoreError::Authentication)) )); fs::write(&record, &original[..12]).expect("truncate hard"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Store(StoreError::InvalidEnvelope)) )); // Restored, it loads again; corruption never reset it to empty. fs::write(&record, &original).expect("restore"); - let (restored, _) = vault.load_or_create(NOW, &limits()).expect("restored"); + let (restored, _) = vault + .load_or_create(NOW, &limits()) + .await + .expect("restored"); assert_eq!(restored, state); } @@ -510,9 +525,9 @@ async fn rollback_is_detected() { let record = record_path(&directory, &relay); let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - vault.persist(&state).expect("generation 1"); + vault.persist(&state).await.expect("generation 1"); let generation_one = fs::read(&record).expect("record"); - vault.persist(&state).expect("generation 2"); + vault.persist(&state).await.expect("generation 2"); // Restoring the complete sealed-store record behind the external anchor // is refused. The anchor is deliberately outside the restored domain. @@ -520,7 +535,7 @@ async fn rollback_is_detected() { let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Rollback { record_generation: 1, anchor_generation: 2 @@ -530,7 +545,7 @@ async fn rollback_is_detected() { // A vanished record with a surviving anchor is not "legitimately empty". fs::remove_file(&record).expect("remove record"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Rollback { record_generation: 0, anchor_generation: 2 @@ -547,12 +562,12 @@ async fn wrong_relay_context_and_schema_versions_are_rejected() { let state = build_state(&RELAY_SECRET); let store = open_store(&directory).await; let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, &relay).expect("vault"); - vault.persist(&state).expect("persist"); + vault.persist(&state).await.expect("persist"); // Persisting a state for another relay through this vault writes nothing. let foreign = build_state(&OTHER_RELAY_SECRET); assert!(matches!( - vault.persist(&foreign), + vault.persist(&foreign).await, Err(RoomStateVaultError::RelayMismatch) )); assert!(!record_path(&directory, &other_relay).exists()); @@ -562,7 +577,7 @@ async fn wrong_relay_context_and_schema_versions_are_rejected() { let mut other_context = RoomStateVault::open(&store, &anchor, "device:other", &relay).expect("vault"); assert!(matches!( - other_context.load_or_create(NOW, &limits()), + other_context.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::ContextMismatch) )); @@ -574,7 +589,7 @@ async fn wrong_relay_context_and_schema_versions_are_rejected() { let mut other_vault = RoomStateVault::open(&store, &anchor, CONTEXT, &other_relay).expect("vault"); assert!(matches!( - other_vault.load_or_create(NOW, &limits()), + other_vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Store(StoreError::Authentication)) )); @@ -588,7 +603,7 @@ async fn wrong_relay_context_and_schema_versions_are_rejected() { .expect("seal"); anchor.set_unchecked(CONTEXT, &other_relay, 9); assert!(matches!( - other_vault.load_or_create(NOW, &limits()), + other_vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::RelayMismatch) )); @@ -600,7 +615,7 @@ async fn wrong_relay_context_and_schema_versions_are_rejected() { .write(&format!("nip29-rooms-v1-{relay}"), future.as_bytes()) .expect("seal future"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::UnsupportedVersion(2)) )); @@ -614,7 +629,7 @@ async fn wrong_relay_context_and_schema_versions_are_rejected() { .write(&format!("nip29-rooms-v1-{relay}"), tampered.as_bytes()) .expect("seal tampered"); assert!(matches!( - vault.load_or_create(NOW, &limits()), + vault.load_or_create(NOW, &limits()).await, Err(RoomStateVaultError::Corrupt(RoomStateError::Event(_))) )); @@ -641,20 +656,24 @@ async fn multiple_relays_stay_isolated_in_one_store() { RoomStateVault::open(&store, &anchor, CONTEXT, &relay) .expect("vault") .persist(&first) + .await .expect("persist"); RoomStateVault::open(&store, &anchor, CONTEXT, &other_relay) .expect("vault") .persist(&second) + .await .expect("persist"); } let store = open_store(&directory).await; let (restored_first, _) = RoomStateVault::open(&store, &anchor, CONTEXT, &relay) .expect("vault") .load_or_create(NOW, &limits()) + .await .expect("first"); let (restored_second, _) = RoomStateVault::open(&store, &anchor, CONTEXT, &other_relay) .expect("vault") .load_or_create(NOW, &limits()) + .await .expect("second"); assert_eq!(restored_first, first); assert_eq!(restored_second, second); diff --git a/crates/omachat-store/tests/nip29_room_state_anchor.rs b/crates/omachat-store/tests/nip29_room_state_anchor.rs index c3e705d..3f382d9 100644 --- a/crates/omachat-store/tests/nip29_room_state_anchor.rs +++ b/crates/omachat-store/tests/nip29_room_state_anchor.rs @@ -24,40 +24,55 @@ fn copy_tree(source: &Path, target: &Path) { } } -#[test] -fn generations_are_monotonic_and_isolated_per_relay_and_context() { +#[tokio::test] +async fn generations_are_monotonic_and_isolated_per_relay_and_context() { let root = TempDir::new().expect("tempdir"); let state = root.path().join("state"); fs::create_dir_all(&state).expect("state"); let anchor = FileGenerationAnchor::open(root.path().join("anchors"), &state).expect("anchor"); - assert_eq!(anchor.load_generation(CONTEXT, RELAY).expect("load"), None); - anchor.store_generation(CONTEXT, RELAY, 0).expect("zero"); - anchor.store_generation(CONTEXT, RELAY, 3).expect("three"); + assert_eq!( + anchor.load_generation(CONTEXT, RELAY).await.expect("load"), + None + ); + anchor + .store_generation(CONTEXT, RELAY, 0) + .await + .expect("zero"); anchor .store_generation(CONTEXT, RELAY, 3) + .await + .expect("three"); + anchor + .store_generation(CONTEXT, RELAY, 3) + .await .expect("idempotent"); - assert!(anchor.store_generation(CONTEXT, RELAY, 2).is_err()); + assert!(anchor.store_generation(CONTEXT, RELAY, 2).await.is_err()); assert_eq!( - anchor.load_generation(CONTEXT, RELAY).expect("load"), + anchor.load_generation(CONTEXT, RELAY).await.expect("load"), Some(3) ); assert_eq!( - anchor.load_generation(CONTEXT, OTHER_RELAY).expect("load"), + anchor + .load_generation(CONTEXT, OTHER_RELAY) + .await + .expect("load"), None ); assert_eq!( anchor .load_generation("other-context", RELAY) + .await .expect("load"), None ); anchor .store_generation("other-context", RELAY, 1) + .await .expect("other"); assert_eq!( - anchor.load_generation(CONTEXT, RELAY).expect("load"), + anchor.load_generation(CONTEXT, RELAY).await.expect("load"), Some(3) ); @@ -74,9 +89,14 @@ fn generations_are_monotonic_and_isolated_per_relay_and_context() { let mode = entry.metadata().expect("meta").permissions().mode() & 0o777; assert_eq!(mode, 0o600, "{:?}", entry.path()); } - assert!(anchor.store_generation(CONTEXT, "relay", 1).is_err()); - assert!(anchor.store_generation("", RELAY, 1).is_err()); - assert!(anchor.store_generation(&"x".repeat(129), RELAY, 1).is_err()); + assert!(anchor.store_generation(CONTEXT, "relay", 1).await.is_err()); + assert!(anchor.store_generation("", RELAY, 1).await.is_err()); + assert!( + anchor + .store_generation(&"x".repeat(129), RELAY, 1) + .await + .is_err() + ); // A file rebound to another relay or context is refused, not trusted. let path = fs::read_dir(&relay_dir) @@ -88,11 +108,11 @@ fn generations_are_monotonic_and_isolated_per_relay_and_context() { .expect("read") .replace(RELAY, OTHER_RELAY); fs::write(&path, swapped).expect("write"); - assert!(anchor.load_generation(CONTEXT, RELAY).is_err()); + assert!(anchor.load_generation(CONTEXT, RELAY).await.is_err()); } -#[test] -fn anchor_refuses_to_share_the_sealed_rollback_domain() { +#[tokio::test] +async fn anchor_refuses_to_share_the_sealed_rollback_domain() { let root = TempDir::new().expect("tempdir"); let state = root.path().join("state"); fs::create_dir_all(&state).expect("state"); @@ -116,11 +136,11 @@ async fn restoring_the_sealed_store_from_backup_is_detected() { .expect("store"); let anchor = FileGenerationAnchor::open(&anchor_directory, &state).expect("anchor"); let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, RELAY).expect("vault"); - let (room_state, load) = vault.load_or_create(NOW, &limits).expect("fresh"); + let (room_state, load) = vault.load_or_create(NOW, &limits).await.expect("fresh"); assert_eq!(load, RoomStateLoad::Fresh); - assert_eq!(vault.persist(&room_state).expect("persist"), 1); + assert_eq!(vault.persist(&room_state).await.expect("persist"), 1); copy_tree(&state, &backup); - assert_eq!(vault.persist(&room_state).expect("persist"), 2); + assert_eq!(vault.persist(&room_state).await.expect("persist"), 2); } // Same anchor, current state: loads at generation 2. @@ -130,7 +150,7 @@ async fn restoring_the_sealed_store_from_backup_is_detected() { .expect("store"); let anchor = FileGenerationAnchor::open(&anchor_directory, &state).expect("anchor"); let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, RELAY).expect("vault"); - let (_, load) = vault.load_or_create(NOW, &limits).expect("load"); + let (_, load) = vault.load_or_create(NOW, &limits).await.expect("load"); assert_eq!(load, RoomStateLoad::Restored { generation: 2 }); } @@ -143,7 +163,7 @@ async fn restoring_the_sealed_store_from_backup_is_detected() { let anchor = FileGenerationAnchor::open(&anchor_directory, &state).expect("anchor"); let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, RELAY).expect("vault"); assert!(matches!( - vault.load_or_create(NOW, &limits), + vault.load_or_create(NOW, &limits).await, Err(RoomStateVaultError::Rollback { record_generation: 1, anchor_generation: 2 @@ -158,7 +178,7 @@ async fn restoring_the_sealed_store_from_backup_is_detected() { let anchor = FileGenerationAnchor::open(&anchor_directory, &state).expect("anchor"); let mut vault = RoomStateVault::open(&store, &anchor, CONTEXT, RELAY).expect("vault"); assert!(matches!( - vault.load_or_create(NOW, &limits), + vault.load_or_create(NOW, &limits).await, Err(RoomStateVaultError::Rollback { record_generation: 0, anchor_generation: 2 @@ -167,7 +187,7 @@ async fn restoring_the_sealed_store_from_backup_is_detected() { // A different relay in the same anchor directory is unaffected. let mut other = RoomStateVault::open(&store, &anchor, CONTEXT, OTHER_RELAY).expect("vault"); - let (fresh, load) = other.load_or_create(NOW, &limits).expect("fresh"); + let (fresh, load) = other.load_or_create(NOW, &limits).await.expect("fresh"); assert_eq!(load, RoomStateLoad::Fresh); assert_eq!( fresh, diff --git a/crates/omachatd/src/config.rs b/crates/omachatd/src/config.rs index 84f18f3..f3da96e 100644 --- a/crates/omachatd/src/config.rs +++ b/crates/omachatd/src/config.rs @@ -191,12 +191,23 @@ pub struct RoomsConfig { /// Room relay URLs: `wss://` or numeric-loopback `ws://`, no credentials, /// query, or fragment. pub relays: Vec, + /// Rollback-resistant generation storage. File anchors remain the default; + /// Secret Service must be selected explicitly. + pub anchor_provider: RoomAnchorProviderConfig, /// Directory for room-state generation anchors. It must lie outside the /// daemon state directory; when omitted the daemon uses a sibling of the /// state directory named `-anchors`. pub anchor_directory: Option, } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum RoomAnchorProviderConfig { + #[default] + File, + SecretService, +} + impl RoomsConfig { pub fn canonical_relays(&self) -> Result, CoreError> { let mut relays = self @@ -210,6 +221,11 @@ impl RoomsConfig { } pub(crate) fn validate(&self) -> Result<(), CoreError> { + if self.anchor_provider == RoomAnchorProviderConfig::SecretService + && self.anchor_directory.is_some() + { + return Err(CoreError::InvalidConfig); + } if self.relays.len() > 16 { return Err(CoreError::InvalidConfig); } @@ -326,3 +342,34 @@ fn canonical_publication_url(raw: &str) -> Result { } Ok(url.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn room_anchor_provider_defaults_to_file() { + let config: DaemonConfig = + serde_json::from_str(r#"{"rooms":{"relays":["wss://rooms.example"]}}"#) + .expect("config"); + assert_eq!( + config.rooms.expect("rooms").anchor_provider, + RoomAnchorProviderConfig::File + ); + } + + #[test] + fn secret_service_anchor_rejects_a_file_directory() { + let config: DaemonConfig = serde_json::from_str( + r#"{ + "rooms": { + "relays": ["wss://rooms.example"], + "anchor_provider": "secret-service", + "anchor_directory": "/tmp/ignored" + } + }"#, + ) + .expect("config"); + assert!(matches!(config.validate(), Err(CoreError::InvalidConfig))); + } +} diff --git a/crates/omachatd/src/core.rs b/crates/omachatd/src/core.rs index b8bc52c..6fa8c80 100644 --- a/crates/omachatd/src/core.rs +++ b/crates/omachatd/src/core.rs @@ -2624,6 +2624,7 @@ impl DaemonCore { &self, state_directory: &Path, default_anchor_directory: std::path::PathBuf, + explicit_anchor_directory: bool, ) -> Result, CoreError> { let rooms = self .inner @@ -2635,6 +2636,11 @@ impl DaemonCore { let Some(rooms) = rooms else { return Ok(None); }; + if rooms.anchor_provider == crate::config::RoomAnchorProviderConfig::SecretService + && explicit_anchor_directory + { + return Err(CoreError::InvalidConfig); + } let relays = rooms.canonical_relays()?; if relays.is_empty() { return Ok(None); @@ -2660,6 +2666,14 @@ impl DaemonCore { .anchor_directory .clone() .unwrap_or(default_anchor_directory), + anchor_provider: match rooms.anchor_provider { + crate::config::RoomAnchorProviderConfig::File => { + crate::room_service::RoomAnchorProvider::File + } + crate::config::RoomAnchorProviderConfig::SecretService => { + crate::room_service::RoomAnchorProvider::SecretService + } + }, state_directory: state_directory.to_owned(), store_context, history_window_seconds: ROOM_HISTORY_WINDOW_SECONDS, diff --git a/crates/omachatd/src/lib.rs b/crates/omachatd/src/lib.rs index 261fd73..206cad8 100644 --- a/crates/omachatd/src/lib.rs +++ b/crates/omachatd/src/lib.rs @@ -27,6 +27,7 @@ pub use agent_lifecycle_store::{ AGENT_LIFECYCLE_RECORD_NAME, SealedAgentLifecycle, SealedAgentLifecycleError, SealedAgentLifecycleState, }; +pub use config::RoomAnchorProviderConfig; pub use config::{ DaemonConfig, ProfilePublicationConfig, RegistryClientConfig, RegistryProtocol, RelayListPublicationConfig, RelayListPublicationRelayConfig, RoomsConfig, diff --git a/crates/omachatd/src/main.rs b/crates/omachatd/src/main.rs index de1fea2..f05165c 100644 --- a/crates/omachatd/src/main.rs +++ b/crates/omachatd/src/main.rs @@ -42,7 +42,11 @@ async fn run(options: Options) -> Result<(), Box> { } let events = EventHub::default(); let core = DaemonCore::open(&options.state, config, events.clone()).await?; - let rooms = core.start_rooms(&options.state, options.anchor_directory())?; + let rooms = core.start_rooms( + &options.state, + options.anchor_directory(), + options.anchors.is_some(), + )?; let (inbound_sender, mut inbound_receiver) = tokio::sync::mpsc::channel(256); let relays = core.relay_urls(); let nostr = if relays.is_empty() { diff --git a/crates/omachatd/src/room_service.rs b/crates/omachatd/src/room_service.rs index 2b5d7d3..36cfdc3 100644 --- a/crates/omachatd/src/room_service.rs +++ b/crates/omachatd/src/room_service.rs @@ -46,8 +46,8 @@ use omachat_nostr::{ }; use omachat_proto::ipc::Topic; use omachat_store::{ - FileGenerationAnchor, RoomStateAnchorError, RoomStateVault, RoomStateVaultError, SealedStore, - StoreError, + FileGenerationAnchor, RoomStateAnchorError, RoomStateGenerationAnchor, RoomStateVault, + RoomStateVaultError, SealedStore, SecretServiceGenerationAnchor, StoreError, }; use serde_json::{Value, json}; use std::{ @@ -138,6 +138,7 @@ pub struct RoomServiceOptions { pub route: RelayRoute, /// Anchor directory; must lie outside `state_directory`. pub anchor_directory: PathBuf, + pub anchor_provider: RoomAnchorProvider, pub state_directory: PathBuf, /// Binds sealed room state to this daemon identity (device Nostr key). pub store_context: String, @@ -145,6 +146,58 @@ pub struct RoomServiceOptions { pub history_window_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RoomAnchorProvider { + File, + SecretService, +} + +enum RoomGenerationAnchor { + File(FileGenerationAnchor), + SecretService(SecretServiceGenerationAnchor), +} + +// Explicit RPIT preserves the trait's `Send` future guarantee. +#[allow(clippy::manual_async_fn)] +impl RoomStateGenerationAnchor for RoomGenerationAnchor { + fn load_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + ) -> impl Future, RoomStateAnchorError>> + Send + 'a { + async move { + match self { + Self::File(anchor) => anchor.load_generation(store_context, relay_pubkey).await, + Self::SecretService(anchor) => { + anchor.load_generation(store_context, relay_pubkey).await + } + } + } + } + + fn store_generation<'a>( + &'a self, + store_context: &'a str, + relay_pubkey: &'a str, + generation: u64, + ) -> impl Future> + Send + 'a { + async move { + match self { + Self::File(anchor) => { + anchor + .store_generation(store_context, relay_pubkey, generation) + .await + } + Self::SecretService(anchor) => { + anchor + .store_generation(store_context, relay_pubkey, generation) + .await + } + } + } + } +} + enum RoomCommand { Join { group_id: String, @@ -291,10 +344,15 @@ impl RoomService { store: Arc, publisher: EventPublisher, ) -> Result { - let anchor = Arc::new( - FileGenerationAnchor::open(&options.anchor_directory, &options.state_directory) - .map_err(RoomServiceError::Anchor)?, - ); + let anchor = Arc::new(match options.anchor_provider { + RoomAnchorProvider::File => RoomGenerationAnchor::File( + FileGenerationAnchor::open(&options.anchor_directory, &options.state_directory) + .map_err(RoomServiceError::Anchor)?, + ), + RoomAnchorProvider::SecretService => { + RoomGenerationAnchor::SecretService(SecretServiceGenerationAnchor::new()) + } + }); let (stop, stop_receiver) = watch::channel(false); let identity_claims = Arc::new(RelayIdentityClaims::default()); let mut relays = BTreeMap::new(); @@ -402,7 +460,7 @@ struct RelayActor { url: String, route: RelayRoute, store: Arc, - anchor: Arc, + anchor: Arc, store_context: String, history_window: u64, publisher: EventPublisher, @@ -474,7 +532,7 @@ impl RelayActor { self.serve_terminal(&mut commands, &mut stop).await; return; }; - let mut state = match vault.load_or_create(now, &self.limits) { + let mut state = match vault.load_or_create(now, &self.limits).await { Ok((state, _)) => state, Err(error) => { self.status = RelayStatus::StateRefused(error.to_string()); @@ -490,7 +548,7 @@ impl RelayActor { now, ) { Ok(RelayIdentityObservation::Bound) => { - if let Err(error) = vault.persist(&state) { + if let Err(error) = vault.persist(&state).await { self.status = RelayStatus::StateRefused(error.to_string()); self.serve_terminal(&mut commands, &mut stop).await; return; @@ -680,7 +738,7 @@ impl RelayActor { handle: &NostrHandle, sink: &mut HandleSink, subscriptions: &mut RoomSubscriptions, - vault: &mut RoomStateVault<'_>, + vault: &mut RoomStateVault<'_, RoomGenerationAnchor>, state: &mut RelayRoomState, source: IdentitySource, ) { @@ -820,7 +878,7 @@ impl RelayActor { notification: PoolNotification, sink: &mut HandleSink, subscriptions: &mut RoomSubscriptions, - vault: &mut RoomStateVault<'_>, + vault: &mut RoomStateVault<'_, RoomGenerationAnchor>, state: &mut RelayRoomState, ) { match notification.notification { @@ -841,7 +899,7 @@ impl RelayActor { match self.reduce_event(event, now, subscriptions, state) { Reduction::Unchanged => {} Reduction::Changed => { - if let Err(error) = vault.persist(state) { + if let Err(error) = vault.persist(state).await { self.status = RelayStatus::StateRefused(error.to_string()); eprintln!( "omachatd: room state for {} could not be persisted; stopping the relay: {error}", diff --git a/crates/omachatd/tests/rooms.rs b/crates/omachatd/tests/rooms.rs index 6ec988a..785ccec 100644 --- a/crates/omachatd/tests/rooms.rs +++ b/crates/omachatd/tests/rooms.rs @@ -180,6 +180,7 @@ fn config_with_relays(relays: Vec) -> DaemonConfig { storage_provider: StorageProviderConfig::File, rooms: Some(RoomsConfig { relays, + anchor_provider: Default::default(), anchor_directory: None, }), ..DaemonConfig::default() diff --git a/docs/installation.md b/docs/installation.md index 0a1092f..7be2eaf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -53,7 +53,11 @@ restart retries never infer protocol semantics from encrypted payloads. `rooms` is an opt-in object for standard NIP-29 rooms: ```json -"rooms": { "relays": ["wss://rooms.example"], "anchor_directory": null } +"rooms": { + "relays": ["wss://rooms.example"], + "anchor_provider": "file", + "anchor_directory": null +} ``` `rooms.relays` lists room relays (`wss://`, or numeric-loopback `ws://` for @@ -68,7 +72,12 @@ relay identity and guarded by a generation anchor that must live outside the daemon state directory; `rooms.anchor_directory` (or `omachatd --anchors`) overrides the default sibling directory `-anchors`. Restoring the state directory from backup without the anchors is detected and refused rather than -silently rewinding rooms. Relay changes require a daemon restart. The default +silently rewinding rooms. Set `anchor_provider` to `secret-service` to keep +generations in the unlocked default Secret Service collection instead. This +selection fails closed when Secret Service is unavailable, locked, duplicated, +or corrupt; `anchor_directory` and `omachatd --anchors` are rejected with that +provider rather than silently ignored. File anchors remain the portable +default. Relay changes require a daemon restart. The default configuration permits one active URL per relay signing key. If two configured URLs declare the same `self` key, both are reported as `identity-conflict` and stopped before they can concurrently reduce or persist that relay's state. From 4a36b99ac3a7479d6bdde27319ba1b8361087693 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Fri, 4 Sep 2026 06:59:16 +0000 Subject: [PATCH 2/2] test(rooms): mark default anchor paths implicit --- crates/omachatd/tests/rooms.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/omachatd/tests/rooms.rs b/crates/omachatd/tests/rooms.rs index 785ccec..28d46dd 100644 --- a/crates/omachatd/tests/rooms.rs +++ b/crates/omachatd/tests/rooms.rs @@ -300,7 +300,7 @@ async fn rooms_join_receive_send_persist_and_restore() { .await .expect("core"); let service = core - .start_rooms(&state, anchors.clone()) + .start_rooms(&state, anchors.clone(), false) .expect("rooms start") .expect("rooms configured"); let bound = wait_for_identity(&core).await; @@ -405,7 +405,7 @@ async fn rooms_join_receive_send_persist_and_restore() { .await .expect("core again"); let service = core - .start_rooms(&state, anchors.clone()) + .start_rooms(&state, anchors.clone(), false) .expect("rooms restart") .expect("rooms configured"); assert_eq!(wait_for_identity(&core).await, relay); @@ -473,7 +473,7 @@ async fn relay_without_self_key_stays_unavailable_even_when_it_advertises_nip29( .await .expect("core"); let service = core - .start_rooms(&state, anchors.clone()) + .start_rooms(&state, anchors.clone(), false) .expect("rooms start") .expect("configured"); tokio::time::timeout(Duration::from_secs(5), async { @@ -516,7 +516,7 @@ async fn relay_without_self_key_stays_unavailable_even_when_it_advertises_nip29( .await .expect("core"); let service = core - .start_rooms(&state, anchors) + .start_rooms(&state, anchors, false) .expect("rooms start") .expect("configured"); tokio::time::timeout(Duration::from_secs(5), async { @@ -571,7 +571,7 @@ async fn duplicate_urls_for_one_relay_identity_stop_both_actors() { .await .expect("core"); let service = core - .start_rooms(&state, anchors) + .start_rooms(&state, anchors, false) .expect("rooms start") .expect("configured");