Skip to main content

appcore_peer_rpc/
nonce.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: nonce.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/23 23:50:45 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Replay-nonce persistence contracts and reference stores.
12
13use super::PeerRpcError;
14use fs2::FileExt;
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, VecDeque};
17use std::fmt::Debug;
18use std::fs::{self, File, OpenOptions};
19use std::io::{Read, Write};
20use std::path::{Path, PathBuf};
21use std::sync::Mutex;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24const NONCE_STORE_FORMAT: &str = "appcore-peer-nonce-v1";
25const MAX_NONCE_STATE_BYTES: u64 = 16 * 1024 * 1024;
26
27/// Atomic replay protection used by [`crate::PeerRpcValidator`].
28pub trait PeerNonceStore: Debug + Send + Sync {
29    /// Rejects a live duplicate or records the nonce until `expires_at_ms`.
30    fn check_and_record(
31        &self,
32        nonce: &str,
33        expires_at_ms: u64,
34        now_ms: u64,
35    ) -> Result<(), PeerRpcError>;
36}
37
38#[derive(Debug, Default)]
39struct MemoryState {
40    seen: BTreeMap<String, u64>,
41    order: VecDeque<String>,
42}
43
44/// Bounded process-local nonce store used by embedded and test deployments.
45#[derive(Debug, Default)]
46pub struct InMemoryPeerNonceStore {
47    state: Mutex<MemoryState>,
48}
49
50impl PeerNonceStore for InMemoryPeerNonceStore {
51    fn check_and_record(
52        &self,
53        nonce: &str,
54        expires_at_ms: u64,
55        now_ms: u64,
56    ) -> Result<(), PeerRpcError> {
57        let mut state = self
58            .state
59            .lock()
60            .map_err(|_| nonce_store_error("nonce_store_poisoned"))?;
61        retain_memory(&mut state, now_ms);
62        if state.seen.contains_key(nonce) {
63            return Err(PeerRpcError::NonceReplay);
64        }
65        if state.seen.len() >= super::MAX_NONCE_CACHE_ENTRIES {
66            return Err(PeerRpcError::NonceCacheFull);
67        }
68        state.seen.insert(nonce.to_string(), expires_at_ms);
69        state.order.push_back(nonce.to_string());
70        Ok(())
71    }
72}
73
74/// Durable process-safe nonce store for Runtime instances sharing one volume.
75#[derive(Debug, Clone)]
76pub struct FilePeerNonceStore {
77    path: PathBuf,
78    lock_path: PathBuf,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82struct PersistedNonceState {
83    format: String,
84    entries: BTreeMap<String, u64>,
85}
86
87impl Default for PersistedNonceState {
88    fn default() -> Self {
89        Self {
90            format: NONCE_STORE_FORMAT.to_string(),
91            entries: BTreeMap::new(),
92        }
93    }
94}
95
96impl FilePeerNonceStore {
97    /// Opens a durable nonce store and validates its owner-only layout.
98    pub fn open(path: impl Into<PathBuf>) -> Result<Self, PeerRpcError> {
99        let path = path.into();
100        let parent = path
101            .parent()
102            .filter(|parent| !parent.as_os_str().is_empty())
103            .ok_or_else(|| nonce_store_error("nonce_store_path_invalid"))?;
104        create_private_directory(parent)?;
105        reject_symlink(&path)?;
106        let lock_path = path.with_extension("lock");
107        initialize_lock(&lock_path)?;
108        let store = Self { path, lock_path };
109        if store.path.exists() {
110            let lock = store.lock_shared()?;
111            let _ = store.load()?;
112            FileExt::unlock(&lock).map_err(|_| nonce_store_error("nonce_store_unlock_failed"))?;
113        }
114        Ok(store)
115    }
116
117    fn lock_exclusive(&self) -> Result<File, PeerRpcError> {
118        validate_private_file(&self.lock_path)?;
119        let file = OpenOptions::new()
120            .read(true)
121            .write(true)
122            .open(&self.lock_path)
123            .map_err(|_| nonce_store_error("nonce_store_lock_unavailable"))?;
124        file.lock_exclusive()
125            .map_err(|_| nonce_store_error("nonce_store_lock_failed"))?;
126        Ok(file)
127    }
128
129    fn lock_shared(&self) -> Result<File, PeerRpcError> {
130        validate_private_file(&self.lock_path)?;
131        let file = OpenOptions::new()
132            .read(true)
133            .write(true)
134            .open(&self.lock_path)
135            .map_err(|_| nonce_store_error("nonce_store_lock_unavailable"))?;
136        FileExt::lock_shared(&file).map_err(|_| nonce_store_error("nonce_store_lock_failed"))?;
137        Ok(file)
138    }
139
140    fn load(&self) -> Result<PersistedNonceState, PeerRpcError> {
141        if !self.path.exists() {
142            return Ok(PersistedNonceState::default());
143        }
144        validate_private_file(&self.path)?;
145        let mut file = File::open(&self.path)
146            .map_err(|_| nonce_store_error("nonce_store_state_unavailable"))?;
147        let length = file
148            .metadata()
149            .map_err(|_| nonce_store_error("nonce_store_metadata_failed"))?
150            .len();
151        if length == 0 || length > MAX_NONCE_STATE_BYTES {
152            return Err(nonce_store_error("nonce_store_state_size_invalid"));
153        }
154        let mut bytes = Vec::with_capacity(length as usize);
155        file.read_to_end(&mut bytes)
156            .map_err(|_| nonce_store_error("nonce_store_read_failed"))?;
157        let state: PersistedNonceState = serde_json::from_slice(&bytes)
158            .map_err(|_| nonce_store_error("nonce_store_state_corrupt"))?;
159        if state.format != NONCE_STORE_FORMAT
160            || state.entries.len() > super::MAX_NONCE_CACHE_ENTRIES
161        {
162            return Err(nonce_store_error("nonce_store_format_invalid"));
163        }
164        Ok(state)
165    }
166
167    fn persist(&self, state: &PersistedNonceState) -> Result<(), PeerRpcError> {
168        let bytes = serde_json::to_vec(state)
169            .map_err(|_| nonce_store_error("nonce_store_encode_failed"))?;
170        atomic_write(&self.path, &bytes)
171    }
172}
173
174impl PeerNonceStore for FilePeerNonceStore {
175    fn check_and_record(
176        &self,
177        nonce: &str,
178        expires_at_ms: u64,
179        now_ms: u64,
180    ) -> Result<(), PeerRpcError> {
181        let lock = self.lock_exclusive()?;
182        let mut state = self.load()?;
183        state
184            .entries
185            .retain(|_, stored_expiry| *stored_expiry > now_ms);
186        if state.entries.contains_key(nonce) {
187            return Err(PeerRpcError::NonceReplay);
188        }
189        if state.entries.len() >= super::MAX_NONCE_CACHE_ENTRIES {
190            return Err(PeerRpcError::NonceCacheFull);
191        }
192        state.entries.insert(nonce.to_string(), expires_at_ms);
193        self.persist(&state)?;
194        FileExt::unlock(&lock).map_err(|_| nonce_store_error("nonce_store_unlock_failed"))
195    }
196}
197
198fn retain_memory(state: &mut MemoryState, now_ms: u64) {
199    while let Some(front) = state.order.front() {
200        let expired = state
201            .seen
202            .get(front)
203            .map(|expires_at_ms| *expires_at_ms <= now_ms)
204            .unwrap_or(true);
205        if !expired {
206            break;
207        }
208        if let Some(key) = state.order.pop_front() {
209            state.seen.remove(&key);
210        }
211    }
212}
213
214fn initialize_lock(path: &Path) -> Result<(), PeerRpcError> {
215    reject_symlink(path)?;
216    let file = OpenOptions::new()
217        .create(true)
218        .truncate(false)
219        .read(true)
220        .write(true)
221        .open(path)
222        .map_err(|_| nonce_store_error("nonce_store_lock_create_failed"))?;
223    set_private_file_permissions(&file)
224}
225
226fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), PeerRpcError> {
227    let parent = path
228        .parent()
229        .ok_or_else(|| nonce_store_error("nonce_store_path_invalid"))?;
230    validate_private_directory(parent)?;
231    reject_symlink(path)?;
232    let temporary = parent.join(format!(".nonce-{}-{}.tmp", std::process::id(), now_nanos()));
233    let mut file = OpenOptions::new()
234        .create_new(true)
235        .write(true)
236        .open(&temporary)
237        .map_err(|_| nonce_store_error("nonce_store_temp_create_failed"))?;
238    set_private_file_permissions(&file)?;
239    file.write_all(contents)
240        .and_then(|_| file.sync_all())
241        .map_err(|_| nonce_store_error("nonce_store_write_failed"))?;
242    drop(file);
243    replace_file(&temporary, path)?;
244    File::open(parent)
245        .and_then(|directory| directory.sync_all())
246        .map_err(|_| nonce_store_error("nonce_store_directory_sync_failed"))
247}
248
249fn replace_file(temporary: &Path, target: &Path) -> Result<(), PeerRpcError> {
250    #[cfg(windows)]
251    if target.exists() {
252        fs::remove_file(target).map_err(|_| nonce_store_error("nonce_store_replace_failed"))?;
253    }
254    fs::rename(temporary, target).map_err(|_| nonce_store_error("nonce_store_replace_failed"))
255}
256
257fn create_private_directory(path: &Path) -> Result<(), PeerRpcError> {
258    reject_symlink(path)?;
259    fs::create_dir_all(path)
260        .map_err(|_| nonce_store_error("nonce_store_directory_create_failed"))?;
261    set_private_directory_permissions(path)?;
262    validate_private_directory(path)
263}
264
265fn reject_symlink(path: &Path) -> Result<(), PeerRpcError> {
266    match fs::symlink_metadata(path) {
267        Ok(metadata) if metadata.file_type().is_symlink() => {
268            Err(nonce_store_error("nonce_store_symlink_rejected"))
269        }
270        Ok(_) => Ok(()),
271        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
272        Err(_) => Err(nonce_store_error("nonce_store_metadata_failed")),
273    }
274}
275
276#[cfg(unix)]
277fn set_private_directory_permissions(path: &Path) -> Result<(), PeerRpcError> {
278    use std::os::unix::fs::PermissionsExt;
279    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
280        .map_err(|_| nonce_store_error("nonce_store_permissions_failed"))
281}
282
283#[cfg(not(unix))]
284fn set_private_directory_permissions(_path: &Path) -> Result<(), PeerRpcError> {
285    Ok(())
286}
287
288#[cfg(unix)]
289fn set_private_file_permissions(file: &File) -> Result<(), PeerRpcError> {
290    use std::os::unix::fs::PermissionsExt;
291    file.set_permissions(fs::Permissions::from_mode(0o600))
292        .map_err(|_| nonce_store_error("nonce_store_permissions_failed"))
293}
294
295#[cfg(not(unix))]
296fn set_private_file_permissions(_file: &File) -> Result<(), PeerRpcError> {
297    Ok(())
298}
299
300#[cfg(unix)]
301fn validate_private_directory(path: &Path) -> Result<(), PeerRpcError> {
302    use std::os::unix::fs::{MetadataExt, PermissionsExt};
303    let metadata = fs::symlink_metadata(path)
304        .map_err(|_| nonce_store_error("nonce_store_directory_unavailable"))?;
305    if metadata.file_type().is_symlink()
306        || !metadata.is_dir()
307        || metadata.permissions().mode() & 0o077 != 0
308        || metadata.uid() != unsafe { libc::geteuid() }
309    {
310        return Err(nonce_store_error("nonce_store_directory_insecure"));
311    }
312    Ok(())
313}
314
315#[cfg(not(unix))]
316fn validate_private_directory(path: &Path) -> Result<(), PeerRpcError> {
317    let metadata = fs::symlink_metadata(path)
318        .map_err(|_| nonce_store_error("nonce_store_directory_unavailable"))?;
319    if metadata.file_type().is_symlink() || !metadata.is_dir() {
320        return Err(nonce_store_error("nonce_store_directory_insecure"));
321    }
322    Ok(())
323}
324
325#[cfg(unix)]
326fn validate_private_file(path: &Path) -> Result<(), PeerRpcError> {
327    use std::os::unix::fs::{MetadataExt, PermissionsExt};
328    let metadata = fs::symlink_metadata(path)
329        .map_err(|_| nonce_store_error("nonce_store_file_unavailable"))?;
330    if metadata.file_type().is_symlink()
331        || !metadata.is_file()
332        || metadata.permissions().mode() & 0o077 != 0
333        || metadata.uid() != unsafe { libc::geteuid() }
334    {
335        return Err(nonce_store_error("nonce_store_file_insecure"));
336    }
337    Ok(())
338}
339
340#[cfg(not(unix))]
341fn validate_private_file(path: &Path) -> Result<(), PeerRpcError> {
342    let metadata = fs::symlink_metadata(path)
343        .map_err(|_| nonce_store_error("nonce_store_file_unavailable"))?;
344    if metadata.file_type().is_symlink() || !metadata.is_file() {
345        return Err(nonce_store_error("nonce_store_file_insecure"));
346    }
347    Ok(())
348}
349
350fn now_nanos() -> u128 {
351    SystemTime::now()
352        .duration_since(UNIX_EPOCH)
353        .map(|duration| duration.as_nanos())
354        .unwrap_or(0)
355}
356
357fn nonce_store_error(reason: &'static str) -> PeerRpcError {
358    PeerRpcError::InvalidEnvelope(reason.to_string())
359}
360
361#[cfg(test)]
362#[path = "nonce_tests.rs"]
363mod tests;