Skip to repository content430 lines · 11.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:19:14.722Z 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.rs
1use std::{fmt, path::PathBuf};
2
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use zeroize::Zeroizing;
5
6use crate::{PublicIdentity, custody::CustodyError, secret::SecretKeyMaterial};
7
8pub const RECOVERY_PROTECTION_SCHEMA: &str = "openagents.omega.recovery-protection.v1";
9
10#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
11#[serde(transparent)]
12pub struct CandidateRef(String);
13
14impl CandidateRef {
15 pub(crate) fn new(value: String) -> Result<Self, InvalidCandidateRef> {
16 if value.is_empty()
17 || value.len() > 128
18 || !value
19 .bytes()
20 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
21 {
22 return Err(InvalidCandidateRef);
23 }
24 Ok(Self(value))
25 }
26
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30}
31
32impl fmt::Debug for CandidateRef {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 formatter
35 .debug_tuple("CandidateRef")
36 .field(&self.0)
37 .finish()
38 }
39}
40
41impl<'de> Deserialize<'de> for CandidateRef {
42 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
43 where
44 D: Deserializer<'de>,
45 {
46 Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
47 }
48}
49
50#[derive(Debug, Copy, Clone, PartialEq, Eq)]
51pub(crate) struct InvalidCandidateRef;
52
53impl fmt::Display for InvalidCandidateRef {
54 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55 formatter.write_str("candidate reference must be a non-empty portable identifier")
56 }
57}
58
59#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum CandidateKind {
62 EncryptedRecoveryArtifact,
63 AdvancedNostrImport,
64}
65
66#[derive(Clone, PartialEq, Eq)]
67pub struct RecoveryCandidate {
68 candidate_ref: CandidateRef,
69 kind: CandidateKind,
70 path: PathBuf,
71 byte_length: u64,
72}
73
74impl fmt::Debug for RecoveryCandidate {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 formatter
77 .debug_struct("RecoveryCandidate")
78 .field("candidate_ref", &self.candidate_ref)
79 .field("kind", &self.kind)
80 .field("path", &"[SELECTED PATH]")
81 .field("byte_length", &self.byte_length)
82 .finish()
83 }
84}
85
86impl RecoveryCandidate {
87 pub(crate) fn artifact(candidate_ref: CandidateRef, path: PathBuf, byte_length: u64) -> Self {
88 Self {
89 candidate_ref,
90 kind: CandidateKind::EncryptedRecoveryArtifact,
91 path,
92 byte_length,
93 }
94 }
95
96 pub fn candidate_ref(&self) -> &CandidateRef {
97 &self.candidate_ref
98 }
99
100 pub fn kind(&self) -> CandidateKind {
101 self.kind
102 }
103
104 pub fn path(&self) -> &std::path::Path {
105 &self.path
106 }
107
108 pub fn byte_length(&self) -> u64 {
109 self.byte_length
110 }
111}
112
113pub struct RecoveryPassword(Zeroizing<String>);
114
115impl RecoveryPassword {
116 pub fn new(password: String) -> Result<Self, InvalidRecoveryPassword> {
117 let password = Zeroizing::new(password);
118 if password.is_empty() || password.len() > 1_024 {
119 return Err(InvalidRecoveryPassword);
120 }
121 Ok(Self(password))
122 }
123
124 pub(crate) fn as_str(&self) -> &str {
125 self.0.as_str()
126 }
127}
128
129impl fmt::Debug for RecoveryPassword {
130 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131 formatter.write_str("RecoveryPassword([REDACTED])")
132 }
133}
134
135#[derive(Debug, Copy, Clone, PartialEq, Eq)]
136pub struct InvalidRecoveryPassword;
137
138impl fmt::Display for InvalidRecoveryPassword {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 formatter.write_str("recovery password must contain between 1 and 1024 bytes")
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct RecoveryProtectionRecord {
147 schema: String,
148 identity: PublicIdentity,
149 artifact_digest: String,
150 byte_length: u64,
151}
152
153impl RecoveryProtectionRecord {
154 pub(crate) fn new(
155 identity: PublicIdentity,
156 artifact_digest: String,
157 byte_length: u64,
158 ) -> Result<Self, CustodyError> {
159 let record = Self {
160 schema: RECOVERY_PROTECTION_SCHEMA.to_string(),
161 identity,
162 artifact_digest,
163 byte_length,
164 };
165 record.validate()?;
166 Ok(record)
167 }
168
169 pub fn validate(&self) -> Result<(), CustodyError> {
170 if self.schema != RECOVERY_PROTECTION_SCHEMA
171 || self.byte_length == 0
172 || self.artifact_digest.len() != 64
173 || !self
174 .artifact_digest
175 .bytes()
176 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
177 || self.identity.validate().is_err()
178 {
179 return Err(CustodyError::InvalidRecoveryProtection);
180 }
181 Ok(())
182 }
183
184 pub fn identity(&self) -> &PublicIdentity {
185 &self.identity
186 }
187
188 pub fn artifact_digest(&self) -> &str {
189 &self.artifact_digest
190 }
191
192 pub fn byte_length(&self) -> u64 {
193 self.byte_length
194 }
195}
196
197#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum RecoveryProtectionState {
200 NotApplicable,
201 Needed,
202 Protected,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct RecoveryProtectionStatus {
208 pub state: RecoveryProtectionState,
209 pub record: Option<RecoveryProtectionRecord>,
210}
211
212impl RecoveryProtectionStatus {
213 pub(crate) fn not_applicable() -> Self {
214 Self {
215 state: RecoveryProtectionState::NotApplicable,
216 record: None,
217 }
218 }
219
220 pub(crate) fn needed() -> Self {
221 Self {
222 state: RecoveryProtectionState::Needed,
223 record: None,
224 }
225 }
226
227 pub(crate) fn protected(record: RecoveryProtectionRecord) -> Self {
228 Self {
229 state: RecoveryProtectionState::Protected,
230 record: Some(record),
231 }
232 }
233}
234
235pub struct PreparedRecovery {
236 candidate_ref: CandidateRef,
237 kind: CandidateKind,
238 identity: PublicIdentity,
239 pub(crate) secret: SecretKeyMaterial,
240}
241
242pub struct SelectedRecovery(PreparedRecovery);
243
244impl SelectedRecovery {
245 pub(crate) fn new(prepared: PreparedRecovery) -> Self {
246 Self(prepared)
247 }
248
249 pub fn identity(&self) -> &PublicIdentity {
250 self.0.identity()
251 }
252
253 pub(crate) fn into_prepared(self) -> PreparedRecovery {
254 self.0
255 }
256}
257
258impl fmt::Debug for SelectedRecovery {
259 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
260 formatter
261 .debug_struct("SelectedRecovery")
262 .field("candidate_ref", self.0.candidate_ref())
263 .field("identity", self.0.identity())
264 .field("secret", &"[REDACTED]")
265 .finish()
266 }
267}
268
269impl PreparedRecovery {
270 pub(crate) fn new(
271 candidate_ref: CandidateRef,
272 kind: CandidateKind,
273 secret: SecretKeyMaterial,
274 ) -> Result<Self, CustodyError> {
275 let identity = secret
276 .public_identity()
277 .map_err(|_| CustodyError::InvalidImportedSecret)?;
278 Ok(Self {
279 candidate_ref,
280 kind,
281 identity,
282 secret,
283 })
284 }
285
286 pub fn candidate_ref(&self) -> &CandidateRef {
287 &self.candidate_ref
288 }
289
290 pub fn kind(&self) -> CandidateKind {
291 self.kind
292 }
293
294 pub fn identity(&self) -> &PublicIdentity {
295 &self.identity
296 }
297}
298
299impl fmt::Debug for PreparedRecovery {
300 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301 formatter
302 .debug_struct("PreparedRecovery")
303 .field("candidate_ref", &self.candidate_ref)
304 .field("kind", &self.kind)
305 .field("identity", &self.identity)
306 .field("secret", &"[REDACTED]")
307 .finish()
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct RecoveryCandidateSummary {
314 pub candidate_ref: CandidateRef,
315 pub kind: CandidateKind,
316 pub identity: PublicIdentity,
317}
318
319#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "kebab-case")]
321pub enum RecoveryResolutionState {
322 NoCandidates,
323 CandidateSelected,
324 OwnerSelectionRequired,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct RecoveryResolution {
330 pub state: RecoveryResolutionState,
331 pub candidates: Vec<RecoveryCandidateSummary>,
332 pub selected_candidate_ref: Option<CandidateRef>,
333}
334
335pub(crate) fn reconcile_prepared(candidates: &[&PreparedRecovery]) -> RecoveryResolution {
336 let mut unique: Vec<RecoveryCandidateSummary> = Vec::new();
337 for candidate in candidates {
338 if unique
339 .iter()
340 .any(|existing| existing.identity == candidate.identity)
341 {
342 continue;
343 }
344 unique.push(RecoveryCandidateSummary {
345 candidate_ref: candidate.candidate_ref.clone(),
346 kind: candidate.kind,
347 identity: candidate.identity.clone(),
348 });
349 }
350
351 match unique.as_slice() {
352 [] => RecoveryResolution {
353 state: RecoveryResolutionState::NoCandidates,
354 candidates: unique,
355 selected_candidate_ref: None,
356 },
357 [candidate] => RecoveryResolution {
358 state: RecoveryResolutionState::CandidateSelected,
359 selected_candidate_ref: Some(candidate.candidate_ref.clone()),
360 candidates: unique,
361 },
362 _ => RecoveryResolution {
363 state: RecoveryResolutionState::OwnerSelectionRequired,
364 candidates: unique,
365 selected_candidate_ref: None,
366 },
367 }
368}
369
370#[derive(Clone, PartialEq, Eq)]
371pub struct RecoveryArtifactReceipt {
372 path: PathBuf,
373 identity: PublicIdentity,
374 byte_length: u64,
375}
376
377impl fmt::Debug for RecoveryArtifactReceipt {
378 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
379 formatter
380 .debug_struct("RecoveryArtifactReceipt")
381 .field("path", &"[SELECTED PATH]")
382 .field("identity", &self.identity)
383 .field("byte_length", &self.byte_length)
384 .finish()
385 }
386}
387
388impl RecoveryArtifactReceipt {
389 pub(crate) fn new(path: PathBuf, identity: PublicIdentity, byte_length: u64) -> Self {
390 Self {
391 path,
392 identity,
393 byte_length,
394 }
395 }
396
397 pub fn path(&self) -> &std::path::Path {
398 &self.path
399 }
400
401 pub fn identity(&self) -> &PublicIdentity {
402 &self.identity
403 }
404
405 pub fn byte_length(&self) -> u64 {
406 self.byte_length
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn passwords_and_selected_paths_are_redacted_from_debug_output() {
416 let password = RecoveryPassword::new("sensitive recovery password".to_string())
417 .expect("valid recovery password");
418 assert_eq!(format!("{password:?}"), "RecoveryPassword([REDACTED])");
419
420 let candidate = RecoveryCandidate::artifact(
421 CandidateRef::new("candidate-one".to_string()).expect("valid candidate"),
422 PathBuf::from("/Users/example/private/recovery.ncryptsec"),
423 163,
424 );
425 let debug = format!("{candidate:?}");
426 assert!(!debug.contains("/Users/example"));
427 assert!(debug.contains("[SELECTED PATH]"));
428 }
429}
430