Skip to repository content348 lines · 12.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:55:21.866Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
recovery_artifact.rs
1use std::{
2 fs,
3 io::{self, Read, Write},
4 path::{Path, PathBuf},
5 sync::atomic::{AtomicU64, Ordering},
6};
7
8use nostr::{FromBech32, ToBech32, nips::nip49::EncryptedSecretKey};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11
12use crate::{CandidateRef, RecoveryCandidate};
13
14pub(crate) const MAX_RECOVERY_ARTIFACT_BYTES: u64 = 4_096;
15static TEMPORARY_ARTIFACT_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17pub(crate) struct RecoveryArtifactWrite {
18 pub byte_length: u64,
19 pub digest: String,
20}
21
22pub(crate) fn discover(path: PathBuf) -> Result<RecoveryCandidate, RecoveryArtifactError> {
23 let metadata = secure_metadata(&path)?;
24 let path_hash = hex::encode(Sha256::digest(path.to_string_lossy().as_bytes()));
25 let candidate_ref = CandidateRef::new(format!("recovery-artifact-{path_hash}"))
26 .map_err(|_| RecoveryArtifactError::InvalidArtifact)?;
27 Ok(RecoveryCandidate::artifact(
28 candidate_ref,
29 path,
30 metadata.len(),
31 ))
32}
33
34pub(crate) fn read_encrypted(
35 candidate: &RecoveryCandidate,
36) -> Result<EncryptedSecretKey, RecoveryArtifactError> {
37 let file = secure_open(candidate.path())?;
38 let metadata = file.metadata()?;
39 validate_metadata(candidate.path(), &metadata)?;
40 if metadata.len() != candidate.byte_length() {
41 return Err(RecoveryArtifactError::CandidateChanged);
42 }
43
44 let capacity =
45 usize::try_from(metadata.len()).map_err(|_| RecoveryArtifactError::ArtifactTooLarge)?;
46 let mut bytes = Vec::with_capacity(capacity);
47 file.take(MAX_RECOVERY_ARTIFACT_BYTES + 1)
48 .read_to_end(&mut bytes)?;
49 if bytes.len() as u64 > MAX_RECOVERY_ARTIFACT_BYTES {
50 return Err(RecoveryArtifactError::ArtifactTooLarge);
51 }
52 let contents =
53 std::str::from_utf8(&bytes).map_err(|_| RecoveryArtifactError::InvalidArtifact)?;
54 let token = contents
55 .strip_suffix("\r\n")
56 .or_else(|| contents.strip_suffix('\n'))
57 .unwrap_or(&contents);
58 if token.is_empty()
59 || token
60 .bytes()
61 .any(|byte| byte.is_ascii_whitespace() || byte == 0)
62 {
63 return Err(RecoveryArtifactError::InvalidArtifact);
64 }
65 let encrypted = EncryptedSecretKey::from_bech32(token)
66 .map_err(|_| RecoveryArtifactError::InvalidArtifact)?;
67 if !(16..=18).contains(&encrypted.log_n()) {
68 return Err(RecoveryArtifactError::UnsupportedWorkFactor);
69 }
70 Ok(encrypted)
71}
72
73pub(crate) fn write_encrypted(
74 path: &Path,
75 encrypted_secret: &EncryptedSecretKey,
76) -> Result<RecoveryArtifactWrite, RecoveryArtifactError> {
77 match fs::symlink_metadata(path) {
78 Ok(_) => return Err(RecoveryArtifactError::DestinationExists),
79 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
80 Err(error) => return Err(error.into()),
81 }
82 let parent = path
83 .parent()
84 .ok_or(RecoveryArtifactError::InvalidDestination)?;
85 if parent.as_os_str().is_empty() {
86 return Err(RecoveryArtifactError::InvalidDestination);
87 }
88 let parent_metadata = fs::symlink_metadata(parent)?;
89 if !parent_metadata.is_dir() || parent_metadata.file_type().is_symlink() {
90 return Err(RecoveryArtifactError::InvalidDestination);
91 }
92
93 let encoded = encrypted_secret
94 .to_bech32()
95 .map_err(|_| RecoveryArtifactError::EncryptionFailed)?;
96 let byte_length = encoded.len() as u64 + 1;
97 let digest = hex::encode(Sha256::digest([encoded.as_bytes(), b"\n"].concat()));
98 if byte_length > MAX_RECOVERY_ARTIFACT_BYTES {
99 return Err(RecoveryArtifactError::ArtifactTooLarge);
100 }
101
102 let (temporary_path, mut file) = create_temporary_artifact(parent, path)?;
103 let write_result = (|| {
104 file.write_all(encoded.as_bytes())?;
105 file.write_all(b"\n")?;
106 file.sync_all()?;
107 validate_metadata(&temporary_path, &file.metadata()?)
108 })();
109 if let Err(error) = write_result {
110 fs::remove_file(&temporary_path)?;
111 return Err(error);
112 }
113 drop(file);
114
115 if let Err(error) = fs::hard_link(&temporary_path, path) {
116 fs::remove_file(&temporary_path)?;
117 return if error.kind() == io::ErrorKind::AlreadyExists {
118 Err(RecoveryArtifactError::DestinationExists)
119 } else {
120 Err(error.into())
121 };
122 }
123 fs::remove_file(&temporary_path)?;
124 validate_metadata(path, &fs::symlink_metadata(path)?)?;
125 Ok(RecoveryArtifactWrite {
126 byte_length,
127 digest,
128 })
129}
130
131fn create_temporary_artifact(
132 parent: &Path,
133 destination: &Path,
134) -> Result<(PathBuf, fs::File), RecoveryArtifactError> {
135 let file_name = destination
136 .file_name()
137 .ok_or(RecoveryArtifactError::InvalidDestination)?
138 .to_string_lossy();
139 for _ in 0..32 {
140 let counter = TEMPORARY_ARTIFACT_COUNTER.fetch_add(1, Ordering::Relaxed);
141 let temporary_path = parent.join(format!(
142 ".{file_name}.omega-recovery-{}-{counter}.tmp",
143 std::process::id()
144 ));
145 let mut options = fs::OpenOptions::new();
146 options.create_new(true).write(true);
147 #[cfg(unix)]
148 {
149 use std::os::unix::fs::OpenOptionsExt as _;
150
151 options.mode(0o600).custom_flags(libc::O_CLOEXEC);
152 }
153 match options.open(&temporary_path) {
154 Ok(file) => return Ok((temporary_path, file)),
155 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
156 Err(error) => return Err(error.into()),
157 }
158 }
159 Err(RecoveryArtifactError::TemporaryFileUnavailable)
160}
161
162fn secure_metadata(path: &Path) -> Result<fs::Metadata, RecoveryArtifactError> {
163 let metadata = fs::symlink_metadata(path)?;
164 validate_metadata(path, &metadata)?;
165 Ok(metadata)
166}
167
168fn secure_open(path: &Path) -> Result<fs::File, RecoveryArtifactError> {
169 let mut options = fs::OpenOptions::new();
170 options.read(true);
171 #[cfg(unix)]
172 {
173 use std::os::unix::fs::OpenOptionsExt as _;
174 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
175 }
176 #[cfg(windows)]
177 {
178 use std::os::windows::fs::OpenOptionsExt as _;
179 use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
180
181 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
182 }
183 Ok(options.open(path)?)
184}
185
186fn validate_metadata(path: &Path, metadata: &fs::Metadata) -> Result<(), RecoveryArtifactError> {
187 if !metadata.is_file() || metadata.file_type().is_symlink() {
188 return Err(RecoveryArtifactError::UnsafeArtifact);
189 }
190 if metadata.len() == 0 {
191 return Err(RecoveryArtifactError::InvalidArtifact);
192 }
193 if metadata.len() > MAX_RECOVERY_ARTIFACT_BYTES {
194 return Err(RecoveryArtifactError::ArtifactTooLarge);
195 }
196 #[cfg(unix)]
197 {
198 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
199
200 let user_id = unsafe { libc::geteuid() };
201 if metadata.permissions().mode() & 0o077 != 0
202 || metadata.uid() != user_id
203 || metadata.nlink() != 1
204 {
205 return Err(RecoveryArtifactError::WeakPermissions);
206 }
207 let path_metadata = fs::symlink_metadata(path)?;
208 if path_metadata.dev() != metadata.dev() || path_metadata.ino() != metadata.ino() {
209 return Err(RecoveryArtifactError::CandidateChanged);
210 }
211 }
212 #[cfg(windows)]
213 {
214 use std::os::windows::fs::MetadataExt as _;
215 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
216
217 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
218 return Err(RecoveryArtifactError::UnsafeArtifact);
219 }
220 }
221 Ok(())
222}
223
224#[derive(Debug, Error)]
225pub(crate) enum RecoveryArtifactError {
226 #[error("the recovery artifact is not a regular protected file")]
227 UnsafeArtifact,
228 #[error("the recovery artifact has weak permissions")]
229 WeakPermissions,
230 #[error("the recovery artifact exceeds the size limit")]
231 ArtifactTooLarge,
232 #[error("the recovery artifact uses an unsupported password work factor")]
233 UnsupportedWorkFactor,
234 #[error("the recovery artifact is invalid")]
235 InvalidArtifact,
236 #[error("the recovery artifact changed after discovery")]
237 CandidateChanged,
238 #[error("the recovery artifact destination already exists")]
239 DestinationExists,
240 #[error("the recovery artifact destination is invalid")]
241 InvalidDestination,
242 #[error("a protected temporary recovery artifact could not be created")]
243 TemporaryFileUnavailable,
244 #[error("recovery artifact encryption failed")]
245 EncryptionFailed,
246 #[error("recovery artifact I/O failed")]
247 Io(#[from] io::Error),
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 const NIP49_VECTOR: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
255
256 fn write_protected(path: &Path, contents: &[u8]) {
257 fs::write(path, contents).expect("write recovery artifact fixture");
258 #[cfg(unix)]
259 {
260 use std::os::unix::fs::PermissionsExt as _;
261 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
262 .expect("protect recovery artifact fixture");
263 }
264 }
265
266 #[test]
267 fn official_vector_is_bounded_and_canonical() {
268 let temporary_directory = tempfile::tempdir().expect("create temporary directory");
269 let artifact_path = temporary_directory.path().join("recovery.ncryptsec");
270 write_protected(&artifact_path, format!("{NIP49_VECTOR}\n").as_bytes());
271
272 let candidate = discover(artifact_path).expect("discover official recovery vector");
273 let encrypted = read_encrypted(&candidate).expect("read official recovery vector");
274 assert_eq!(encrypted.log_n(), 16);
275 assert_eq!(
276 encrypted
277 .to_bech32()
278 .expect("encode official recovery vector"),
279 NIP49_VECTOR
280 );
281 }
282
283 #[test]
284 fn unsafe_or_resource_dangerous_artifacts_are_rejected_before_decryption() {
285 let temporary_directory = tempfile::tempdir().expect("create temporary directory");
286 let weak_path = temporary_directory.path().join("weak.ncryptsec");
287 fs::write(&weak_path, NIP49_VECTOR).expect("write weak artifact fixture");
288 #[cfg(unix)]
289 {
290 use std::os::unix::fs::PermissionsExt as _;
291 fs::set_permissions(&weak_path, fs::Permissions::from_mode(0o644))
292 .expect("set weak permissions");
293 assert!(matches!(
294 discover(weak_path),
295 Err(RecoveryArtifactError::WeakPermissions)
296 ));
297 }
298
299 let expensive_path = temporary_directory.path().join("expensive.ncryptsec");
300 let encrypted =
301 EncryptedSecretKey::from_bech32(NIP49_VECTOR).expect("parse official recovery vector");
302 let mut bytes = encrypted.as_vec();
303 bytes[1] = 19;
304 let expensive = EncryptedSecretKey::from_slice(&bytes)
305 .expect("construct unsupported work-factor fixture")
306 .to_bech32()
307 .expect("encode unsupported work-factor fixture");
308 write_protected(&expensive_path, expensive.as_bytes());
309 let candidate = discover(expensive_path).expect("discover expensive artifact");
310 assert!(matches!(
311 read_encrypted(&candidate),
312 Err(RecoveryArtifactError::UnsupportedWorkFactor)
313 ));
314
315 let multiline_path = temporary_directory.path().join("multiline.ncryptsec");
316 write_protected(
317 &multiline_path,
318 format!("{NIP49_VECTOR}\n{NIP49_VECTOR}\n").as_bytes(),
319 );
320 let candidate = discover(multiline_path).expect("discover multiline artifact");
321 assert!(matches!(
322 read_encrypted(&candidate),
323 Err(RecoveryArtifactError::InvalidArtifact)
324 ));
325 }
326
327 #[cfg(unix)]
328 #[test]
329 fn symlink_artifacts_are_rejected_without_following_the_target() {
330 use std::os::unix::fs::symlink;
331
332 let temporary_directory = tempfile::tempdir().expect("create temporary directory");
333 let target_path = temporary_directory.path().join("target.ncryptsec");
334 let link_path = temporary_directory.path().join("link.ncryptsec");
335 write_protected(&target_path, NIP49_VECTOR.as_bytes());
336 symlink(&target_path, &link_path).expect("create recovery artifact symlink");
337
338 assert!(matches!(
339 discover(link_path),
340 Err(RecoveryArtifactError::UnsafeArtifact)
341 ));
342 assert_eq!(
343 fs::read_to_string(target_path).expect("read unchanged symlink target"),
344 NIP49_VECTOR
345 );
346 }
347}
348