Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:15:09.166Z 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.rs

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