Skip to repository content407 lines · 14.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:00:05.369Z 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
proof.rs
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