Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:53:20.833Z 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

proof.rs

407 lines · 14.8 KB · rust
1use std::{
2    fs,
3    path::{Component, Path, PathBuf},
4};
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::{
10    AdmittedSigningRequest, CustodyError, CustodyResult, IdentityInspection, IdentityRef,
11    IdentityService, ReceiptRef, RecoveryPassword, SigningResult,
12};
13
14pub const IDENTITY_PROOF_PROTOCOL: &str = "openagents.omega.identity-proof.v1";
15pub const IDENTITY_PROOF_KEYRING_SERVICE: &str = "com.openagents.omega.identity-proof.v1";
16pub const IDENTITY_PROOF_KEYRING_ACCOUNT: &str = "disposable-proof-only";
17pub const IDENTITY_PROOF_ROOT_PREFIX: &str = "omega-identity-proof-";
18const PROOF_SENTINEL: &str = ".omega-identity-proof-v1.json";
19const PROOF_RECOVERY_ARTIFACT: &str = "disposable-recovery.ncryptsec";
20const PROOF_CORRUPT_RECOVERY_ARTIFACT: &str = "disposable-corrupt-recovery.ncryptsec";
21
22#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum ProofCrashBoundary {
25    AfterSecretWrite,
26    AfterSecretReadBack,
27    AfterManifestCommit,
28    AfterResetMarker,
29    AfterResetCommit,
30    AfterRelaunchAcknowledge,
31}
32
33#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum ProofSafeScenario {
36    ConflictCustody,
37    LostCustody,
38    LockedCustody,
39    SymlinkRefusal,
40    WeakPermissionRefusal,
41    KeychainUnavailable,
42    CorruptKeychain,
43    MalformedEventRejection,
44    UnadmittedPurposeRejection,
45    ConflictingRecoverySelection,
46    LateCompletionFencing,
47    SignerCrashBeforeCompletion,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct ProofSafeScenarioResult {
52    pub scenario: ProofSafeScenario,
53    pub mode: &'static str,
54    pub expected_outcome: &'static str,
55    pub production_locator_access: &'static str,
56}
57
58#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum ProofRecoveryRejection {
61    WrongPassword,
62    CorruptArtifact,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct ProofRecoveryRejectionResult {
67    pub scenario: ProofRecoveryRejection,
68    pub expected_outcome: &'static str,
69    pub production_locator_access: &'static str,
70}
71
72#[derive(Debug, Error)]
73pub enum IdentityProofError {
74    #[error(
75        "proof root must be an absolute, normalized path whose final component starts with omega-identity-proof-"
76    )]
77    UnsafeRoot,
78    #[error("proof root or one of its existing ancestors is a symbolic link")]
79    SymbolicLink,
80    #[error("proof root is not initialized with the exact proof namespace sentinel")]
81    MissingSentinel,
82    #[error("proof root initialization failed")]
83    Io(#[from] std::io::Error),
84    #[error("a recovery input expected to be rejected was admitted")]
85    UnexpectedRecoveryAdmission,
86    #[error(transparent)]
87    Custody(#[from] CustodyError),
88}
89
90#[derive(Debug, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct ProofSentinel {
93    protocol: String,
94    keyring_service: String,
95    keyring_account: String,
96}
97
98pub struct IdentityProofService {
99    root: PathBuf,
100    service: IdentityService,
101}
102
103impl IdentityProofService {
104    pub fn initialize(root: &Path) -> Result<(), IdentityProofError> {
105        validate_root_shape(root)?;
106        reject_symbolic_links(root)?;
107        fs::create_dir(root)?;
108        let sentinel = ProofSentinel {
109            protocol: IDENTITY_PROOF_PROTOCOL.to_string(),
110            keyring_service: IDENTITY_PROOF_KEYRING_SERVICE.to_string(),
111            keyring_account: IDENTITY_PROOF_KEYRING_ACCOUNT.to_string(),
112        };
113        fs::write(
114            root.join(PROOF_SENTINEL),
115            serde_json::to_vec_pretty(&sentinel)
116                .map_err(|_| IdentityProofError::MissingSentinel)?,
117        )?;
118        Ok(())
119    }
120
121    pub fn open(
122        root: PathBuf,
123        crash_boundary: Option<ProofCrashBoundary>,
124    ) -> Result<Self, IdentityProofError> {
125        validate_root_shape(&root)?;
126        reject_symbolic_links(&root)?;
127        let sentinel: ProofSentinel = serde_json::from_slice(&fs::read(root.join(PROOF_SENTINEL))?)
128            .map_err(|_| IdentityProofError::MissingSentinel)?;
129        if sentinel.protocol != IDENTITY_PROOF_PROTOCOL
130            || sentinel.keyring_service != IDENTITY_PROOF_KEYRING_SERVICE
131            || sentinel.keyring_account != IDENTITY_PROOF_KEYRING_ACCOUNT
132        {
133            return Err(IdentityProofError::MissingSentinel);
134        }
135        Ok(Self {
136            service: IdentityService::for_disposable_proof(root.clone(), crash_boundary),
137            root,
138        })
139    }
140
141    pub fn inspect(&self) -> Result<IdentityInspection, IdentityProofError> {
142        Ok(self.service.inspect_details()?)
143    }
144
145    pub fn inspect_for_process_start(&self) -> Result<IdentityInspection, IdentityProofError> {
146        Ok(self.service.inspect_for_process_start()?)
147    }
148
149    pub fn create(&self, receipt: ReceiptRef) -> Result<CustodyResult, IdentityProofError> {
150        Ok(self.service.create(receipt)?)
151    }
152
153    pub fn resume_create(&self) -> Result<CustodyResult, IdentityProofError> {
154        Ok(self.service.resume_incomplete_create()?)
155    }
156
157    pub fn sign(
158        &self,
159        request: &AdmittedSigningRequest,
160    ) -> Result<SigningResult, IdentityProofError> {
161        Ok(self.service.sign(request)?)
162    }
163
164    pub fn reset(
165        &self,
166        identity: &IdentityRef,
167        receipt: ReceiptRef,
168    ) -> Result<CustodyResult, IdentityProofError> {
169        Ok(self.service.reset(identity, receipt)?)
170    }
171
172    pub fn resume_reset(&self) -> Result<CustodyResult, IdentityProofError> {
173        Ok(self.service.resume_pending_reset()?)
174    }
175
176    pub fn protect_recovery(
177        &self,
178        identity: &IdentityRef,
179        password: RecoveryPassword,
180    ) -> Result<IdentityInspection, IdentityProofError> {
181        self.service.export_recovery_artifact(
182            identity,
183            &self.root.join(PROOF_RECOVERY_ARTIFACT),
184            password,
185        )?;
186        Ok(self.service.inspect_details()?)
187    }
188
189    pub fn recover(
190        &self,
191        password: RecoveryPassword,
192        receipt: ReceiptRef,
193    ) -> Result<IdentityInspection, IdentityProofError> {
194        let candidate = self
195            .service
196            .discover_recovery_artifact(self.root.join(PROOF_RECOVERY_ARTIFACT))?;
197        let prepared = self
198            .service
199            .prepare_recovery_artifact(&candidate, password)?;
200        let candidate_ref = prepared.candidate_ref().clone();
201        let selected = self
202            .service
203            .select_recovery(vec![prepared], &candidate_ref)?;
204        self.service.adopt(selected, receipt)?;
205        Ok(self.service.inspect_details()?)
206    }
207
208    pub fn probe_wrong_recovery_password(
209        &self,
210        password: RecoveryPassword,
211    ) -> Result<ProofRecoveryRejectionResult, IdentityProofError> {
212        let candidate = self
213            .service
214            .discover_recovery_artifact(self.root.join(PROOF_RECOVERY_ARTIFACT))?;
215        match self.service.prepare_recovery_artifact(&candidate, password) {
216            Err(CustodyError::RecoveryDecryptionFailed) => Ok(ProofRecoveryRejectionResult {
217                scenario: ProofRecoveryRejection::WrongPassword,
218                expected_outcome: "recovery-decryption-rejected",
219                production_locator_access: "rejected-by-construction",
220            }),
221            Err(error) => Err(error.into()),
222            Ok(_) => Err(IdentityProofError::UnexpectedRecoveryAdmission),
223        }
224    }
225
226    pub fn probe_corrupt_recovery_artifact(
227        &self,
228        password: RecoveryPassword,
229    ) -> Result<ProofRecoveryRejectionResult, IdentityProofError> {
230        let path = self.root.join(PROOF_CORRUPT_RECOVERY_ARTIFACT);
231        write_corrupt_recovery_artifact(&path)?;
232        let candidate = self.service.discover_recovery_artifact(path)?;
233        match self.service.prepare_recovery_artifact(&candidate, password) {
234            Err(CustodyError::InvalidRecoveryArtifact) => Ok(ProofRecoveryRejectionResult {
235                scenario: ProofRecoveryRejection::CorruptArtifact,
236                expected_outcome: "invalid-recovery-artifact-rejected",
237                production_locator_access: "rejected-by-construction",
238            }),
239            Err(error) => Err(error.into()),
240            Ok(_) => Err(IdentityProofError::UnexpectedRecoveryAdmission),
241        }
242    }
243
244    pub fn simulate_safe_scenario(&self, scenario: ProofSafeScenario) -> ProofSafeScenarioResult {
245        let expected_outcome = match scenario {
246            ProofSafeScenario::ConflictCustody
247            | ProofSafeScenario::LostCustody
248            | ProofSafeScenario::LockedCustody => "custody-denied",
249            ProofSafeScenario::SymlinkRefusal | ProofSafeScenario::WeakPermissionRefusal => {
250                "unsafe-public-store-rejected"
251            }
252            ProofSafeScenario::KeychainUnavailable | ProofSafeScenario::CorruptKeychain => {
253                "secure-store-error"
254            }
255            ProofSafeScenario::MalformedEventRejection
256            | ProofSafeScenario::UnadmittedPurposeRejection => "signing-request-rejected",
257            ProofSafeScenario::ConflictingRecoverySelection => "owner-selection-required",
258            ProofSafeScenario::LateCompletionFencing => "stale-completion-rejected",
259            ProofSafeScenario::SignerCrashBeforeCompletion => "no-completion-committed",
260        };
261        ProofSafeScenarioResult {
262            scenario,
263            mode: "deterministic-no-keychain-simulation",
264            expected_outcome,
265            production_locator_access: "rejected-by-construction",
266        }
267    }
268}
269
270fn write_corrupt_recovery_artifact(path: &Path) -> Result<(), std::io::Error> {
271    use std::io::Write as _;
272
273    let mut options = fs::OpenOptions::new();
274    options.create_new(true).write(true);
275    #[cfg(unix)]
276    {
277        use std::os::unix::fs::OpenOptionsExt as _;
278
279        options.mode(0o600).custom_flags(libc::O_CLOEXEC);
280    }
281    let mut file = options.open(path)?;
282    file.write_all(b"not-a-valid-nip49-recovery-artifact\n")?;
283    file.sync_all()
284}
285
286fn validate_root_shape(root: &Path) -> Result<(), IdentityProofError> {
287    if !root.is_absolute()
288        || root
289            .components()
290            .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
291        || !root
292            .file_name()
293            .and_then(|name| name.to_str())
294            .is_some_and(|name| {
295                name.starts_with(IDENTITY_PROOF_ROOT_PREFIX)
296                    && name.len() > IDENTITY_PROOF_ROOT_PREFIX.len()
297            })
298    {
299        return Err(IdentityProofError::UnsafeRoot);
300    }
301    Ok(())
302}
303
304fn reject_symbolic_links(root: &Path) -> Result<(), IdentityProofError> {
305    let mut path = PathBuf::new();
306    for component in root.components() {
307        path.push(component.as_os_str());
308        match fs::symlink_metadata(&path) {
309            Ok(metadata) if metadata.file_type().is_symlink() => {
310                return Err(IdentityProofError::SymbolicLink);
311            }
312            Ok(_) => {}
313            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
314            Err(error) => return Err(error.into()),
315        }
316    }
317    Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn rejects_production_and_ambiguous_roots() {
326        for root in [
327            Path::new("/tmp/Omega RC"),
328            Path::new("/tmp/omega-identity-proof-"),
329            Path::new("relative/omega-identity-proof-test"),
330        ] {
331            assert!(matches!(
332                IdentityProofService::initialize(root),
333                Err(IdentityProofError::UnsafeRoot)
334            ));
335        }
336    }
337
338    #[test]
339    fn sentinel_is_exact_and_required() {
340        let temporary = tempfile::tempdir().expect("temporary directory");
341        let root = temporary
342            .path()
343            .canonicalize()
344            .expect("canonical temporary directory")
345            .join("omega-identity-proof-test");
346        IdentityProofService::initialize(&root).expect("initialize proof root");
347        IdentityProofService::open(root.clone(), None).expect("open exact proof root");
348        fs::write(root.join(PROOF_SENTINEL), b"{}").expect("replace sentinel");
349        assert!(matches!(
350            IdentityProofService::open(root, None),
351            Err(IdentityProofError::MissingSentinel)
352        ));
353    }
354
355    #[cfg(unix)]
356    #[test]
357    fn rejects_symbolic_link_root() {
358        use std::os::unix::fs::symlink;
359        let temporary = tempfile::tempdir().expect("temporary directory");
360        let temporary_root = temporary
361            .path()
362            .canonicalize()
363            .expect("canonical temporary directory");
364        let actual = temporary_root.join("omega-identity-proof-actual");
365        fs::create_dir(&actual).expect("create actual root");
366        let linked = temporary_root.join("omega-identity-proof-linked");
367        symlink(actual, &linked).expect("create symlink");
368        assert!(matches!(
369            IdentityProofService::initialize(&linked),
370            Err(IdentityProofError::SymbolicLink)
371        ));
372    }
373
374    #[test]
375    fn safe_scenarios_are_explicit_and_never_claim_live_keychain_execution() {
376        let scenarios = [
377            ProofSafeScenario::ConflictCustody,
378            ProofSafeScenario::LostCustody,
379            ProofSafeScenario::LockedCustody,
380            ProofSafeScenario::SymlinkRefusal,
381            ProofSafeScenario::WeakPermissionRefusal,
382            ProofSafeScenario::KeychainUnavailable,
383            ProofSafeScenario::CorruptKeychain,
384            ProofSafeScenario::MalformedEventRejection,
385            ProofSafeScenario::UnadmittedPurposeRejection,
386            ProofSafeScenario::ConflictingRecoverySelection,
387            ProofSafeScenario::LateCompletionFencing,
388            ProofSafeScenario::SignerCrashBeforeCompletion,
389        ];
390        let temporary = tempfile::tempdir().expect("temporary directory");
391        let root = temporary
392            .path()
393            .canonicalize()
394            .expect("canonical temporary directory")
395            .join("omega-identity-proof-scenarios");
396        IdentityProofService::initialize(&root).expect("initialize proof root");
397        let service = IdentityProofService::open(root, None).expect("open proof root");
398        for scenario in scenarios {
399            let result = service.simulate_safe_scenario(scenario);
400            assert_eq!(result.scenario, scenario);
401            assert_eq!(result.mode, "deterministic-no-keychain-simulation");
402            assert_eq!(result.production_locator_access, "rejected-by-construction");
403            assert!(!result.expected_outcome.is_empty());
404        }
405    }
406}
407
Served at tenant.openagents/omega Member data and write actions are omitted.