Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:01:23.797Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

recovery_artifact.rs

348 lines · 12.8 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.