appcore_peer_rpc/
nonce.rs1use 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
27pub trait PeerNonceStore: Debug + Send + Sync {
29 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#[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#[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 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;