Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:10:32.421Z 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

custody.rs

3467 lines · 137.8 KB · rust
1use std::{
2    path::{Path, PathBuf},
3    sync::{Arc, Mutex},
4};
5
6use app_identity::AppChannel;
7use nostr::{
8    Event, EventBuilder, JsonUtil, Kind,
9    nips::{
10        nip44::{self, Version as Nip44Version},
11        nip49::{EncryptedSecretKey, KeySecurity},
12        nip59,
13    },
14    secp256k1::Message,
15};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest as _, Sha256};
18use thiserror::Error;
19
20use crate::{
21    AdmittedSigningRequest, CompletionRecord, ContractError, CustodyConflict,
22    CustodyConflictReason, CustodyResult, CustodyState, GiftWrappedPrivateMessage,
23    IdentityInspection, IdentityManifest, IdentityRef, ImportedSecret, KeyringLocator,
24    NostrPublicKeyHex, OwnerAttestationRequest, OwnerAttestationResult, PendingIdentityOperation,
25    PendingIdentityTransaction, PrivateMessageRequest, PublicIdentity, PublicStoreError,
26    ReceiptRef, SigningResult, UnwrappedPrivateMessage,
27    mutation_lock::{IdentityMutationGuard, MutationLockError},
28    proof::{IDENTITY_PROOF_KEYRING_ACCOUNT, IDENTITY_PROOF_KEYRING_SERVICE, ProofCrashBoundary},
29    public_store::{
30        read_completion_record, read_completion_record_for_locator, read_identity_manifest,
31        read_identity_manifest_for_locator, read_json_document, remove_public_document,
32        write_completion_record, write_completion_record_for_locator, write_identity_manifest,
33        write_identity_manifest_for_locator, write_json_document,
34    },
35    recovery::{
36        CandidateKind, CandidateRef, PreparedRecovery, RecoveryArtifactReceipt, RecoveryCandidate,
37        RecoveryPassword, RecoveryProtectionRecord, RecoveryProtectionStatus, RecoveryResolution,
38        SelectedRecovery, reconcile_prepared,
39    },
40    recovery_artifact::{self, RecoveryArtifactError},
41    secret::{SecretKeyMaterial, SecretStore, StoreError, SystemKeyringStore},
42};
43
44const IDENTITY_TRANSACTION_SCHEMA: &str = "openagents.omega.identity-transaction.v1";
45const RESET_MARKER_SCHEMA: &str = "openagents.omega.identity-reset.v1";
46const NIP49_LOG_N: u8 = 16;
47static RECOVERY_KDF_LOCK: Mutex<()> = Mutex::new(());
48
49pub struct IdentityService {
50    channel: AppChannel,
51    locator: KeyringLocator,
52    paths: CustodyPaths,
53    store: Arc<dyn SecretStore>,
54    generator: Arc<dyn SecretGenerator>,
55    proof_crash_boundary: Option<ProofCrashBoundary>,
56}
57
58impl IdentityService {
59    pub fn system(channel: AppChannel) -> Self {
60        Self::new(
61            channel,
62            CustodyPaths::for_data_root(paths::data_dir().join("identity")),
63            Arc::new(SystemKeyringStore),
64            Arc::new(SystemSecretGenerator),
65        )
66    }
67
68    /// Build custody against the standard data root for `channel`.
69    ///
70    /// Unlike [`Self::system`], this does not depend on the compile-time
71    /// `app_identity::CHANNEL` path resolution, so an operator CLI can target
72    /// `rc` while built from a `dev` tree.
73    pub fn for_channel(channel: AppChannel) -> Self {
74        Self::for_channel_data_root(channel, channel_data_root(channel))
75    }
76
77    /// Build custody against an explicit channel data root (parent of `identity/`).
78    pub fn for_channel_data_root(channel: AppChannel, data_root: PathBuf) -> Self {
79        Self::new(
80            channel,
81            CustodyPaths::for_data_root(data_root.join("identity")),
82            Arc::new(SystemKeyringStore),
83            Arc::new(SystemSecretGenerator),
84        )
85    }
86
87    pub(crate) fn for_disposable_proof(
88        proof_root: PathBuf,
89        crash_boundary: Option<ProofCrashBoundary>,
90    ) -> Self {
91        Self {
92            channel: AppChannel::Rc,
93            locator: KeyringLocator::proof(
94                IDENTITY_PROOF_KEYRING_SERVICE,
95                IDENTITY_PROOF_KEYRING_ACCOUNT,
96            ),
97            paths: CustodyPaths::for_data_root(proof_root.join("identity")),
98            store: Arc::new(SystemKeyringStore),
99            generator: Arc::new(SystemSecretGenerator),
100            proof_crash_boundary: crash_boundary,
101        }
102    }
103
104    pub fn inspect(&self) -> Result<CustodyResult, CustodyError> {
105        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
106        if let Some(result) = self.resume_reset_if_pending_locked() {
107            return Ok(result);
108        }
109        Ok(self.resolve_locked().result)
110    }
111
112    pub fn inspect_details(&self) -> Result<IdentityInspection, CustodyError> {
113        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
114        self.inspect_details_locked()
115    }
116
117    pub fn inspect_for_process_start(&self) -> Result<IdentityInspection, CustodyError> {
118        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
119        if let Some(marker) = self.read_reset_marker_locked()?
120            && marker.status == ResetStatus::Complete
121        {
122            self.acknowledge_relaunch_locked(marker)?;
123        }
124        self.inspect_details_locked()
125    }
126
127    fn inspect_details_locked(&self) -> Result<IdentityInspection, CustodyError> {
128        if let Some(custody) = self.resume_reset_if_pending_locked() {
129            return Ok(IdentityInspection {
130                recovery_protection: self.recovery_protection_status_locked(&custody)?,
131                custody,
132                pending_transaction: None,
133                conflict: None,
134            });
135        }
136
137        let resolved = self.resolve_locked();
138        let pending_transaction = self
139            .read_transaction_locked()
140            .ok()
141            .flatten()
142            .map(IdentityTransaction::public_facts);
143        let recovery_protection = self.recovery_protection_status_locked(&resolved.result)?;
144        Ok(IdentityInspection {
145            custody: resolved.result,
146            pending_transaction,
147            conflict: resolved.conflict,
148            recovery_protection,
149        })
150    }
151
152    pub fn open(&self, expected_identity: &IdentityRef) -> Result<CustodyResult, CustodyError> {
153        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
154        self.require_no_reset_locked()?;
155        let mut result = self.resolve_locked().result;
156        if let Some(identity) = &result.identity
157            && identity.identity_ref() != expected_identity
158        {
159            result.state = CustodyState::Conflict;
160        }
161        Ok(result)
162    }
163
164    pub fn create(&self, receipt_ref: ReceiptRef) -> Result<CustodyResult, CustodyError> {
165        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
166        self.require_no_reset_locked()?;
167        if let Some(result) = self.ready_idempotent_result_locked(&receipt_ref, None)? {
168            return Ok(result);
169        }
170
171        let transaction = self.begin_or_resume_transaction_locked(
172            TransactionOperation::Create,
173            receipt_ref,
174            None,
175            None,
176        )?;
177        let secret = match self
178            .store
179            .read(&self.locator)
180            .map_err(custody_error_from_store)?
181        {
182            Some(secret) => secret,
183            None => self.generator.generate(),
184        };
185        self.commit_secret_locked(secret, transaction)
186    }
187
188    pub fn resume_incomplete_create(&self) -> Result<CustodyResult, CustodyError> {
189        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
190        self.require_no_reset_locked()?;
191        let transaction = self
192            .read_transaction_locked()?
193            .ok_or(CustodyError::CustodyDenied(CustodyState::Absent))?;
194        if transaction.operation != TransactionOperation::Create {
195            return Err(CustodyError::CustodyDenied(CustodyState::Incomplete));
196        }
197        let resolved = self.resolve_locked();
198        if resolved.result.state != CustodyState::Incomplete {
199            return Err(CustodyError::CustodyDenied(resolved.result.state));
200        }
201        let secret = match resolved.secret {
202            Some(secret) => secret,
203            None if transaction.expected_identity.is_none() => self.generator.generate(),
204            None => return Err(CustodyError::TransactionIncomplete),
205        };
206        self.commit_secret_locked(secret, transaction)
207    }
208
209    pub fn import(
210        &self,
211        imported_secret: ImportedSecret,
212        receipt_ref: ReceiptRef,
213    ) -> Result<CustodyResult, CustodyError> {
214        let prepared = self.prepare_import(imported_secret)?;
215        let candidate_ref = prepared.candidate_ref().clone();
216        let selected = self.select_recovery(vec![prepared], &candidate_ref)?;
217        self.adopt(selected, receipt_ref)
218    }
219
220    pub fn prepare_import(
221        &self,
222        imported_secret: ImportedSecret,
223    ) -> Result<PreparedRecovery, CustodyError> {
224        let secret = SecretKeyMaterial::from_imported(imported_secret)
225            .map_err(|_| CustodyError::InvalidImportedSecret)?;
226        let candidate_ref = CandidateRef::new("advanced-nostr-import".to_string())
227            .map_err(|_| CustodyError::InvalidRecoveryCandidate)?;
228        PreparedRecovery::new(candidate_ref, CandidateKind::AdvancedNostrImport, secret)
229    }
230
231    pub fn discover_recovery_artifact(
232        &self,
233        path: PathBuf,
234    ) -> Result<RecoveryCandidate, CustodyError> {
235        recovery_artifact::discover(path).map_err(CustodyError::from)
236    }
237
238    pub fn prepare_recovery_artifact(
239        &self,
240        candidate: &RecoveryCandidate,
241        password: RecoveryPassword,
242    ) -> Result<PreparedRecovery, CustodyError> {
243        let encrypted = recovery_artifact::read_encrypted(candidate)?;
244        let _kdf_guard = RECOVERY_KDF_LOCK
245            .lock()
246            .map_err(|_| CustodyError::RecoveryDecryptionFailed)?;
247        let secret_key = encrypted
248            .decrypt(password.as_str())
249            .map_err(|_| CustodyError::RecoveryDecryptionFailed)?;
250        PreparedRecovery::new(
251            candidate.candidate_ref().clone(),
252            CandidateKind::EncryptedRecoveryArtifact,
253            SecretKeyMaterial::from_secret_key(secret_key),
254        )
255    }
256
257    pub fn reconcile_recoveries(&self, candidates: &[&PreparedRecovery]) -> RecoveryResolution {
258        reconcile_prepared(candidates)
259    }
260
261    pub fn select_recovery(
262        &self,
263        mut candidates: Vec<PreparedRecovery>,
264        selected_candidate_ref: &CandidateRef,
265    ) -> Result<SelectedRecovery, CustodyError> {
266        if candidates.is_empty() {
267            return Err(CustodyError::InvalidRecoveryCandidate);
268        }
269        for (index, candidate) in candidates.iter().enumerate() {
270            if candidates[..index]
271                .iter()
272                .any(|previous| previous.candidate_ref() == candidate.candidate_ref())
273            {
274                return Err(CustodyError::InvalidRecoveryCandidate);
275            }
276        }
277        let selected_index = candidates
278            .iter()
279            .position(|candidate| candidate.candidate_ref() == selected_candidate_ref)
280            .ok_or(CustodyError::InvalidRecoveryCandidate)?;
281        let selected = candidates.swap_remove(selected_index);
282        let resolution = reconcile_prepared(
283            &std::iter::once(&selected)
284                .chain(candidates.iter())
285                .collect::<Vec<_>>(),
286        );
287        if resolution.candidates.is_empty() {
288            return Err(CustodyError::InvalidRecoveryCandidate);
289        }
290        Ok(SelectedRecovery::new(selected))
291    }
292
293    pub fn adopt(
294        &self,
295        selected: SelectedRecovery,
296        receipt_ref: ReceiptRef,
297    ) -> Result<CustodyResult, CustodyError> {
298        let prepared = selected.into_prepared();
299        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
300        self.require_no_reset_locked()?;
301        if let Some(result) =
302            self.ready_idempotent_result_locked(&receipt_ref, Some(prepared.identity()))?
303        {
304            return Ok(result);
305        }
306
307        let transaction = self.begin_or_resume_transaction_locked(
308            TransactionOperation::Import,
309            receipt_ref,
310            Some(prepared.candidate_ref().clone()),
311            Some(prepared.identity()),
312        )?;
313        if let Some(expected_identity) = &transaction.expected_identity
314            && expected_identity != prepared.identity()
315        {
316            return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
317        }
318        self.commit_secret_locked(prepared.secret, transaction)
319    }
320
321    pub fn resolve_conflict(
322        &self,
323        selected: SelectedRecovery,
324        receipt_ref: ReceiptRef,
325    ) -> Result<CustodyResult, CustodyError> {
326        let prepared = selected.into_prepared();
327        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
328        self.require_no_reset_locked()?;
329        if let Some(result) =
330            self.ready_idempotent_result_locked(&receipt_ref, Some(prepared.identity()))?
331        {
332            return Ok(result);
333        }
334
335        let transaction = match self.read_transaction_locked()? {
336            Some(transaction) => {
337                if transaction.operation != TransactionOperation::ResolveConflict
338                    || transaction.receipt_ref != receipt_ref
339                    || transaction.candidate_ref.as_ref() != Some(prepared.candidate_ref())
340                    || transaction.expected_identity.as_ref() != Some(prepared.identity())
341                {
342                    return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
343                }
344                transaction
345            }
346            None => {
347                let resolved = self.resolve_locked();
348                let conflict = resolved
349                    .conflict
350                    .ok_or(CustodyError::CustodyDenied(resolved.result.state))?;
351                if conflict.reason == CustodyConflictReason::AmbiguousSecureStore
352                    || !conflict
353                        .identities
354                        .iter()
355                        .any(|identity| identity == prepared.identity())
356                {
357                    return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
358                }
359                let mut transaction = IdentityTransaction::new(
360                    TransactionOperation::ResolveConflict,
361                    receipt_ref,
362                    Some(prepared.candidate_ref().clone()),
363                );
364                transaction.expected_identity = Some(prepared.identity().clone());
365                transaction.conflict_identities = conflict.identities;
366                write_json_document(&self.paths.transaction_path, &transaction)?;
367                transaction
368            }
369        };
370
371        if let Some(stored_secret) = self
372            .store
373            .read(&self.locator)
374            .map_err(custody_error_from_store)?
375        {
376            let stored_identity = stored_secret
377                .public_identity()
378                .map_err(|_| CustodyError::ReadBackMismatch)?;
379            if !transaction
380                .conflict_identities
381                .iter()
382                .any(|identity| identity == &stored_identity)
383            {
384                return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
385            }
386            if &stored_identity != prepared.identity() {
387                self.store
388                    .delete(&self.locator)
389                    .map_err(|_| CustodyError::TransactionIncomplete)?;
390                match self.store.read(&self.locator) {
391                    Ok(None) => {}
392                    Ok(Some(_)) | Err(_) => return Err(CustodyError::TransactionIncomplete),
393                }
394            }
395        }
396
397        self.commit_secret_locked(prepared.secret, transaction)
398    }
399
400    pub fn export_recovery_artifact(
401        &self,
402        expected_identity: &IdentityRef,
403        path: &Path,
404        password: RecoveryPassword,
405    ) -> Result<RecoveryArtifactReceipt, CustodyError> {
406        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
407        self.require_no_reset_locked()?;
408        let resolved = self.resolve_locked();
409        if resolved.result.state != CustodyState::Ready {
410            return Err(CustodyError::CustodyDenied(resolved.result.state));
411        }
412        let identity = resolved
413            .result
414            .identity
415            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
416        if identity.identity_ref() != expected_identity {
417            return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
418        }
419        let secret = resolved
420            .secret
421            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
422        let keys = secret
423            .keys()
424            .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?;
425        let _kdf_guard = RECOVERY_KDF_LOCK
426            .lock()
427            .map_err(|_| CustodyError::RecoveryEncryptionFailed)?;
428        let encrypted = EncryptedSecretKey::new(
429            keys.secret_key(),
430            password.as_str(),
431            NIP49_LOG_N,
432            KeySecurity::Unknown,
433        )
434        .map_err(|_| CustodyError::RecoveryEncryptionFailed)?;
435        let artifact_write = recovery_artifact::write_encrypted(path, &encrypted)?;
436        let protection = RecoveryProtectionRecord::new(
437            identity.clone(),
438            artifact_write.digest,
439            artifact_write.byte_length,
440        )?;
441        write_json_document(&self.paths.recovery_protection_path, &protection)?;
442        Ok(RecoveryArtifactReceipt::new(
443            path.to_path_buf(),
444            identity,
445            artifact_write.byte_length,
446        ))
447    }
448
449    pub fn sign(&self, request: &AdmittedSigningRequest) -> Result<SigningResult, CustodyError> {
450        if request.purpose != crate::SigningPurpose::NostrEvent {
451            return Err(CustodyError::SigningFailed);
452        }
453        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
454        self.require_no_reset_locked()?;
455        let resolved = self.resolve_locked();
456        if resolved.result.state != CustodyState::Ready {
457            return Err(CustodyError::CustodyDenied(resolved.result.state));
458        }
459        let identity = resolved
460            .result
461            .identity
462            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
463        let secret = resolved
464            .secret
465            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
466        let unsigned_event = request.unsigned_event(&identity)?;
467        let event = unsigned_event
468            .sign_with_keys(
469                &secret
470                    .keys()
471                    .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?,
472            )
473            .map_err(|_| CustodyError::SigningFailed)?;
474        let signed_event_json = event
475            .try_as_json()
476            .map_err(|_| CustodyError::SigningFailed)?;
477
478        Ok(SigningResult {
479            request_ref: request.request_ref.clone(),
480            identity,
481            event_id: event.id.to_hex(),
482            signature: event.sig.to_string(),
483            signed_event_json,
484        })
485    }
486
487    pub fn sign_owner_attestation(
488        &self,
489        request: &OwnerAttestationRequest,
490    ) -> Result<OwnerAttestationResult, CustodyError> {
491        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
492        self.require_no_reset_locked()?;
493        let resolved = self.resolve_locked();
494        if resolved.result.state != CustodyState::Ready {
495            return Err(CustodyError::CustodyDenied(resolved.result.state));
496        }
497        let identity = resolved
498            .result
499            .identity
500            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
501        request.validate(&identity)?;
502        let secret = resolved
503            .secret
504            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
505        let keys = secret
506            .keys()
507            .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?;
508        let digest = Sha256::digest(
509            format!(
510                "nostr:agent-auth:{}:{}",
511                request.agent_public_key_hex.as_str(),
512                request.conditions
513            )
514            .as_bytes(),
515        );
516        let message = Message::from_digest(digest.into());
517        let signature = keys.sign_schnorr(&message).to_string();
518        let auth_tag = vec![
519            "auth".to_string(),
520            identity.public_key_hex().as_str().to_string(),
521            request.conditions.clone(),
522            signature,
523        ];
524        Ok(OwnerAttestationResult {
525            request_ref: request.request_ref.clone(),
526            identity,
527            agent_public_key_hex: request.agent_public_key_hex.clone(),
528            auth_tag,
529        })
530    }
531
532    pub fn sign_nip44_encrypted_to_self(
533        &self,
534        request: &AdmittedSigningRequest,
535    ) -> Result<SigningResult, CustodyError> {
536        if request.purpose != crate::SigningPurpose::Nip44EncryptedSelfEvent {
537            return Err(CustodyError::SigningFailed);
538        }
539        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
540        self.require_no_reset_locked()?;
541        let resolved = self.resolve_locked();
542        if resolved.result.state != CustodyState::Ready {
543            return Err(CustodyError::CustodyDenied(resolved.result.state));
544        }
545        let identity = resolved
546            .result
547            .identity
548            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
549        let secret = resolved
550            .secret
551            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
552        let keys = secret
553            .keys()
554            .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?;
555        let ciphertext = nip44::encrypt(
556            keys.secret_key(),
557            &keys.public_key(),
558            request.event.content.as_bytes(),
559            Nip44Version::V2,
560        )
561        .map_err(|_| CustodyError::SigningFailed)?;
562        let mut encrypted_request = request.clone();
563        encrypted_request.purpose = crate::SigningPurpose::NostrEvent;
564        encrypted_request.event.content = ciphertext;
565        let unsigned_event = encrypted_request.unsigned_event(&identity)?;
566        let event = unsigned_event
567            .sign_with_keys(&keys)
568            .map_err(|_| CustodyError::SigningFailed)?;
569        let signed_event_json = event
570            .try_as_json()
571            .map_err(|_| CustodyError::SigningFailed)?;
572        Ok(SigningResult {
573            request_ref: request.request_ref.clone(),
574            identity,
575            event_id: event.id.to_hex(),
576            signature: event.sig.to_string(),
577            signed_event_json,
578        })
579    }
580
581    pub fn decrypt_nip44_from(
582        &self,
583        sender_public_key_hex: &NostrPublicKeyHex,
584        ciphertext: &str,
585    ) -> Result<String, CustodyError> {
586        if ciphertext.len() > 1_048_576 {
587            return Err(CustodyError::SigningFailed);
588        }
589        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
590        self.require_no_reset_locked()?;
591        let resolved = self.resolve_locked();
592        if resolved.result.state != CustodyState::Ready {
593            return Err(CustodyError::CustodyDenied(resolved.result.state));
594        }
595        let secret = resolved
596            .secret
597            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
598        let keys = secret
599            .keys()
600            .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?;
601        let sender_public_key = sender_public_key_hex.public_key()?;
602        nip44::decrypt(keys.secret_key(), &sender_public_key, ciphertext.as_bytes())
603            .map_err(|_| CustodyError::SigningFailed)
604    }
605
606    pub fn decrypt_nip44_from_self(&self, ciphertext: &str) -> Result<String, CustodyError> {
607        let identity = self
608            .inspect()?
609            .identity
610            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
611        let public_key = NostrPublicKeyHex::new(identity.public_key_hex().as_str())?;
612        self.decrypt_nip44_from(&public_key, ciphertext)
613    }
614
615    pub fn gift_wrap_private_message(
616        &self,
617        request: &PrivateMessageRequest,
618    ) -> Result<Vec<GiftWrappedPrivateMessage>, CustodyError> {
619        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
620        self.require_no_reset_locked()?;
621        let resolved = self.resolve_locked();
622        if resolved.result.state != CustodyState::Ready {
623            return Err(CustodyError::CustodyDenied(resolved.result.state));
624        }
625        let identity = resolved
626            .result
627            .identity
628            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
629        let secret = resolved
630            .secret
631            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
632        let keys = secret
633            .keys()
634            .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?;
635        let rumor = request.unsigned_rumor(&identity)?;
636        rumor.verify_id().map_err(|_| CustodyError::SigningFailed)?;
637        let rumor_event_id = rumor.id.ok_or(CustodyError::SigningFailed)?.to_hex();
638        let mut wrapped = Vec::with_capacity(request.recipients.len());
639        for recipient in &request.recipients {
640            let recipient_public_key = recipient.public_key()?;
641            let gift_wrap = smol::block_on(EventBuilder::gift_wrap(
642                &keys,
643                &recipient_public_key,
644                rumor.clone(),
645                [],
646            ))
647            .map_err(|_| CustodyError::SigningFailed)?;
648            wrapped.push(GiftWrappedPrivateMessage {
649                receiver_public_key_hex: recipient.as_str().to_string(),
650                rumor_event_id: rumor_event_id.clone(),
651                gift_wrap_event_json: gift_wrap
652                    .try_as_json()
653                    .map_err(|_| CustodyError::SigningFailed)?,
654            });
655        }
656        Ok(wrapped)
657    }
658
659    pub fn unwrap_private_message(
660        &self,
661        gift_wrap_event_json: &str,
662    ) -> Result<UnwrappedPrivateMessage, CustodyError> {
663        if gift_wrap_event_json.len() > 1_048_576 {
664            return Err(CustodyError::SigningFailed);
665        }
666        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
667        self.require_no_reset_locked()?;
668        let resolved = self.resolve_locked();
669        if resolved.result.state != CustodyState::Ready {
670            return Err(CustodyError::CustodyDenied(resolved.result.state));
671        }
672        let secret = resolved
673            .secret
674            .ok_or(CustodyError::CustodyDenied(CustodyState::Incomplete))?;
675        let keys = secret
676            .keys()
677            .map_err(|_| CustodyError::CustodyDenied(CustodyState::Incomplete))?;
678        let gift_wrap =
679            Event::from_json(gift_wrap_event_json).map_err(|_| CustodyError::SigningFailed)?;
680        if gift_wrap.kind != Kind::GiftWrap || gift_wrap.verify().is_err() {
681            return Err(CustodyError::SigningFailed);
682        }
683        let gift = smol::block_on(nip59::extract_rumor(&keys, &gift_wrap))
684            .map_err(|_| CustodyError::SigningFailed)?;
685        if gift.rumor.kind != Kind::PrivateDirectMessage {
686            return Err(CustodyError::SigningFailed);
687        }
688        gift.rumor
689            .verify_id()
690            .map_err(|_| CustodyError::SigningFailed)?;
691        let rumor_event_id = gift.rumor.id.ok_or(CustodyError::SigningFailed)?.to_hex();
692        Ok(UnwrappedPrivateMessage {
693            rumor_event_id,
694            sender_public_key_hex: gift.sender.to_hex(),
695            created_at: gift.rumor.created_at.as_secs(),
696            tags: gift
697                .rumor
698                .tags
699                .iter()
700                .map(|tag| tag.as_slice().to_vec())
701                .collect(),
702            content: gift.rumor.content,
703        })
704    }
705
706    pub fn reset(
707        &self,
708        expected_identity: &IdentityRef,
709        authorization_ref: ReceiptRef,
710    ) -> Result<CustodyResult, CustodyError> {
711        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
712        if let Some(marker) = self.read_reset_marker_locked()? {
713            if marker.expected_identity != *expected_identity
714                || marker.authorization_ref != authorization_ref
715            {
716                return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
717            }
718            return Ok(marker.result());
719        }
720        let resolved = self.resolve_locked();
721        if let Some(identity) = &resolved.result.identity {
722            if identity.identity_ref() != expected_identity {
723                return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
724            }
725        } else {
726            return Err(CustodyError::CustodyDenied(resolved.result.state));
727        }
728
729        let marker = ResetMarker::pending(expected_identity.clone(), authorization_ref);
730        write_json_document(&self.paths.reset_path, &marker)?;
731        self.trigger_proof_crash(ProofCrashBoundary::AfterResetMarker);
732        Ok(marker.result())
733    }
734
735    pub fn resume_pending_reset(&self) -> Result<CustodyResult, CustodyError> {
736        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
737        self.resume_reset_if_pending_locked()
738            .ok_or(CustodyError::CustodyDenied(CustodyState::Absent))
739    }
740
741    pub fn acknowledge_relaunch(&self) -> Result<CustodyResult, CustodyError> {
742        let _mutation_guard = IdentityMutationGuard::acquire(&self.locator)?;
743        let marker = self
744            .read_reset_marker_locked()?
745            .ok_or(CustodyError::CustodyDenied(CustodyState::Absent))?;
746        self.acknowledge_relaunch_locked(marker)
747    }
748
749    fn acknowledge_relaunch_locked(
750        &self,
751        marker: ResetMarker,
752    ) -> Result<CustodyResult, CustodyError> {
753        if marker.status != ResetStatus::Complete
754            || self
755                .store
756                .read(&self.locator)
757                .map_err(custody_error_from_store)?
758                .is_some()
759            || self.read_manifest_locked()?.is_some()
760            || self
761                .paths
762                .completion_path
763                .try_exists()
764                .map_err(|_| CustodyError::ResetFailed)?
765            || self
766                .paths
767                .transaction_path
768                .try_exists()
769                .map_err(|_| CustodyError::ResetFailed)?
770            || self
771                .paths
772                .recovery_protection_path
773                .try_exists()
774                .map_err(|_| CustodyError::ResetFailed)?
775        {
776            return Err(CustodyError::ResetFailed);
777        }
778        remove_public_document(&self.paths.reset_path)?;
779        self.trigger_proof_crash(ProofCrashBoundary::AfterRelaunchAcknowledge);
780        Ok(CustodyResult {
781            state: CustodyState::Absent,
782            identity: None,
783            receipt_ref: Some(marker.authorization_ref),
784        })
785    }
786
787    fn new(
788        channel: AppChannel,
789        paths: CustodyPaths,
790        store: Arc<dyn SecretStore>,
791        generator: Arc<dyn SecretGenerator>,
792    ) -> Self {
793        Self {
794            channel,
795            locator: KeyringLocator::for_channel(channel),
796            paths,
797            store,
798            generator,
799            proof_crash_boundary: None,
800        }
801    }
802
803    fn require_no_reset_locked(&self) -> Result<(), CustodyError> {
804        if let Some(marker) = self.read_reset_marker_locked()? {
805            return Err(CustodyError::CustodyDenied(marker.result().state));
806        }
807        Ok(())
808    }
809
810    fn ready_idempotent_result_locked(
811        &self,
812        receipt_ref: &ReceiptRef,
813        expected_identity: Option<&PublicIdentity>,
814    ) -> Result<Option<CustodyResult>, CustodyError> {
815        let result = self.resolve_locked().result;
816        if result.state != CustodyState::Ready {
817            return Ok(None);
818        }
819        if let Some(expected_identity) = expected_identity {
820            if result.identity.as_ref() != Some(expected_identity) {
821                return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
822            }
823            if result.receipt_ref.as_ref() != Some(receipt_ref) {
824                return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
825            }
826            if let Some(transaction) = self.read_transaction_locked()? {
827                if transaction.expected_identity.as_ref() != result.identity.as_ref() {
828                    return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
829                }
830                remove_public_document(&self.paths.transaction_path)?;
831            }
832            return Ok(Some(result));
833        }
834        if result.receipt_ref.as_ref() == Some(receipt_ref) {
835            if let Some(transaction) = self.read_transaction_locked()? {
836                if transaction.operation != TransactionOperation::Create
837                    || &transaction.receipt_ref != receipt_ref
838                    || transaction.expected_identity.as_ref() != result.identity.as_ref()
839                {
840                    return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
841                }
842                remove_public_document(&self.paths.transaction_path)?;
843            }
844            return Ok(Some(result));
845        }
846        Ok(None)
847    }
848
849    fn begin_or_resume_transaction_locked(
850        &self,
851        operation: TransactionOperation,
852        receipt_ref: ReceiptRef,
853        candidate_ref: Option<CandidateRef>,
854        expected_identity: Option<&PublicIdentity>,
855    ) -> Result<IdentityTransaction, CustodyError> {
856        if let Some(transaction) = self.read_transaction_locked()? {
857            if transaction.operation != operation
858                || transaction.receipt_ref != receipt_ref
859                || transaction.candidate_ref != candidate_ref
860            {
861                return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
862            }
863            return Ok(transaction);
864        }
865
866        let result = self.resolve_locked().result;
867        let can_recover = operation == TransactionOperation::Import
868            && matches!(result.state, CustodyState::Lost | CustodyState::Incomplete)
869            && result.identity.as_ref() == expected_identity;
870        if result.state != CustodyState::Absent && !can_recover {
871            return Err(CustodyError::CustodyDenied(result.state));
872        }
873        let mut transaction = IdentityTransaction::new(operation, receipt_ref, candidate_ref);
874        if can_recover {
875            transaction.expected_identity = expected_identity.cloned();
876        }
877        write_json_document(&self.paths.transaction_path, &transaction)?;
878        Ok(transaction)
879    }
880
881    fn commit_secret_locked(
882        &self,
883        secret: SecretKeyMaterial,
884        mut transaction: IdentityTransaction,
885    ) -> Result<CustodyResult, CustodyError> {
886        let expected_identity = secret
887            .public_identity()
888            .map_err(|_| CustodyError::InvalidImportedSecret)?;
889        if let Some(transaction_identity) = &transaction.expected_identity
890            && transaction_identity != &expected_identity
891        {
892            return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
893        }
894        let rollback_on_failure = transaction.expected_identity.is_none();
895        let mut write_attempted = false;
896        let mut resumed_existing_secret = false;
897
898        let result = (|| {
899            match self
900                .store
901                .read(&self.locator)
902                .map_err(custody_error_from_store)?
903            {
904                Some(stored_secret) => {
905                    resumed_existing_secret = true;
906                    let stored_identity = stored_secret
907                        .public_identity()
908                        .map_err(|_| CustodyError::ReadBackMismatch)?;
909                    if stored_identity != expected_identity {
910                        return Err(CustodyError::CustodyDenied(CustodyState::Conflict));
911                    }
912                }
913                None => {
914                    write_attempted = true;
915                    self.store
916                        .write(&self.locator, &secret)
917                        .map_err(custody_error_from_store)?;
918                    self.trigger_proof_crash(ProofCrashBoundary::AfterSecretWrite);
919                }
920            }
921
922            let read_back = self
923                .store
924                .read(&self.locator)
925                .map_err(custody_error_from_store)?
926                .ok_or(CustodyError::ReadBackMismatch)?;
927            let read_back_identity = read_back
928                .public_identity()
929                .map_err(|_| CustodyError::ReadBackMismatch)?;
930            if read_back_identity != expected_identity {
931                return Err(CustodyError::ReadBackMismatch);
932            }
933            self.trigger_proof_crash(ProofCrashBoundary::AfterSecretReadBack);
934
935            transaction.expected_identity = Some(expected_identity.clone());
936            write_json_document(&self.paths.transaction_path, &transaction)?;
937            let manifest = IdentityManifest::new(
938                expected_identity.clone(),
939                self.locator.clone(),
940                vec![transaction.receipt_ref.clone()],
941            );
942            self.write_manifest_locked(&manifest)?;
943            self.trigger_proof_crash(ProofCrashBoundary::AfterManifestCommit);
944            let completion = if self.is_disposable_proof() {
945                CompletionRecord::new_for_locator(
946                    &manifest,
947                    transaction.receipt_ref.clone(),
948                    &self.locator,
949                )?
950            } else {
951                CompletionRecord::new(&manifest, transaction.receipt_ref.clone(), self.channel)?
952            };
953            self.write_completion_locked(&completion, &manifest)?;
954            self.remove_mismatched_recovery_protection_locked(&expected_identity)?;
955
956            Ok(CustodyResult {
957                state: CustodyState::Ready,
958                identity: Some(expected_identity),
959                receipt_ref: Some(transaction.receipt_ref.clone()),
960            })
961        })();
962
963        match result {
964            Ok(result) => {
965                remove_public_document(&self.paths.transaction_path)?;
966                Ok(result)
967            }
968            Err(error) if rollback_on_failure && write_attempted => {
969                self.rollback_transaction_locked()?;
970                Err(error)
971            }
972            Err(error) if rollback_on_failure && resumed_existing_secret => Err(error),
973            Err(error) if rollback_on_failure => {
974                remove_public_document(&self.paths.transaction_path)?;
975                Err(error)
976            }
977            Err(error) => Err(error),
978        }
979    }
980
981    fn rollback_transaction_locked(&self) -> Result<(), CustodyError> {
982        self.store
983            .delete(&self.locator)
984            .map_err(|_| CustodyError::TransactionIncomplete)?;
985        match self.store.read(&self.locator) {
986            Ok(None) => {}
987            Ok(Some(_)) | Err(_) => return Err(CustodyError::TransactionIncomplete),
988        }
989        remove_public_document(&self.paths.completion_path)
990            .map_err(|_| CustodyError::TransactionIncomplete)?;
991        remove_public_document(&self.paths.manifest_path)
992            .map_err(|_| CustodyError::TransactionIncomplete)?;
993        remove_public_document(&self.paths.transaction_path)
994            .map_err(|_| CustodyError::TransactionIncomplete)
995    }
996
997    fn read_transaction_locked(&self) -> Result<Option<IdentityTransaction>, CustodyError> {
998        let transaction: Option<IdentityTransaction> =
999            read_json_document(&self.paths.transaction_path)
1000                .map_err(|_| CustodyError::TransactionIncomplete)?;
1001        if transaction
1002            .as_ref()
1003            .is_some_and(|transaction| !transaction.is_valid())
1004        {
1005            return Err(CustodyError::TransactionIncomplete);
1006        }
1007        Ok(transaction)
1008    }
1009
1010    fn read_reset_marker_locked(&self) -> Result<Option<ResetMarker>, CustodyError> {
1011        let marker: Option<ResetMarker> =
1012            read_json_document(&self.paths.reset_path).map_err(|_| CustodyError::ResetFailed)?;
1013        if marker.as_ref().is_some_and(|marker| !marker.is_valid()) {
1014            return Err(CustodyError::ResetFailed);
1015        }
1016        Ok(marker)
1017    }
1018
1019    fn read_recovery_protection_locked(
1020        &self,
1021    ) -> Result<Option<RecoveryProtectionRecord>, CustodyError> {
1022        let record: Option<RecoveryProtectionRecord> =
1023            match read_json_document(&self.paths.recovery_protection_path) {
1024                Ok(record) => record,
1025                Err(PublicStoreError::Serialization(_)) => {
1026                    remove_public_document(&self.paths.recovery_protection_path)?;
1027                    return Ok(None);
1028                }
1029                Err(error) => return Err(error.into()),
1030            };
1031        if record
1032            .as_ref()
1033            .is_some_and(|record| record.validate().is_err())
1034        {
1035            remove_public_document(&self.paths.recovery_protection_path)?;
1036            return Ok(None);
1037        }
1038        Ok(record)
1039    }
1040
1041    fn recovery_protection_status_locked(
1042        &self,
1043        custody: &CustodyResult,
1044    ) -> Result<RecoveryProtectionStatus, CustodyError> {
1045        let Some(identity) = custody.identity.as_ref() else {
1046            return Ok(RecoveryProtectionStatus::not_applicable());
1047        };
1048        match self.read_recovery_protection_locked()? {
1049            Some(record) if record.identity() == identity => {
1050                Ok(RecoveryProtectionStatus::protected(record))
1051            }
1052            Some(_) => {
1053                remove_public_document(&self.paths.recovery_protection_path)?;
1054                Ok(RecoveryProtectionStatus::needed())
1055            }
1056            None => Ok(RecoveryProtectionStatus::needed()),
1057        }
1058    }
1059
1060    fn remove_mismatched_recovery_protection_locked(
1061        &self,
1062        identity: &PublicIdentity,
1063    ) -> Result<(), CustodyError> {
1064        if self
1065            .read_recovery_protection_locked()?
1066            .is_some_and(|record| record.identity() != identity)
1067        {
1068            remove_public_document(&self.paths.recovery_protection_path)?;
1069        }
1070        Ok(())
1071    }
1072
1073    fn resume_reset_if_pending_locked(&self) -> Option<CustodyResult> {
1074        let marker = match self.read_reset_marker_locked() {
1075            Ok(Some(marker)) => marker,
1076            Ok(None) => return None,
1077            Err(_) => return Some(CustodyResult::for_state(CustodyState::ResetFailed)),
1078        };
1079        if marker.status == ResetStatus::Complete {
1080            return Some(marker.result());
1081        }
1082        Some(match self.complete_reset_locked(marker.clone()) {
1083            Ok(()) => ResetMarker {
1084                status: ResetStatus::Complete,
1085                ..marker
1086            }
1087            .result(),
1088            Err(_) => {
1089                let failed_marker = ResetMarker {
1090                    status: ResetStatus::Failed,
1091                    ..marker
1092                };
1093                if write_json_document(&self.paths.reset_path, &failed_marker).is_err() {
1094                    return Some(CustodyResult::for_state(CustodyState::ResetFailed));
1095                }
1096                CustodyResult::for_state(CustodyState::ResetFailed)
1097            }
1098        })
1099    }
1100
1101    fn complete_reset_locked(&self, mut marker: ResetMarker) -> Result<(), CustodyState> {
1102        match self.store.read(&self.locator) {
1103            Ok(Some(secret)) => {
1104                let identity = secret
1105                    .public_identity()
1106                    .map_err(|_| CustodyState::ResetFailed)?;
1107                if identity.identity_ref() != &marker.expected_identity {
1108                    return Err(CustodyState::Conflict);
1109                }
1110            }
1111            Ok(None) => {}
1112            Err(_) => return Err(CustodyState::ResetFailed),
1113        }
1114        self.store
1115            .delete(&self.locator)
1116            .map_err(|_| CustodyState::ResetFailed)?;
1117        match self.store.read(&self.locator) {
1118            Ok(None) => {}
1119            Ok(Some(_)) | Err(_) => return Err(CustodyState::ResetFailed),
1120        }
1121        remove_public_document(&self.paths.completion_path)
1122            .map_err(|_| CustodyState::ResetFailed)?;
1123        remove_public_document(&self.paths.manifest_path).map_err(|_| CustodyState::ResetFailed)?;
1124        remove_public_document(&self.paths.transaction_path)
1125            .map_err(|_| CustodyState::ResetFailed)?;
1126        remove_public_document(&self.paths.recovery_protection_path)
1127            .map_err(|_| CustodyState::ResetFailed)?;
1128        marker.status = ResetStatus::Complete;
1129        write_json_document(&self.paths.reset_path, &marker)
1130            .map_err(|_| CustodyState::ResetFailed)?;
1131        self.trigger_proof_crash(ProofCrashBoundary::AfterResetCommit);
1132        Ok(())
1133    }
1134
1135    fn is_disposable_proof(&self) -> bool {
1136        self.locator.service() == IDENTITY_PROOF_KEYRING_SERVICE
1137            && self.locator.account() == IDENTITY_PROOF_KEYRING_ACCOUNT
1138    }
1139
1140    fn read_manifest_locked(&self) -> Result<Option<IdentityManifest>, PublicStoreError> {
1141        if self.is_disposable_proof() {
1142            read_identity_manifest_for_locator(&self.paths.manifest_path, &self.locator)
1143        } else {
1144            read_identity_manifest(&self.paths.manifest_path, self.channel)
1145        }
1146    }
1147
1148    fn read_completion_locked(
1149        &self,
1150        manifest: &IdentityManifest,
1151    ) -> Result<Option<CompletionRecord>, PublicStoreError> {
1152        if self.is_disposable_proof() {
1153            read_completion_record_for_locator(&self.paths.completion_path, manifest, &self.locator)
1154        } else {
1155            read_completion_record(&self.paths.completion_path, manifest, self.channel)
1156        }
1157    }
1158
1159    fn write_manifest_locked(&self, manifest: &IdentityManifest) -> Result<(), PublicStoreError> {
1160        if self.is_disposable_proof() {
1161            write_identity_manifest_for_locator(&self.paths.manifest_path, manifest, &self.locator)
1162        } else {
1163            write_identity_manifest(&self.paths.manifest_path, manifest, self.channel)
1164        }
1165    }
1166
1167    fn write_completion_locked(
1168        &self,
1169        completion: &CompletionRecord,
1170        manifest: &IdentityManifest,
1171    ) -> Result<(), PublicStoreError> {
1172        if self.is_disposable_proof() {
1173            write_completion_record_for_locator(
1174                &self.paths.completion_path,
1175                completion,
1176                manifest,
1177                &self.locator,
1178            )
1179        } else {
1180            write_completion_record(
1181                &self.paths.completion_path,
1182                completion,
1183                manifest,
1184                self.channel,
1185            )
1186        }
1187    }
1188
1189    fn trigger_proof_crash(&self, boundary: ProofCrashBoundary) {
1190        if !self.is_disposable_proof() || self.proof_crash_boundary != Some(boundary) {
1191            return;
1192        }
1193        #[cfg(unix)]
1194        unsafe {
1195            libc::kill(libc::getpid(), libc::SIGKILL);
1196        }
1197        std::process::abort();
1198    }
1199
1200    fn resolve_locked(&self) -> ResolvedCustody {
1201        let secret = match self.store.read(&self.locator) {
1202            Ok(secret) => secret,
1203            Err(StoreError::Locked | StoreError::Unavailable) => {
1204                return ResolvedCustody::without_secret(
1205                    CustodyState::Locked,
1206                    self.best_effort_manifest_identity(),
1207                );
1208            }
1209            Err(StoreError::Conflict) => {
1210                return ResolvedCustody::conflict(
1211                    CustodyConflictReason::AmbiguousSecureStore,
1212                    self.best_effort_manifest_identity(),
1213                    None,
1214                );
1215            }
1216            Err(StoreError::Corrupt | StoreError::Configuration) => {
1217                return ResolvedCustody::without_secret(
1218                    CustodyState::Incomplete,
1219                    self.best_effort_manifest_identity(),
1220                );
1221            }
1222        };
1223        let transaction = match self.read_transaction_locked() {
1224            Ok(transaction) => transaction,
1225            Err(_) => {
1226                return ResolvedCustody::without_secret(CustodyState::Incomplete, None);
1227            }
1228        };
1229        let manifest = match self.read_manifest_locked() {
1230            Ok(manifest) => manifest,
1231            Err(_) => {
1232                return ResolvedCustody::without_secret(CustodyState::Incomplete, None);
1233            }
1234        };
1235
1236        match (manifest, secret) {
1237            (None, None) => match transaction {
1238                Some(transaction) => ResolvedCustody::without_secret(
1239                    CustodyState::Incomplete,
1240                    transaction.expected_identity,
1241                ),
1242                None => ResolvedCustody::without_secret(CustodyState::Absent, None),
1243            },
1244            (Some(manifest), None) => {
1245                if let Some(expected_identity) = transaction
1246                    .as_ref()
1247                    .and_then(|transaction| transaction.expected_identity.as_ref())
1248                    .filter(|identity| *identity != manifest.identity())
1249                {
1250                    ResolvedCustody::conflict(
1251                        CustodyConflictReason::PendingTransactionMismatch,
1252                        [manifest.identity().clone(), expected_identity.clone()],
1253                        None,
1254                    )
1255                } else {
1256                    ResolvedCustody::without_secret(
1257                        CustodyState::Lost,
1258                        Some(manifest.identity().clone()),
1259                    )
1260                }
1261            }
1262            (None, Some(secret)) => match secret.public_identity() {
1263                Ok(identity) => {
1264                    if let Some(expected_identity) = transaction
1265                        .as_ref()
1266                        .and_then(|transaction| transaction.expected_identity.as_ref())
1267                        .filter(|expected| *expected != &identity)
1268                    {
1269                        ResolvedCustody::conflict(
1270                            CustodyConflictReason::PendingTransactionMismatch,
1271                            [identity, expected_identity.clone()],
1272                            Some(secret),
1273                        )
1274                    } else {
1275                        ResolvedCustody {
1276                            result: CustodyResult {
1277                                state: CustodyState::Incomplete,
1278                                identity: Some(identity),
1279                                receipt_ref: None,
1280                            },
1281                            secret: Some(secret),
1282                            conflict: None,
1283                        }
1284                    }
1285                }
1286                Err(_) => ResolvedCustody::without_secret(CustodyState::Incomplete, None),
1287            },
1288            (Some(manifest), Some(secret)) => {
1289                let identity = match secret.public_identity() {
1290                    Ok(identity) => identity,
1291                    Err(_) => {
1292                        return ResolvedCustody::without_secret(
1293                            CustodyState::Incomplete,
1294                            Some(manifest.identity().clone()),
1295                        );
1296                    }
1297                };
1298                if &identity != manifest.identity() {
1299                    return ResolvedCustody::conflict(
1300                        CustodyConflictReason::PublicManifestCustodyMismatch,
1301                        [manifest.identity().clone(), identity],
1302                        Some(secret),
1303                    );
1304                }
1305                if let Some(expected_identity) = transaction
1306                    .as_ref()
1307                    .and_then(|transaction| transaction.expected_identity.as_ref())
1308                    .filter(|expected| *expected != &identity)
1309                {
1310                    return ResolvedCustody::conflict(
1311                        CustodyConflictReason::PendingTransactionMismatch,
1312                        [identity, expected_identity.clone()],
1313                        Some(secret),
1314                    );
1315                }
1316
1317                match self.read_completion_locked(&manifest) {
1318                    Ok(Some(completion))
1319                        if transaction.as_ref().is_none_or(|transaction| {
1320                            completion.receipt_ref() == &transaction.receipt_ref
1321                        }) =>
1322                    {
1323                        ResolvedCustody {
1324                            result: CustodyResult {
1325                                state: CustodyState::Ready,
1326                                identity: Some(identity),
1327                                receipt_ref: Some(completion.receipt_ref().clone()),
1328                            },
1329                            secret: Some(secret),
1330                            conflict: None,
1331                        }
1332                    }
1333                    Ok(Some(_)) | Ok(None) | Err(_) => ResolvedCustody {
1334                        result: CustodyResult {
1335                            state: CustodyState::Incomplete,
1336                            identity: Some(identity),
1337                            receipt_ref: None,
1338                        },
1339                        secret: Some(secret),
1340                        conflict: None,
1341                    },
1342                }
1343            }
1344        }
1345    }
1346
1347    fn best_effort_manifest_identity(&self) -> Option<PublicIdentity> {
1348        self.read_manifest_locked()
1349            .ok()
1350            .flatten()
1351            .map(|manifest| manifest.identity().clone())
1352    }
1353}
1354
1355#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
1356#[serde(rename_all = "kebab-case")]
1357enum TransactionOperation {
1358    Create,
1359    Import,
1360    ResolveConflict,
1361}
1362
1363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1364#[serde(deny_unknown_fields)]
1365struct IdentityTransaction {
1366    schema: String,
1367    operation: TransactionOperation,
1368    receipt_ref: ReceiptRef,
1369    candidate_ref: Option<CandidateRef>,
1370    expected_identity: Option<PublicIdentity>,
1371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1372    conflict_identities: Vec<PublicIdentity>,
1373}
1374
1375impl IdentityTransaction {
1376    fn new(
1377        operation: TransactionOperation,
1378        receipt_ref: ReceiptRef,
1379        candidate_ref: Option<CandidateRef>,
1380    ) -> Self {
1381        Self {
1382            schema: IDENTITY_TRANSACTION_SCHEMA.to_string(),
1383            operation,
1384            receipt_ref,
1385            candidate_ref,
1386            expected_identity: None,
1387            conflict_identities: Vec::new(),
1388        }
1389    }
1390
1391    fn is_valid(&self) -> bool {
1392        self.schema == IDENTITY_TRANSACTION_SCHEMA
1393            && matches!(
1394                (
1395                    self.operation,
1396                    self.candidate_ref.is_some(),
1397                    self.conflict_identities.is_empty(),
1398                    self.expected_identity.is_some(),
1399                ),
1400                (TransactionOperation::Create, false, true, _)
1401                    | (TransactionOperation::Import, true, true, _)
1402                    | (TransactionOperation::ResolveConflict, true, false, true)
1403            )
1404            && self
1405                .expected_identity
1406                .as_ref()
1407                .is_none_or(|identity| identity.validate().is_ok())
1408            && self
1409                .conflict_identities
1410                .iter()
1411                .all(|identity| identity.validate().is_ok())
1412            && (self.operation != TransactionOperation::ResolveConflict
1413                || self.expected_identity.as_ref().is_some_and(|expected| {
1414                    self.conflict_identities
1415                        .iter()
1416                        .any(|identity| identity == expected)
1417                }))
1418    }
1419
1420    fn public_facts(self) -> PendingIdentityTransaction {
1421        PendingIdentityTransaction {
1422            operation: match self.operation {
1423                TransactionOperation::Create => PendingIdentityOperation::Create,
1424                TransactionOperation::Import => PendingIdentityOperation::Import,
1425                TransactionOperation::ResolveConflict => PendingIdentityOperation::ResolveConflict,
1426            },
1427            receipt_ref: self.receipt_ref,
1428            expected_identity: self.expected_identity,
1429        }
1430    }
1431}
1432
1433#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
1434#[serde(rename_all = "kebab-case")]
1435enum ResetStatus {
1436    Pending,
1437    Failed,
1438    Complete,
1439}
1440
1441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1442#[serde(deny_unknown_fields)]
1443struct ResetMarker {
1444    schema: String,
1445    expected_identity: IdentityRef,
1446    authorization_ref: ReceiptRef,
1447    status: ResetStatus,
1448}
1449
1450impl ResetMarker {
1451    fn pending(expected_identity: IdentityRef, authorization_ref: ReceiptRef) -> Self {
1452        Self {
1453            schema: RESET_MARKER_SCHEMA.to_string(),
1454            expected_identity,
1455            authorization_ref,
1456            status: ResetStatus::Pending,
1457        }
1458    }
1459
1460    fn is_valid(&self) -> bool {
1461        self.schema == RESET_MARKER_SCHEMA
1462    }
1463
1464    fn result(&self) -> CustodyResult {
1465        CustodyResult {
1466            state: match self.status {
1467                ResetStatus::Failed => CustodyState::ResetFailed,
1468                ResetStatus::Pending | ResetStatus::Complete => CustodyState::RelaunchRequired,
1469            },
1470            identity: None,
1471            receipt_ref: Some(self.authorization_ref.clone()),
1472        }
1473    }
1474}
1475
1476impl CustodyResult {
1477    fn for_state(state: CustodyState) -> Self {
1478        Self {
1479            state,
1480            identity: None,
1481            receipt_ref: None,
1482        }
1483    }
1484}
1485
1486struct ResolvedCustody {
1487    result: CustodyResult,
1488    secret: Option<SecretKeyMaterial>,
1489    conflict: Option<CustodyConflict>,
1490}
1491
1492impl ResolvedCustody {
1493    fn without_secret(state: CustodyState, identity: Option<PublicIdentity>) -> Self {
1494        Self {
1495            result: CustodyResult {
1496                state,
1497                identity,
1498                receipt_ref: None,
1499            },
1500            secret: None,
1501            conflict: None,
1502        }
1503    }
1504
1505    fn conflict(
1506        reason: CustodyConflictReason,
1507        identities: impl IntoIterator<Item = PublicIdentity>,
1508        secret: Option<SecretKeyMaterial>,
1509    ) -> Self {
1510        let mut identities = identities.into_iter().collect::<Vec<_>>();
1511        identities.dedup();
1512        Self {
1513            result: CustodyResult {
1514                state: CustodyState::Conflict,
1515                identity: identities.first().cloned(),
1516                receipt_ref: None,
1517            },
1518            secret,
1519            conflict: Some(CustodyConflict { reason, identities }),
1520        }
1521    }
1522}
1523
1524#[derive(Clone)]
1525struct CustodyPaths {
1526    manifest_path: PathBuf,
1527    completion_path: PathBuf,
1528    transaction_path: PathBuf,
1529    reset_path: PathBuf,
1530    recovery_protection_path: PathBuf,
1531}
1532
1533impl CustodyPaths {
1534    fn for_data_root(root: PathBuf) -> Self {
1535        Self {
1536            manifest_path: root.join("identity.json"),
1537            completion_path: root.join("identity.complete.json"),
1538            transaction_path: root.join("identity.transaction.json"),
1539            reset_path: root.join("identity.reset.json"),
1540            recovery_protection_path: root.join("identity.recovery-protection.json"),
1541        }
1542    }
1543}
1544
1545fn channel_data_root(channel: AppChannel) -> PathBuf {
1546    #[cfg(target_os = "macos")]
1547    {
1548        paths::home_dir()
1549            .join("Library/Application Support")
1550            .join(channel.display_name())
1551    }
1552    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1553    {
1554        let base = std::env::var_os("XDG_DATA_HOME")
1555            .map(PathBuf::from)
1556            .unwrap_or_else(|| paths::home_dir().join(".local/share"));
1557        base.join(channel.storage_slug())
1558    }
1559    #[cfg(target_os = "windows")]
1560    {
1561        std::env::var_os("LOCALAPPDATA")
1562            .map(PathBuf::from)
1563            .unwrap_or_else(|| paths::home_dir().join("AppData").join("Local"))
1564            .join(channel.display_name())
1565    }
1566    #[cfg(not(any(
1567        target_os = "macos",
1568        target_os = "linux",
1569        target_os = "freebsd",
1570        target_os = "windows"
1571    )))]
1572    {
1573        paths::home_dir()
1574            .join(".config")
1575            .join(channel.storage_slug())
1576    }
1577}
1578
1579trait SecretGenerator: Send + Sync {
1580    fn generate(&self) -> SecretKeyMaterial;
1581}
1582
1583struct SystemSecretGenerator;
1584
1585impl SecretGenerator for SystemSecretGenerator {
1586    fn generate(&self) -> SecretKeyMaterial {
1587        SecretKeyMaterial::generate()
1588    }
1589}
1590
1591#[derive(Debug, Error)]
1592pub enum CustodyError {
1593    #[error("identity custody is unavailable in state {0:?}")]
1594    CustodyDenied(CustodyState),
1595    #[error("secure identity storage is unavailable")]
1596    SecureStoreUnavailable,
1597    #[error("secure identity read-back verification failed")]
1598    ReadBackMismatch,
1599    #[error("the imported identity secret is invalid")]
1600    InvalidImportedSecret,
1601    #[error("the recovery candidate is invalid")]
1602    InvalidRecoveryCandidate,
1603    #[error("the encrypted recovery artifact is invalid or unsafe")]
1604    InvalidRecoveryArtifact,
1605    #[error("the recovery artifact destination already exists")]
1606    RecoveryArtifactExists,
1607    #[error("recovery artifact storage is unavailable")]
1608    RecoveryArtifactUnavailable,
1609    #[error("recovery artifact encryption failed")]
1610    RecoveryEncryptionFailed,
1611    #[error("the recovery password or artifact is invalid")]
1612    RecoveryDecryptionFailed,
1613    #[error("the recovery protection record is invalid")]
1614    InvalidRecoveryProtection,
1615    #[error("identity signing failed")]
1616    SigningFailed,
1617    #[error("the identity transaction is incomplete")]
1618    TransactionIncomplete,
1619    #[error("identity reset could not be verified")]
1620    ResetFailed,
1621    #[error("identity mutation serialization is unavailable")]
1622    MutationLock,
1623    #[error("identity contract validation failed")]
1624    Contract(#[from] ContractError),
1625    #[error("public identity state could not be committed")]
1626    PublicStore(#[from] PublicStoreError),
1627}
1628
1629impl From<MutationLockError> for CustodyError {
1630    fn from(_: MutationLockError) -> Self {
1631        Self::MutationLock
1632    }
1633}
1634
1635impl From<RecoveryArtifactError> for CustodyError {
1636    fn from(error: RecoveryArtifactError) -> Self {
1637        match error {
1638            RecoveryArtifactError::DestinationExists => Self::RecoveryArtifactExists,
1639            RecoveryArtifactError::Io(_) | RecoveryArtifactError::TemporaryFileUnavailable => {
1640                Self::RecoveryArtifactUnavailable
1641            }
1642            RecoveryArtifactError::UnsafeArtifact
1643            | RecoveryArtifactError::WeakPermissions
1644            | RecoveryArtifactError::ArtifactTooLarge
1645            | RecoveryArtifactError::UnsupportedWorkFactor
1646            | RecoveryArtifactError::InvalidArtifact
1647            | RecoveryArtifactError::CandidateChanged
1648            | RecoveryArtifactError::InvalidDestination
1649            | RecoveryArtifactError::EncryptionFailed => Self::InvalidRecoveryArtifact,
1650        }
1651    }
1652}
1653
1654fn custody_error_from_store(error: StoreError) -> CustodyError {
1655    match error {
1656        StoreError::Locked | StoreError::Unavailable => {
1657            CustodyError::CustodyDenied(CustodyState::Locked)
1658        }
1659        StoreError::Conflict => CustodyError::CustodyDenied(CustodyState::Conflict),
1660        StoreError::Corrupt | StoreError::Configuration => CustodyError::SecureStoreUnavailable,
1661    }
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666    use std::sync::Mutex;
1667
1668    use nostr::{Event, JsonUtil};
1669    use sha2::Digest as _;
1670    use zeroize::Zeroizing;
1671
1672    use super::*;
1673    use crate::{RecoveryProtectionState, SigningPurpose, UnsignedEventTemplate};
1674
1675    struct FakeStore {
1676        state: Mutex<FakeStoreState>,
1677    }
1678
1679    struct FakeStoreState {
1680        secret: Option<SecretKeyMaterial>,
1681        read_mode: FakeReadMode,
1682        mode_after_write: Option<FakeReadMode>,
1683        writes: usize,
1684        deletes: usize,
1685        fail_delete: bool,
1686    }
1687
1688    #[derive(Copy, Clone)]
1689    enum FakeReadMode {
1690        Normal,
1691        Missing,
1692        Locked,
1693        Unavailable,
1694        Conflict,
1695        Substitute([u8; 32]),
1696    }
1697
1698    impl FakeStore {
1699        fn empty() -> Arc<Self> {
1700            Arc::new(Self {
1701                state: Mutex::new(FakeStoreState {
1702                    secret: None,
1703                    read_mode: FakeReadMode::Normal,
1704                    mode_after_write: None,
1705                    writes: 0,
1706                    deletes: 0,
1707                    fail_delete: false,
1708                }),
1709            })
1710        }
1711
1712        fn set_read_mode(&self, mode: FakeReadMode) {
1713            self.state.lock().expect("lock fake store").read_mode = mode;
1714        }
1715
1716        fn set_mode_after_write(&self, mode: FakeReadMode) {
1717            self.state.lock().expect("lock fake store").mode_after_write = Some(mode);
1718        }
1719
1720        fn set_fail_delete(&self, fail_delete: bool) {
1721            self.state.lock().expect("lock fake store").fail_delete = fail_delete;
1722        }
1723
1724        fn lose_secret(&self) {
1725            let mut state = self.state.lock().expect("lock fake store");
1726            state.secret = None;
1727            state.read_mode = FakeReadMode::Normal;
1728        }
1729
1730        fn replace_secret(&self, bytes: [u8; 32]) {
1731            let mut state = self.state.lock().expect("lock fake store");
1732            state.secret = Some(
1733                SecretKeyMaterial::from_bytes(Zeroizing::new(bytes))
1734                    .expect("valid replacement secret"),
1735            );
1736            state.read_mode = FakeReadMode::Normal;
1737        }
1738    }
1739
1740    impl SecretStore for FakeStore {
1741        fn read(&self, _locator: &KeyringLocator) -> Result<Option<SecretKeyMaterial>, StoreError> {
1742            let state = self.state.lock().expect("lock fake store");
1743            match state.read_mode {
1744                FakeReadMode::Normal => Ok(state.secret.as_ref().map(SecretKeyMaterial::duplicate)),
1745                FakeReadMode::Missing => Ok(None),
1746                FakeReadMode::Locked => Err(StoreError::Locked),
1747                FakeReadMode::Unavailable => Err(StoreError::Unavailable),
1748                FakeReadMode::Conflict => Err(StoreError::Conflict),
1749                FakeReadMode::Substitute(bytes) => {
1750                    SecretKeyMaterial::from_bytes(Zeroizing::new(bytes))
1751                        .map(Some)
1752                        .map_err(|_| StoreError::Corrupt)
1753                }
1754            }
1755        }
1756
1757        fn write(
1758            &self,
1759            _locator: &KeyringLocator,
1760            secret: &SecretKeyMaterial,
1761        ) -> Result<(), StoreError> {
1762            let mut state = self.state.lock().expect("lock fake store");
1763            state.secret = Some(secret.duplicate());
1764            state.writes += 1;
1765            if let Some(mode) = state.mode_after_write.take() {
1766                state.read_mode = mode;
1767            }
1768            Ok(())
1769        }
1770
1771        fn delete(&self, _locator: &KeyringLocator) -> Result<(), StoreError> {
1772            let mut state = self.state.lock().expect("lock fake store");
1773            state.deletes += 1;
1774            if state.fail_delete {
1775                return Err(StoreError::Unavailable);
1776            }
1777            state.secret = None;
1778            state.read_mode = FakeReadMode::Normal;
1779            Ok(())
1780        }
1781    }
1782
1783    struct FixedGenerator([u8; 32]);
1784
1785    impl SecretGenerator for FixedGenerator {
1786        fn generate(&self) -> SecretKeyMaterial {
1787            SecretKeyMaterial::from_bytes(Zeroizing::new(self.0))
1788                .expect("valid fixed generator secret")
1789        }
1790    }
1791
1792    struct CountingGenerator {
1793        bytes: [u8; 32],
1794        calls: Arc<std::sync::atomic::AtomicUsize>,
1795    }
1796
1797    impl SecretGenerator for CountingGenerator {
1798        fn generate(&self) -> SecretKeyMaterial {
1799            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1800            SecretKeyMaterial::from_bytes(Zeroizing::new(self.bytes))
1801                .expect("valid counting generator secret")
1802        }
1803    }
1804
1805    fn service(store: Arc<FakeStore>, data_root: PathBuf) -> IdentityService {
1806        IdentityService::new(
1807            AppChannel::Dev,
1808            CustodyPaths::for_data_root(data_root),
1809            store,
1810            Arc::new(FixedGenerator([1; 32])),
1811        )
1812    }
1813
1814    fn counting_service(
1815        store: Arc<FakeStore>,
1816        data_root: PathBuf,
1817        calls: Arc<std::sync::atomic::AtomicUsize>,
1818    ) -> IdentityService {
1819        IdentityService::new(
1820            AppChannel::Dev,
1821            CustodyPaths::for_data_root(data_root),
1822            store,
1823            Arc::new(CountingGenerator {
1824                bytes: [1; 32],
1825                calls,
1826            }),
1827        )
1828    }
1829
1830    fn receipt() -> ReceiptRef {
1831        ReceiptRef::new("owner-action-1").expect("valid test receipt")
1832    }
1833
1834    fn select_one(service: &IdentityService, prepared: PreparedRecovery) -> SelectedRecovery {
1835        let candidate_ref = prepared.candidate_ref().clone();
1836        service
1837            .select_recovery(vec![prepared], &candidate_ref)
1838            .expect("select prepared recovery")
1839    }
1840
1841    fn signing_request(identity: &PublicIdentity) -> AdmittedSigningRequest {
1842        AdmittedSigningRequest {
1843            request_ref: ReceiptRef::new("signing-action-1").expect("valid signing receipt"),
1844            identity_ref: identity.identity_ref().clone(),
1845            purpose: SigningPurpose::NostrEvent,
1846            event: UnsignedEventTemplate {
1847                created_at: 1_700_000_000,
1848                kind: 1,
1849                tags: vec![vec!["t".to_string(), "omega".to_string()]],
1850                content: "Omega custody conformance".to_string(),
1851            },
1852        }
1853    }
1854
1855    #[test]
1856    fn restart_returns_the_same_public_identity() {
1857        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
1858        let store = FakeStore::empty();
1859        let first_service = service(store.clone(), temporary_directory.path().to_path_buf());
1860        let created = first_service.create(receipt()).expect("create identity");
1861        let created_identity = created.identity.expect("created public identity");
1862
1863        let restarted_service = service(store.clone(), temporary_directory.path().to_path_buf());
1864        let restarted = restarted_service.inspect().expect("inspect after restart");
1865        assert_eq!(restarted.state, CustodyState::Ready);
1866        assert_eq!(restarted.identity, Some(created_identity));
1867        assert_eq!(store.state.lock().expect("lock fake store").writes, 1);
1868    }
1869
1870    #[test]
1871    fn inspection_never_generates_and_same_create_is_idempotent() {
1872        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
1873        let store = FakeStore::empty();
1874        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1875        let service = counting_service(
1876            store.clone(),
1877            temporary_directory.path().to_path_buf(),
1878            generator_calls.clone(),
1879        );
1880
1881        assert_eq!(
1882            service.inspect().expect("inspect absent identity").state,
1883            CustodyState::Absent
1884        );
1885        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
1886
1887        let first = service.create(receipt()).expect("create identity");
1888        let second = service.create(receipt()).expect("repeat same create");
1889        assert_eq!(first, second);
1890        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1891        assert_eq!(store.state.lock().expect("lock fake store").writes, 1);
1892    }
1893
1894    #[test]
1895    fn committing_a_different_identity_invalidates_stale_recovery_protection() {
1896        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
1897        let store = FakeStore::empty();
1898        let service = service(store, temporary_directory.path().to_path_buf());
1899        let stale_identity = SecretKeyMaterial::from_bytes(Zeroizing::new([2; 32]))
1900            .expect("valid stale secret")
1901            .public_identity()
1902            .expect("derive stale identity");
1903        let stale_record = RecoveryProtectionRecord::new(stale_identity, "a".repeat(64), 100)
1904            .expect("valid stale recovery protection");
1905        write_json_document(&service.paths.recovery_protection_path, &stale_record)
1906            .expect("write stale recovery protection");
1907
1908        let created = service
1909            .create(receipt())
1910            .expect("create different identity");
1911        assert_ne!(
1912            created.identity.as_ref(),
1913            Some(stale_record.identity()),
1914            "fixture identities must differ"
1915        );
1916        assert!(!service.paths.recovery_protection_path.exists());
1917        assert_eq!(
1918            service
1919                .inspect_details()
1920                .expect("inspect unprotected new identity")
1921                .recovery_protection
1922                .state,
1923            crate::RecoveryProtectionState::Needed
1924        );
1925    }
1926
1927    #[test]
1928    fn malformed_recovery_protection_never_blocks_ready_custody() {
1929        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
1930        let store = FakeStore::empty();
1931        let service = service(store, temporary_directory.path().to_path_buf());
1932        let created = service.create(receipt()).expect("create identity");
1933        std::fs::write(
1934            &service.paths.recovery_protection_path,
1935            b"not a recovery protection record",
1936        )
1937        .expect("write malformed recovery protection");
1938
1939        let inspection = service
1940            .inspect_details()
1941            .expect("inspect through malformed recovery protection");
1942        assert_eq!(inspection.custody, created);
1943        assert_eq!(
1944            inspection.recovery_protection.state,
1945            crate::RecoveryProtectionState::Needed
1946        );
1947        assert!(inspection.recovery_protection.record.is_none());
1948        assert!(!service.paths.recovery_protection_path.exists());
1949    }
1950
1951    #[test]
1952    fn concurrent_same_create_generates_and_writes_once() {
1953        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
1954        let store = FakeStore::empty();
1955        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1956        let service = Arc::new(counting_service(
1957            store.clone(),
1958            temporary_directory.path().to_path_buf(),
1959            generator_calls.clone(),
1960        ));
1961        let first = std::thread::spawn({
1962            let service = service.clone();
1963            move || service.create(receipt()).expect("first concurrent create")
1964        });
1965        let second = std::thread::spawn({
1966            let service = service;
1967            move || service.create(receipt()).expect("second concurrent create")
1968        });
1969
1970        assert_eq!(
1971            first.join().expect("join first create"),
1972            second.join().expect("join second create")
1973        );
1974        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1975        assert_eq!(store.state.lock().expect("lock fake store").writes, 1);
1976    }
1977
1978    #[test]
1979    fn concurrent_distinct_create_receipts_commit_exactly_one_identity() {
1980        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
1981        let data_root = temporary_directory.path().to_path_buf();
1982        let store = FakeStore::empty();
1983        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1984        let first_service =
1985            counting_service(store.clone(), data_root.clone(), generator_calls.clone());
1986        let second_service =
1987            counting_service(store.clone(), data_root.clone(), generator_calls.clone());
1988        let start = Arc::new(std::sync::Barrier::new(3));
1989
1990        let first = std::thread::spawn({
1991            let start = start.clone();
1992            move || {
1993                start.wait();
1994                first_service.create(
1995                    ReceiptRef::new("concurrent-owner-action-1")
1996                        .expect("valid first concurrent receipt"),
1997                )
1998            }
1999        });
2000        let second = std::thread::spawn({
2001            let start = start.clone();
2002            move || {
2003                start.wait();
2004                second_service.create(
2005                    ReceiptRef::new("concurrent-owner-action-2")
2006                        .expect("valid second concurrent receipt"),
2007                )
2008            }
2009        });
2010        start.wait();
2011
2012        let first = first.join().expect("join first create");
2013        let second = second.join().expect("join second create");
2014        let successes = [&first, &second]
2015            .into_iter()
2016            .filter(|result| {
2017                result
2018                    .as_ref()
2019                    .is_ok_and(|result| result.state == CustodyState::Ready)
2020            })
2021            .count();
2022        let denials = [&first, &second]
2023            .into_iter()
2024            .filter(|result| {
2025                matches!(
2026                    result,
2027                    Err(CustodyError::CustodyDenied(CustodyState::Ready))
2028                )
2029            })
2030            .count();
2031
2032        assert_eq!(successes, 1);
2033        assert_eq!(denials, 1);
2034        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
2035        assert_eq!(store.state.lock().expect("lock fake store").writes, 1);
2036        assert_eq!(
2037            service(store, data_root)
2038                .inspect()
2039                .expect("inspect winning identity")
2040                .state,
2041            CustodyState::Ready
2042        );
2043    }
2044
2045    #[test]
2046    fn unavailable_secure_store_blocks_create_before_journaling_or_generation() {
2047        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2048        let store = FakeStore::empty();
2049        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2050        let service = counting_service(
2051            store.clone(),
2052            temporary_directory.path().to_path_buf(),
2053            generator_calls.clone(),
2054        );
2055        store.set_read_mode(FakeReadMode::Unavailable);
2056
2057        assert!(matches!(
2058            service.create(receipt()),
2059            Err(CustodyError::CustodyDenied(CustodyState::Locked))
2060        ));
2061        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
2062        assert_eq!(store.state.lock().expect("lock fake store").writes, 0);
2063        assert!(!service.paths.transaction_path.exists());
2064        assert!(!service.paths.manifest_path.exists());
2065        assert!(!service.paths.completion_path.exists());
2066    }
2067
2068    #[test]
2069    fn pending_create_resumes_the_stored_identity_without_generation() {
2070        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2071        let store = FakeStore::empty();
2072        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2073        let service = counting_service(
2074            store.clone(),
2075            temporary_directory.path().to_path_buf(),
2076            generator_calls.clone(),
2077        );
2078        let secret = SecretKeyMaterial::from_bytes(Zeroizing::new([4; 32]))
2079            .expect("valid pending identity secret");
2080        let identity = secret
2081            .public_identity()
2082            .expect("derive pending public identity");
2083        store
2084            .write(&service.locator, &secret)
2085            .expect("write pending identity secret");
2086        let mut transaction =
2087            IdentityTransaction::new(TransactionOperation::Create, receipt(), None);
2088        transaction.expected_identity = Some(identity.clone());
2089        write_json_document(&service.paths.transaction_path, &transaction)
2090            .expect("write pending transaction");
2091
2092        let resumed = service.create(receipt()).expect("resume pending create");
2093        assert_eq!(resumed.identity, Some(identity));
2094        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
2095        assert!(!service.paths.transaction_path.exists());
2096    }
2097
2098    #[test]
2099    fn pending_create_facts_survive_restart_and_resume_the_original_receipt() {
2100        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2101        let store = FakeStore::empty();
2102        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2103        let first_service = counting_service(
2104            store.clone(),
2105            temporary_directory.path().to_path_buf(),
2106            generator_calls.clone(),
2107        );
2108        let pending_receipt =
2109            ReceiptRef::new("restart-create-receipt").expect("valid pending receipt");
2110        write_json_document(
2111            &first_service.paths.transaction_path,
2112            &IdentityTransaction::new(TransactionOperation::Create, pending_receipt.clone(), None),
2113        )
2114        .expect("write pending create");
2115
2116        let restarted_service = counting_service(
2117            store,
2118            temporary_directory.path().to_path_buf(),
2119            generator_calls.clone(),
2120        );
2121        let inspection = restarted_service
2122            .inspect_details()
2123            .expect("inspect pending create after restart");
2124        assert_eq!(inspection.custody.state, CustodyState::Incomplete);
2125        assert_eq!(
2126            inspection.pending_transaction,
2127            Some(PendingIdentityTransaction {
2128                operation: PendingIdentityOperation::Create,
2129                receipt_ref: pending_receipt.clone(),
2130                expected_identity: None,
2131            })
2132        );
2133
2134        let resumed = restarted_service
2135            .resume_incomplete_create()
2136            .expect("resume pending create");
2137        assert_eq!(resumed.state, CustodyState::Ready);
2138        assert_eq!(resumed.receipt_ref, Some(pending_receipt));
2139        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
2140        assert!(
2141            restarted_service
2142                .inspect_details()
2143                .expect("inspect completed create")
2144                .pending_transaction
2145                .is_none()
2146        );
2147    }
2148
2149    #[test]
2150    fn restart_after_manifest_commit_resumes_same_identity_and_receipt() {
2151        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2152        let data_root = temporary_directory.path().to_path_buf();
2153        let store = FakeStore::empty();
2154        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2155        let initial_service =
2156            counting_service(store.clone(), data_root.clone(), generator_calls.clone());
2157        let pending_receipt =
2158            ReceiptRef::new("manifest-commit-crash").expect("valid pending receipt");
2159        let secret = SecretKeyMaterial::from_bytes(Zeroizing::new([4; 32]))
2160            .expect("valid pending identity secret");
2161        let identity = secret
2162            .public_identity()
2163            .expect("derive pending public identity");
2164        store
2165            .write(&initial_service.locator, &secret)
2166            .expect("write pending identity secret");
2167        let mut transaction =
2168            IdentityTransaction::new(TransactionOperation::Create, pending_receipt.clone(), None);
2169        transaction.expected_identity = Some(identity.clone());
2170        write_json_document(&initial_service.paths.transaction_path, &transaction)
2171            .expect("write pending transaction");
2172        let manifest = IdentityManifest::new(
2173            identity.clone(),
2174            initial_service.locator.clone(),
2175            vec![pending_receipt.clone()],
2176        );
2177        write_identity_manifest(
2178            &initial_service.paths.manifest_path,
2179            &manifest,
2180            initial_service.channel,
2181        )
2182        .expect("write committed manifest");
2183        assert!(!initial_service.paths.completion_path.exists());
2184
2185        let restarted_service = counting_service(store, data_root, generator_calls.clone());
2186        let inspection = restarted_service
2187            .inspect_details()
2188            .expect("inspect manifest-only crash state");
2189        assert_eq!(inspection.custody.state, CustodyState::Incomplete);
2190        assert_eq!(inspection.custody.identity, Some(identity.clone()));
2191        assert_eq!(
2192            inspection
2193                .pending_transaction
2194                .expect("pending create facts")
2195                .receipt_ref,
2196            pending_receipt
2197        );
2198
2199        let resumed = restarted_service
2200            .resume_incomplete_create()
2201            .expect("resume after manifest commit");
2202        assert_eq!(resumed.state, CustodyState::Ready);
2203        assert_eq!(resumed.identity, Some(identity));
2204        assert_eq!(resumed.receipt_ref, Some(pending_receipt));
2205        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
2206        assert!(!restarted_service.paths.transaction_path.exists());
2207    }
2208
2209    #[test]
2210    fn restart_after_completion_commit_cleans_matching_create_journal() {
2211        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2212        let data_root = temporary_directory.path().to_path_buf();
2213        let store = FakeStore::empty();
2214        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2215        let initial_service =
2216            counting_service(store.clone(), data_root.clone(), generator_calls.clone());
2217        let completed_receipt =
2218            ReceiptRef::new("completion-commit-crash").expect("valid completed receipt");
2219        let completed = initial_service
2220            .create(completed_receipt.clone())
2221            .expect("create completed identity");
2222        let identity = completed.identity.clone().expect("completed identity");
2223        let mut interrupted_transaction = IdentityTransaction::new(
2224            TransactionOperation::Create,
2225            completed_receipt.clone(),
2226            None,
2227        );
2228        interrupted_transaction.expected_identity = Some(identity.clone());
2229        write_json_document(
2230            &initial_service.paths.transaction_path,
2231            &interrupted_transaction,
2232        )
2233        .expect("restore journal left at crash boundary");
2234        let calls_before_restart = generator_calls.load(std::sync::atomic::Ordering::SeqCst);
2235        let writes_before_restart = store.state.lock().expect("lock fake store").writes;
2236
2237        let restarted_service = counting_service(store.clone(), data_root, generator_calls.clone());
2238        let inspection = restarted_service
2239            .inspect_details()
2240            .expect("inspect completed crash state");
2241        assert_eq!(inspection.custody, completed);
2242        assert_eq!(
2243            inspection
2244                .pending_transaction
2245                .expect("matching create journal")
2246                .receipt_ref,
2247            completed_receipt
2248        );
2249
2250        let resumed = restarted_service
2251            .create(completed_receipt)
2252            .expect("idempotently finalize matching create");
2253        assert_eq!(resumed.state, CustodyState::Ready);
2254        assert_eq!(resumed.identity, Some(identity));
2255        assert!(!restarted_service.paths.transaction_path.exists());
2256        assert_eq!(
2257            generator_calls.load(std::sync::atomic::Ordering::SeqCst),
2258            calls_before_restart
2259        );
2260        assert_eq!(
2261            store.state.lock().expect("lock fake store").writes,
2262            writes_before_restart
2263        );
2264    }
2265
2266    #[test]
2267    fn pending_create_with_known_missing_identity_never_rotates() {
2268        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2269        let store = FakeStore::empty();
2270        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2271        let service = counting_service(
2272            store,
2273            temporary_directory.path().to_path_buf(),
2274            generator_calls.clone(),
2275        );
2276        let expected_identity = SecretKeyMaterial::from_bytes(Zeroizing::new([4; 32]))
2277            .expect("valid pending identity secret")
2278            .public_identity()
2279            .expect("derive pending identity");
2280        let mut transaction =
2281            IdentityTransaction::new(TransactionOperation::Create, receipt(), None);
2282        transaction.expected_identity = Some(expected_identity);
2283        write_json_document(&service.paths.transaction_path, &transaction)
2284            .expect("write pending transaction");
2285
2286        assert!(matches!(
2287            service.resume_incomplete_create(),
2288            Err(CustodyError::TransactionIncomplete)
2289        ));
2290        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
2291        assert!(service.paths.transaction_path.exists());
2292    }
2293
2294    #[test]
2295    fn resumed_create_failure_keeps_the_journal_and_stored_identity() {
2296        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2297        let store = FakeStore::empty();
2298        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2299        let service = counting_service(
2300            store.clone(),
2301            temporary_directory.path().to_path_buf(),
2302            generator_calls.clone(),
2303        );
2304        let secret = SecretKeyMaterial::from_bytes(Zeroizing::new([4; 32]))
2305            .expect("valid pending identity secret");
2306        store
2307            .write(&service.locator, &secret)
2308            .expect("write pending identity secret");
2309        write_json_document(
2310            &service.paths.transaction_path,
2311            &IdentityTransaction::new(TransactionOperation::Create, receipt(), None),
2312        )
2313        .expect("write pending transaction");
2314        std::fs::create_dir(&service.paths.manifest_path)
2315            .expect("create manifest write obstruction");
2316
2317        assert!(matches!(
2318            service.create(receipt()),
2319            Err(CustodyError::PublicStore(_))
2320        ));
2321        assert!(service.paths.transaction_path.exists());
2322        assert!(
2323            store
2324                .read(&service.locator)
2325                .expect("read preserved pending identity")
2326                .is_some()
2327        );
2328        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
2329    }
2330
2331    #[test]
2332    fn locked_custody_precedes_corrupt_public_transaction() {
2333        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2334        let store = FakeStore::empty();
2335        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2336        std::fs::create_dir_all(
2337            service
2338                .paths
2339                .transaction_path
2340                .parent()
2341                .expect("transaction parent"),
2342        )
2343        .expect("create transaction directory");
2344        std::fs::write(&service.paths.transaction_path, b"not-json")
2345            .expect("write corrupt transaction");
2346        store.set_read_mode(FakeReadMode::Locked);
2347
2348        assert_eq!(
2349            service
2350                .inspect()
2351                .expect("inspect locked custody with corrupt transaction")
2352                .state,
2353            CustodyState::Locked
2354        );
2355    }
2356
2357    #[test]
2358    fn corrupt_transaction_blocks_create_without_generation() {
2359        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2360        let store = FakeStore::empty();
2361        let generator_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2362        let service = counting_service(
2363            store,
2364            temporary_directory.path().to_path_buf(),
2365            generator_calls.clone(),
2366        );
2367        std::fs::create_dir_all(
2368            service
2369                .paths
2370                .transaction_path
2371                .parent()
2372                .expect("transaction parent"),
2373        )
2374        .expect("create transaction directory");
2375        std::fs::write(
2376            &service.paths.transaction_path,
2377            br#"{"schema":"untrusted"}"#,
2378        )
2379        .expect("write corrupt transaction");
2380
2381        assert!(matches!(
2382            service.create(receipt()),
2383            Err(CustodyError::TransactionIncomplete)
2384        ));
2385        assert_eq!(generator_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
2386    }
2387
2388    #[test]
2389    fn create_refuses_to_replace_existing_custody() {
2390        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2391        let store = FakeStore::empty();
2392        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2393        let created = service.create(receipt()).expect("create identity");
2394
2395        assert!(matches!(
2396            service.create(ReceiptRef::new("owner-action-2").expect("valid test receipt")),
2397            Err(CustodyError::CustodyDenied(CustodyState::Ready))
2398        ));
2399        assert_eq!(
2400            service.inspect().expect("inspect identity").identity,
2401            created.identity
2402        );
2403        assert_eq!(store.state.lock().expect("lock fake store").writes, 1);
2404    }
2405
2406    #[test]
2407    fn import_commits_the_supplied_identity_without_exposing_its_secret() {
2408        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2409        let store = FakeStore::empty();
2410        let service = service(store, temporary_directory.path().to_path_buf());
2411        let imported_secret =
2412            ImportedSecret::new(hex::encode([3; 32])).expect("valid imported secret");
2413
2414        let imported = service
2415            .import(imported_secret, receipt())
2416            .expect("import identity");
2417        let expected = SecretKeyMaterial::from_bytes(Zeroizing::new([3; 32]))
2418            .expect("valid expected secret")
2419            .public_identity()
2420            .expect("derive expected public identity");
2421
2422        assert_eq!(imported.state, CustodyState::Ready);
2423        assert_eq!(imported.identity, Some(expected));
2424        assert!(!format!("{imported:?}").contains(&hex::encode([3; 32])));
2425    }
2426
2427    #[test]
2428    fn lost_identity_can_adopt_only_the_matching_recovery() {
2429        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2430        let store = FakeStore::empty();
2431        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2432        let created = service.create(receipt()).expect("create identity");
2433        let identity = created.identity.expect("created public identity");
2434        store.lose_secret();
2435        assert_eq!(
2436            service.inspect().expect("inspect lost identity").state,
2437            CustodyState::Lost
2438        );
2439
2440        let wrong = service
2441            .prepare_import(
2442                ImportedSecret::new(hex::encode([2; 32])).expect("valid different import"),
2443            )
2444            .expect("prepare different import");
2445        assert!(matches!(
2446            service.adopt(
2447                select_one(&service, wrong),
2448                ReceiptRef::new("recovery-action-1").expect("valid recovery receipt")
2449            ),
2450            Err(CustodyError::CustodyDenied(CustodyState::Lost))
2451        ));
2452
2453        let matching = service
2454            .prepare_import(
2455                ImportedSecret::new(hex::encode([1; 32])).expect("valid matching import"),
2456            )
2457            .expect("prepare matching import");
2458        let recovered = service
2459            .adopt(
2460                select_one(&service, matching),
2461                ReceiptRef::new("recovery-action-2").expect("valid recovery receipt"),
2462            )
2463            .expect("adopt matching recovery");
2464        assert_eq!(recovered.state, CustodyState::Ready);
2465        assert_eq!(recovered.identity, Some(identity));
2466    }
2467
2468    #[test]
2469    fn recovery_reconciliation_deduplicates_identity_and_stops_on_conflict() {
2470        let first = PreparedRecovery::new(
2471            CandidateRef::new("candidate-one".to_string()).expect("valid candidate"),
2472            CandidateKind::EncryptedRecoveryArtifact,
2473            SecretKeyMaterial::from_bytes(Zeroizing::new([2; 32])).expect("valid first candidate"),
2474        )
2475        .expect("prepare first candidate");
2476        let duplicate = PreparedRecovery::new(
2477            CandidateRef::new("candidate-two".to_string()).expect("valid candidate"),
2478            CandidateKind::EncryptedRecoveryArtifact,
2479            SecretKeyMaterial::from_bytes(Zeroizing::new([2; 32]))
2480                .expect("valid duplicate candidate"),
2481        )
2482        .expect("prepare duplicate candidate");
2483        let conflicting = PreparedRecovery::new(
2484            CandidateRef::new("candidate-three".to_string()).expect("valid candidate"),
2485            CandidateKind::EncryptedRecoveryArtifact,
2486            SecretKeyMaterial::from_bytes(Zeroizing::new([3; 32]))
2487                .expect("valid conflicting candidate"),
2488        )
2489        .expect("prepare conflicting candidate");
2490
2491        let deduplicated = reconcile_prepared(&[&first, &duplicate]);
2492        assert_eq!(
2493            deduplicated.state,
2494            crate::RecoveryResolutionState::CandidateSelected
2495        );
2496        assert_eq!(deduplicated.candidates.len(), 1);
2497
2498        let conflict = reconcile_prepared(&[&first, &duplicate, &conflicting]);
2499        assert_eq!(
2500            conflict.state,
2501            crate::RecoveryResolutionState::OwnerSelectionRequired
2502        );
2503        assert_eq!(conflict.candidates.len(), 2);
2504        assert!(conflict.selected_candidate_ref.is_none());
2505
2506        let selected_candidate_ref = conflicting.candidate_ref().clone();
2507        let selection_directory = tempfile::tempdir().expect("create selection directory");
2508        let selection_service =
2509            service(FakeStore::empty(), selection_directory.path().to_path_buf());
2510        let selected = selection_service
2511            .select_recovery(vec![first, conflicting], &selected_candidate_ref)
2512            .expect("select conflicting recovery explicitly");
2513        assert_eq!(selected.identity(), &conflict.candidates[1].identity);
2514    }
2515
2516    #[test]
2517    fn read_back_mismatch_or_missing_value_never_completes() {
2518        for mode in [FakeReadMode::Substitute([2; 32]), FakeReadMode::Missing] {
2519            let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2520            let store = FakeStore::empty();
2521            store.set_mode_after_write(mode);
2522            let service = service(store, temporary_directory.path().to_path_buf());
2523
2524            assert!(matches!(
2525                service.create(receipt()),
2526                Err(CustodyError::ReadBackMismatch)
2527            ));
2528            assert!(
2529                !temporary_directory
2530                    .path()
2531                    .join("identity.complete.json")
2532                    .exists()
2533            );
2534            assert_eq!(
2535                service.inspect().expect("inspect rolled back create").state,
2536                CustodyState::Absent
2537            );
2538        }
2539    }
2540
2541    #[test]
2542    fn rollback_failure_stays_incomplete_and_blocks_replacement() {
2543        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2544        let store = FakeStore::empty();
2545        store.set_mode_after_write(FakeReadMode::Missing);
2546        store.set_fail_delete(true);
2547        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2548
2549        assert!(matches!(
2550            service.create(receipt()),
2551            Err(CustodyError::TransactionIncomplete)
2552        ));
2553        assert!(service.paths.transaction_path.exists());
2554        assert!(matches!(
2555            service.create(ReceiptRef::new("owner-action-2").expect("valid second receipt")),
2556            Err(CustodyError::CustodyDenied(CustodyState::Conflict))
2557        ));
2558        assert_eq!(store.state.lock().expect("lock fake store").writes, 1);
2559    }
2560
2561    #[test]
2562    fn locked_and_lost_custody_deny_signing() {
2563        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2564        let store = FakeStore::empty();
2565        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2566        let created = service.create(receipt()).expect("create identity");
2567        let identity = created.identity.expect("created public identity");
2568        let request = signing_request(&identity);
2569
2570        store.set_read_mode(FakeReadMode::Locked);
2571        assert_eq!(
2572            service.inspect().expect("inspect locked custody").state,
2573            CustodyState::Locked
2574        );
2575        assert!(matches!(
2576            service.sign(&request),
2577            Err(CustodyError::CustodyDenied(CustodyState::Locked))
2578        ));
2579
2580        store.set_read_mode(FakeReadMode::Missing);
2581        assert_eq!(
2582            service.inspect().expect("inspect lost custody").state,
2583            CustodyState::Lost
2584        );
2585        assert!(matches!(
2586            service.sign(&request),
2587            Err(CustodyError::CustodyDenied(CustodyState::Lost))
2588        ));
2589    }
2590
2591    #[test]
2592    fn inspection_distinguishes_keychain_ambiguity_from_identity_mismatch() {
2593        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2594        let store = FakeStore::empty();
2595        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2596        let created = service.create(receipt()).expect("create identity");
2597        let manifest_identity = created.identity.expect("created public identity");
2598
2599        store.set_read_mode(FakeReadMode::Conflict);
2600        let ambiguous = service
2601            .inspect_details()
2602            .expect("inspect ambiguous secure store");
2603        assert_eq!(ambiguous.custody.state, CustodyState::Conflict);
2604        assert_eq!(
2605            ambiguous.conflict,
2606            Some(CustodyConflict {
2607                reason: CustodyConflictReason::AmbiguousSecureStore,
2608                identities: vec![manifest_identity.clone()],
2609            })
2610        );
2611
2612        store.set_read_mode(FakeReadMode::Substitute([2; 32]));
2613        let mismatch = service
2614            .inspect_details()
2615            .expect("inspect manifest and custody mismatch");
2616        let mismatch = mismatch.conflict.expect("mismatch details");
2617        assert_eq!(
2618            mismatch.reason,
2619            CustodyConflictReason::PublicManifestCustodyMismatch
2620        );
2621        assert_eq!(mismatch.identities.len(), 2);
2622        assert_eq!(mismatch.identities[0], manifest_identity);
2623        assert_ne!(mismatch.identities[0], mismatch.identities[1]);
2624    }
2625
2626    #[test]
2627    fn owner_selected_recovery_repairs_a_public_manifest_custody_conflict() {
2628        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2629        let store = FakeStore::empty();
2630        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2631        let original = service.create(receipt()).expect("create original identity");
2632        let original_identity = original.identity.expect("original public identity");
2633        store.replace_secret([2; 32]);
2634
2635        let conflict = service
2636            .inspect_details()
2637            .expect("inspect identity conflict")
2638            .conflict
2639            .expect("public conflict details");
2640        assert_eq!(
2641            conflict.reason,
2642            CustodyConflictReason::PublicManifestCustodyMismatch
2643        );
2644        let custody_identity = conflict
2645            .identities
2646            .iter()
2647            .find(|identity| *identity != &original_identity)
2648            .expect("custody identity")
2649            .clone();
2650        let resolution_receipt =
2651            ReceiptRef::new("conflict-resolution-1").expect("valid resolution receipt");
2652        let selected = service
2653            .prepare_import(
2654                ImportedSecret::new(hex::encode([2; 32])).expect("valid custody import"),
2655            )
2656            .map(|prepared| select_one(&service, prepared))
2657            .expect("prepare selected custody identity");
2658
2659        let resolved = service
2660            .resolve_conflict(selected, resolution_receipt.clone())
2661            .expect("resolve identity conflict");
2662        assert_eq!(resolved.state, CustodyState::Ready);
2663        assert_eq!(resolved.identity, Some(custody_identity.clone()));
2664        assert_eq!(resolved.receipt_ref, Some(resolution_receipt.clone()));
2665        assert!(
2666            service
2667                .inspect_details()
2668                .expect("inspect repaired identity")
2669                .conflict
2670                .is_none()
2671        );
2672
2673        let repeated = service
2674            .prepare_import(
2675                ImportedSecret::new(hex::encode([2; 32])).expect("valid repeated import"),
2676            )
2677            .map(|prepared| select_one(&service, prepared))
2678            .and_then(|selected| service.resolve_conflict(selected, resolution_receipt))
2679            .expect("repeat conflict resolution idempotently");
2680        assert_eq!(repeated.identity, Some(custody_identity));
2681    }
2682
2683    #[test]
2684    fn conflict_resolution_rejects_an_identity_outside_the_inspected_conflict() {
2685        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2686        let store = FakeStore::empty();
2687        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2688        service.create(receipt()).expect("create original identity");
2689        store.replace_secret([2; 32]);
2690        let unrelated = service
2691            .prepare_import(
2692                ImportedSecret::new(hex::encode([3; 32])).expect("valid unrelated import"),
2693            )
2694            .map(|prepared| select_one(&service, prepared))
2695            .expect("prepare unrelated identity");
2696
2697        assert!(matches!(
2698            service.resolve_conflict(
2699                unrelated,
2700                ReceiptRef::new("conflict-resolution-2").expect("valid resolution receipt")
2701            ),
2702            Err(CustodyError::CustodyDenied(CustodyState::Conflict))
2703        ));
2704        assert_eq!(
2705            service
2706                .inspect_details()
2707                .expect("conflict remains")
2708                .custody
2709                .state,
2710            CustodyState::Conflict
2711        );
2712    }
2713
2714    #[test]
2715    fn conflict_resolution_journal_retries_after_verified_delete_failure() {
2716        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2717        let store = FakeStore::empty();
2718        let service = service(store.clone(), temporary_directory.path().to_path_buf());
2719        let original = service.create(receipt()).expect("create original identity");
2720        let original_identity = original.identity.expect("original public identity");
2721        store.replace_secret([2; 32]);
2722        store.set_fail_delete(true);
2723        let resolution_receipt =
2724            ReceiptRef::new("conflict-resolution-3").expect("valid resolution receipt");
2725        let selected = service
2726            .prepare_import(
2727                ImportedSecret::new(hex::encode([1; 32])).expect("valid original import"),
2728            )
2729            .map(|prepared| select_one(&service, prepared))
2730            .expect("prepare original identity");
2731
2732        assert!(matches!(
2733            service.resolve_conflict(selected, resolution_receipt.clone()),
2734            Err(CustodyError::TransactionIncomplete)
2735        ));
2736        let pending = service
2737            .inspect_details()
2738            .expect("inspect pending conflict resolution")
2739            .pending_transaction
2740            .expect("pending conflict transaction");
2741        assert_eq!(pending.operation, PendingIdentityOperation::ResolveConflict);
2742        assert_eq!(pending.receipt_ref, resolution_receipt);
2743
2744        store.set_fail_delete(false);
2745        let selected = service
2746            .prepare_import(ImportedSecret::new(hex::encode([1; 32])).expect("valid retry import"))
2747            .map(|prepared| select_one(&service, prepared))
2748            .expect("prepare retry identity");
2749        let resolved = service
2750            .resolve_conflict(selected, pending.receipt_ref)
2751            .expect("retry conflict resolution");
2752        assert_eq!(resolved.state, CustodyState::Ready);
2753        assert_eq!(resolved.identity, Some(original_identity));
2754        assert!(!service.paths.transaction_path.exists());
2755    }
2756
2757    #[test]
2758    fn conflict_resolution_is_not_ready_until_its_receipt_is_completed() {
2759        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2760        let store = FakeStore::empty();
2761        let service = service(store, temporary_directory.path().to_path_buf());
2762        let created = service.create(receipt()).expect("create identity");
2763        let identity = created.identity.expect("created identity");
2764        let other_identity = SecretKeyMaterial::from_bytes(Zeroizing::new([2; 32]))
2765            .expect("valid other secret")
2766            .public_identity()
2767            .expect("derive other identity");
2768        let pending_receipt =
2769            ReceiptRef::new("conflict-resolution-crash").expect("valid pending receipt");
2770        let mut transaction = IdentityTransaction::new(
2771            TransactionOperation::ResolveConflict,
2772            pending_receipt.clone(),
2773            Some(
2774                CandidateRef::new("advanced-nostr-import".to_string())
2775                    .expect("valid candidate reference"),
2776            ),
2777        );
2778        transaction.expected_identity = Some(identity);
2779        transaction.conflict_identities = vec![
2780            transaction
2781                .expected_identity
2782                .clone()
2783                .expect("expected identity"),
2784            other_identity,
2785        ];
2786        write_json_document(&service.paths.transaction_path, &transaction)
2787            .expect("write interrupted conflict transaction");
2788
2789        let inspection = service
2790            .inspect_details()
2791            .expect("inspect interrupted conflict resolution");
2792        assert_eq!(inspection.custody.state, CustodyState::Incomplete);
2793        assert_eq!(
2794            inspection
2795                .pending_transaction
2796                .expect("pending conflict resolution")
2797                .receipt_ref,
2798            pending_receipt
2799        );
2800    }
2801
2802    #[test]
2803    fn admitted_signing_returns_a_verified_public_event() {
2804        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2805        let store = FakeStore::empty();
2806        let service = service(store, temporary_directory.path().to_path_buf());
2807        let created = service.create(receipt()).expect("create identity");
2808        let identity = created.identity.expect("created public identity");
2809        let request = signing_request(&identity);
2810
2811        let signed = service.sign(&request).expect("sign admitted request");
2812        let event = Event::from_json(&signed.signed_event_json).expect("parse signed event");
2813        event.verify().expect("verify signed event");
2814        assert_eq!(signed.identity, identity);
2815        assert_eq!(signed.event_id, event.id.to_hex());
2816        assert_eq!(signed.signature, event.sig.to_string());
2817    }
2818
2819    #[test]
2820    fn owner_attestation_matches_the_nip_oa_domain_without_exporting_custody() {
2821        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2822        let service = service(FakeStore::empty(), temporary_directory.path().to_path_buf());
2823        let identity = service
2824            .create(receipt())
2825            .expect("create identity")
2826            .identity
2827            .expect("created public identity");
2828        let agent_public_key_hex = NostrPublicKeyHex::new("2".repeat(64)).expect("agent key");
2829        let result = service
2830            .sign_owner_attestation(&OwnerAttestationRequest {
2831                request_ref: ReceiptRef::new("attest-sarah").expect("request ref"),
2832                identity_ref: identity.identity_ref().clone(),
2833                agent_public_key_hex: agent_public_key_hex.clone(),
2834                conditions: String::new(),
2835            })
2836            .expect("sign owner attestation");
2837
2838        assert_eq!(result.agent_public_key_hex, agent_public_key_hex);
2839        assert_eq!(result.auth_tag[0], "auth");
2840        assert_eq!(result.auth_tag[1], identity.public_key_hex().as_str());
2841        assert_eq!(result.auth_tag[2], "");
2842        let digest = Sha256::digest(
2843            format!("nostr:agent-auth:{}:", result.agent_public_key_hex.as_str()).as_bytes(),
2844        );
2845        let message = Message::from_digest(digest.into());
2846        let signature = result.auth_tag[3]
2847            .parse::<nostr::secp256k1::schnorr::Signature>()
2848            .expect("signature");
2849        nostr::SECP256K1
2850            .verify_schnorr(
2851                &signature,
2852                &message,
2853                &identity
2854                    .public_key_hex()
2855                    .public_key()
2856                    .expect("owner public key")
2857                    .xonly()
2858                    .expect("x-only owner key"),
2859            )
2860            .expect("verify NIP-OA signature");
2861        assert!(
2862            !serde_json::to_string(&result)
2863                .expect("serialize result")
2864                .contains("private")
2865        );
2866    }
2867
2868    #[test]
2869    fn nip44_self_encryption_never_signs_the_plaintext_template() {
2870        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2871        let service = service(FakeStore::empty(), temporary_directory.path().to_path_buf());
2872        let identity = service
2873            .create(receipt())
2874            .expect("create identity")
2875            .identity
2876            .expect("created public identity");
2877        let request = AdmittedSigningRequest {
2878            request_ref: ReceiptRef::new("nip44-self-record").expect("request receipt"),
2879            identity_ref: identity.identity_ref().clone(),
2880            purpose: crate::SigningPurpose::Nip44EncryptedSelfEvent,
2881            event: UnsignedEventTemplate {
2882                created_at: 1_700_000_002,
2883                kind: 30_078,
2884                tags: vec![vec!["d".into(), "read-state:mobile".into()]],
2885                content: "{\"v\":1,\"client_id\":\"mobile\",\"contexts\":{}}".into(),
2886            },
2887        };
2888
2889        assert!(service.sign(&request).is_err());
2890        let signed = service
2891            .sign_nip44_encrypted_to_self(&request)
2892            .expect("encrypt and sign");
2893        let event = Event::from_json(&signed.signed_event_json).expect("signed event");
2894        event.verify().expect("verify signed event");
2895        assert_ne!(event.content, request.event.content);
2896        assert!(!event.content.contains("client_id"));
2897        assert_eq!(
2898            service
2899                .decrypt_nip44_from_self(&event.content)
2900                .expect("decrypt self record"),
2901            request.event.content
2902        );
2903    }
2904
2905    #[test]
2906    fn nip17_wrap_and_unwrap_keep_secret_material_inside_custody() {
2907        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2908        let sender = service(
2909            FakeStore::empty(),
2910            temporary_directory.path().join("sender"),
2911        );
2912        let recipient = IdentityService::new(
2913            AppChannel::Dev,
2914            CustodyPaths::for_data_root(temporary_directory.path().join("recipient")),
2915            FakeStore::empty(),
2916            Arc::new(FixedGenerator([2; 32])),
2917        );
2918        let sender_identity = sender
2919            .create(ReceiptRef::new("sender-create").expect("sender receipt"))
2920            .expect("create sender")
2921            .identity
2922            .expect("sender identity");
2923        let recipient_identity = recipient
2924            .create(ReceiptRef::new("recipient-create").expect("recipient receipt"))
2925            .expect("create recipient")
2926            .identity
2927            .expect("recipient identity");
2928        let recipient_public_key = recipient_identity.public_key_hex().clone();
2929        let request = PrivateMessageRequest {
2930            request_ref: ReceiptRef::new("private-message-1").expect("private receipt"),
2931            identity_ref: sender_identity.identity_ref().clone(),
2932            recipients: vec![recipient_public_key.clone()],
2933            rumor: UnsignedEventTemplate {
2934                created_at: 1_700_000_001,
2935                kind: Kind::PrivateDirectMessage.as_u16(),
2936                tags: vec![
2937                    vec!["p".to_string(), recipient_public_key.as_str().to_string()],
2938                    vec!["conversation".to_string(), "sarah.fixture".to_string()],
2939                ],
2940                content: "owner-private".to_string(),
2941            },
2942        };
2943
2944        let wrapped = sender
2945            .gift_wrap_private_message(&request)
2946            .expect("gift wrap");
2947        assert_eq!(wrapped.len(), 1);
2948        let outer = Event::from_json(&wrapped[0].gift_wrap_event_json).expect("outer event");
2949        assert_eq!(outer.kind, Kind::GiftWrap);
2950        assert!(!outer.content.contains("owner-private"));
2951
2952        let unwrapped = recipient
2953            .unwrap_private_message(&wrapped[0].gift_wrap_event_json)
2954            .expect("recipient unwrap");
2955        assert_eq!(unwrapped.rumor_event_id, wrapped[0].rumor_event_id);
2956        assert_eq!(
2957            unwrapped.sender_public_key_hex,
2958            sender_identity.public_key_hex().as_str()
2959        );
2960        assert_eq!(unwrapped.content, "owner-private");
2961        assert!(
2962            sender
2963                .unwrap_private_message(&wrapped[0].gift_wrap_event_json)
2964                .is_err()
2965        );
2966    }
2967
2968    #[test]
2969    fn encrypted_recovery_artifact_round_trips_without_mutating_preview() {
2970        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
2971        let source_store = FakeStore::empty();
2972        let source_service = service(
2973            source_store.clone(),
2974            temporary_directory.path().join("source"),
2975        );
2976        let created = source_service
2977            .create(receipt())
2978            .expect("create source identity");
2979        let identity = created.identity.expect("created source identity");
2980        let artifact_path = temporary_directory.path().join("omega-recovery.ncryptsec");
2981
2982        let artifact = source_service
2983            .export_recovery_artifact(
2984                identity.identity_ref(),
2985                &artifact_path,
2986                RecoveryPassword::new("correct horse battery staple".to_string())
2987                    .expect("valid export password"),
2988            )
2989            .expect("export recovery artifact");
2990        let artifact_bytes = std::fs::read(&artifact_path).expect("read recovery artifact");
2991        let inspection = source_service
2992            .inspect_details()
2993            .expect("inspect protected identity");
2994        assert_eq!(
2995            inspection.recovery_protection.state,
2996            crate::RecoveryProtectionState::Protected
2997        );
2998        let protection = inspection
2999            .recovery_protection
3000            .record
3001            .expect("recovery protection record");
3002        assert_eq!(protection.identity(), &identity);
3003        assert_eq!(protection.byte_length(), artifact.byte_length());
3004        assert_eq!(
3005            protection.artifact_digest(),
3006            hex::encode(sha2::Sha256::digest(&artifact_bytes))
3007        );
3008        let protection_document =
3009            std::fs::read_to_string(&source_service.paths.recovery_protection_path)
3010                .expect("read recovery protection document");
3011        assert!(!protection_document.contains(artifact_path.to_string_lossy().as_ref()));
3012        let secret_hex = hex::encode([1; 32]);
3013        assert_eq!(artifact.byte_length(), artifact_bytes.len() as u64);
3014        assert!(artifact_bytes.starts_with(b"ncryptsec1"));
3015        assert!(artifact_bytes.ends_with(b"\n"));
3016        assert!(!String::from_utf8_lossy(&artifact_bytes).contains(&secret_hex));
3017        assert!(!String::from_utf8_lossy(&artifact_bytes).contains(identity.npub().as_str()));
3018        assert!(
3019            std::fs::read_dir(temporary_directory.path())
3020                .expect("read artifact directory")
3021                .all(|entry| {
3022                    !entry
3023                        .expect("read artifact directory entry")
3024                        .file_name()
3025                        .to_string_lossy()
3026                        .contains(".omega-recovery-")
3027                })
3028        );
3029
3030        let candidate = source_service
3031            .discover_recovery_artifact(artifact_path.clone())
3032            .expect("discover recovery artifact");
3033        let writes_before_preview = source_store.state.lock().expect("lock fake store").writes;
3034        assert!(matches!(
3035            source_service.prepare_recovery_artifact(
3036                &candidate,
3037                RecoveryPassword::new("wrong password".to_string()).expect("valid wrong password")
3038            ),
3039            Err(CustodyError::RecoveryDecryptionFailed)
3040        ));
3041        let prepared = source_service
3042            .prepare_recovery_artifact(
3043                &candidate,
3044                RecoveryPassword::new("correct horse battery staple".to_string())
3045                    .expect("valid import password"),
3046            )
3047            .expect("prepare recovery artifact");
3048        assert_eq!(prepared.identity(), &identity);
3049        assert_eq!(
3050            source_store.state.lock().expect("lock fake store").writes,
3051            writes_before_preview
3052        );
3053
3054        let destination = service(
3055            FakeStore::empty(),
3056            temporary_directory.path().join("destination"),
3057        );
3058        let adopted = destination
3059            .adopt(
3060                select_one(&destination, prepared),
3061                ReceiptRef::new("artifact-recovery-1").expect("valid recovery receipt"),
3062            )
3063            .expect("adopt recovery artifact");
3064        assert_eq!(adopted.identity.as_ref(), Some(&identity));
3065
3066        let before = std::fs::read(&artifact_path).expect("read preserved artifact");
3067        assert!(matches!(
3068            source_service.export_recovery_artifact(
3069                identity.identity_ref(),
3070                &artifact_path,
3071                RecoveryPassword::new("another strong password".to_string())
3072                    .expect("valid replacement password")
3073            ),
3074            Err(CustodyError::RecoveryArtifactExists)
3075        ));
3076        assert_eq!(
3077            std::fs::read(&artifact_path).expect("read unchanged artifact"),
3078            before
3079        );
3080
3081        #[cfg(unix)]
3082        {
3083            use std::os::unix::fs::PermissionsExt as _;
3084            assert_eq!(
3085                std::fs::metadata(&artifact_path)
3086                    .expect("read artifact metadata")
3087                    .permissions()
3088                    .mode()
3089                    & 0o777,
3090                0o600
3091            );
3092        }
3093    }
3094
3095    /// The onboarding gate is `custody.state != CustodyState::Ready`
3096    /// (`crates/onboarding/src/identity_startup.rs`). This pins both sides of
3097    /// that predicate against a temporary root and a fake keyring, so first-run
3098    /// behaviour is provable without a GUI, a human, or the login Keychain.
3099    ///
3100    /// It exists because a human was once asked to reset an identity to
3101    /// unblock a proof. Test state is the harness's problem.
3102    #[test]
3103    fn a_fresh_profile_requires_onboarding_and_a_created_one_does_not() {
3104        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3105        let service = service(FakeStore::empty(), temporary_directory.path().to_path_buf());
3106
3107        // Nothing has been created yet: this is what a first run looks like.
3108        let fresh = service.inspect().expect("inspect a fresh profile");
3109        assert_ne!(
3110            fresh.state,
3111            CustodyState::Ready,
3112            "a profile with no identity must not report Ready, or first launch \
3113             would skip onboarding"
3114        );
3115        assert_eq!(fresh.state, CustodyState::Absent);
3116
3117        service.create(receipt()).expect("create identity");
3118
3119        // Same root, same store, identity now present.
3120        let established = service.inspect().expect("inspect an established profile");
3121        assert_eq!(
3122            established.state,
3123            CustodyState::Ready,
3124            "an established identity must report Ready, or onboarding would \
3125             show on every launch"
3126        );
3127    }
3128
3129    /// Resetting must return the profile to the first-run state, so a proof
3130    /// harness can produce a clean identity itself rather than asking a person
3131    /// to delete one.
3132    #[test]
3133    fn resetting_returns_a_profile_to_the_first_run_state() {
3134        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3135        let service = service(FakeStore::empty(), temporary_directory.path().to_path_buf());
3136        let created = service.create(receipt()).expect("create identity");
3137        let identity = created.identity.expect("created public identity");
3138        assert_eq!(
3139            service.inspect().expect("inspect").state,
3140            CustodyState::Ready
3141        );
3142
3143        let reset = service
3144            .reset(
3145                identity.identity_ref(),
3146                ReceiptRef::new("harness-reset").expect("valid reset receipt"),
3147            )
3148            .expect("reset the identity");
3149
3150        // Reset lands on RelaunchRequired rather than Absent: the running
3151        // process still holds the old identity, so the state is deliberately
3152        // one that a restart resolves. What matters for the gate is only that
3153        // it is not Ready.
3154        assert_eq!(reset.state, CustodyState::RelaunchRequired);
3155        assert_ne!(
3156            reset.state,
3157            CustodyState::Ready,
3158            "after a reset the profile must once again require onboarding"
3159        );
3160    }
3161
3162    #[test]
3163    fn reset_requires_the_expected_identity_and_verifies_deletion() {
3164        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3165        let store = FakeStore::empty();
3166        let service = service(store, temporary_directory.path().to_path_buf());
3167        let created = service.create(receipt()).expect("create identity");
3168        let identity = created.identity.expect("created public identity");
3169        let artifact_directory = tempfile::tempdir().expect("create artifact directory");
3170        service
3171            .export_recovery_artifact(
3172                identity.identity_ref(),
3173                &artifact_directory.path().join("recovery.ncryptsec"),
3174                RecoveryPassword::new("reset protection password".to_string())
3175                    .expect("valid recovery password"),
3176            )
3177            .expect("protect identity recovery");
3178        assert!(service.paths.recovery_protection_path.exists());
3179        let wrong_identity = SecretKeyMaterial::from_bytes(Zeroizing::new([2; 32]))
3180            .expect("valid different secret")
3181            .public_identity()
3182            .expect("derive different identity");
3183
3184        assert!(matches!(
3185            service.reset(
3186                wrong_identity.identity_ref(),
3187                ReceiptRef::new("reset-action-1").expect("valid reset receipt")
3188            ),
3189            Err(CustodyError::CustodyDenied(CustodyState::Conflict))
3190        ));
3191        assert_eq!(
3192            service
3193                .inspect()
3194                .expect("inspect after refused reset")
3195                .state,
3196            CustodyState::Ready
3197        );
3198
3199        let reset = service
3200            .reset(
3201                identity.identity_ref(),
3202                ReceiptRef::new("reset-action-2").expect("valid reset receipt"),
3203            )
3204            .expect("reset identity");
3205        assert_eq!(reset.state, CustodyState::RelaunchRequired);
3206        assert_eq!(
3207            service
3208                .inspect()
3209                .expect("resume reset after relaunch")
3210                .state,
3211            CustodyState::RelaunchRequired
3212        );
3213        assert!(!service.paths.recovery_protection_path.exists());
3214        assert_eq!(
3215            service
3216                .acknowledge_relaunch()
3217                .expect("acknowledge completed reset")
3218                .state,
3219            CustodyState::Absent
3220        );
3221    }
3222
3223    #[test]
3224    fn failed_reset_stays_durable_and_resumes_without_exposing_absent() {
3225        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3226        let store = FakeStore::empty();
3227        let service = service(store.clone(), temporary_directory.path().to_path_buf());
3228        let created = service.create(receipt()).expect("create identity");
3229        let identity = created.identity.expect("created public identity");
3230        let request = signing_request(&identity);
3231        store.set_fail_delete(true);
3232
3233        assert_eq!(
3234            service
3235                .reset(
3236                    identity.identity_ref(),
3237                    ReceiptRef::new("reset-action-3").expect("valid reset receipt"),
3238                )
3239                .expect("record reset intent")
3240                .state,
3241            CustodyState::RelaunchRequired
3242        );
3243        assert_eq!(
3244            service.inspect().expect("attempt pending reset").state,
3245            CustodyState::ResetFailed
3246        );
3247        assert!(matches!(
3248            service.sign(&request),
3249            Err(CustodyError::CustodyDenied(CustodyState::ResetFailed))
3250        ));
3251        assert!(service.paths.reset_path.exists());
3252        assert!(service.paths.manifest_path.exists());
3253
3254        store.set_fail_delete(false);
3255        assert_eq!(
3256            service
3257                .resume_pending_reset()
3258                .expect("resume failed reset")
3259                .state,
3260            CustodyState::RelaunchRequired
3261        );
3262        assert_eq!(
3263            service.inspect().expect("inspect completed reset").state,
3264            CustodyState::RelaunchRequired
3265        );
3266        assert_eq!(
3267            service
3268                .acknowledge_relaunch()
3269                .expect("acknowledge completed reset")
3270                .state,
3271            CustodyState::Absent
3272        );
3273    }
3274
3275    #[test]
3276    fn process_start_acknowledges_reset_complete_at_entry() {
3277        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3278        let data_root = temporary_directory.path().to_path_buf();
3279        let store = FakeStore::empty();
3280        let initial_service = service(store.clone(), data_root.clone());
3281        let created = initial_service.create(receipt()).expect("create identity");
3282        let identity = created.identity.expect("created public identity");
3283        initial_service
3284            .reset(
3285                identity.identity_ref(),
3286                ReceiptRef::new("process-start-complete").expect("valid reset receipt"),
3287            )
3288            .expect("record reset intent");
3289        assert_eq!(
3290            initial_service
3291                .inspect()
3292                .expect("complete pending reset")
3293                .state,
3294            CustodyState::RelaunchRequired
3295        );
3296
3297        let restarted_service = service(store, data_root);
3298        let inspection = restarted_service
3299            .inspect_for_process_start()
3300            .expect("inspect completed reset on process start");
3301
3302        assert_eq!(
3303            inspection.custody,
3304            CustodyResult::for_state(CustodyState::Absent)
3305        );
3306        assert_eq!(
3307            inspection.recovery_protection.state,
3308            RecoveryProtectionState::NotApplicable
3309        );
3310        assert!(inspection.pending_transaction.is_none());
3311        assert!(inspection.conflict.is_none());
3312        assert!(!restarted_service.paths.reset_path.exists());
3313    }
3314
3315    #[test]
3316    fn process_start_preserves_reset_completed_during_same_call() {
3317        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3318        let data_root = temporary_directory.path().to_path_buf();
3319        let store = FakeStore::empty();
3320        let initial_service = service(store.clone(), data_root.clone());
3321        let created = initial_service.create(receipt()).expect("create identity");
3322        let identity = created.identity.expect("created public identity");
3323        initial_service
3324            .reset(
3325                identity.identity_ref(),
3326                ReceiptRef::new("process-start-pending").expect("valid reset receipt"),
3327            )
3328            .expect("record reset intent");
3329
3330        let inspection = initial_service
3331            .inspect_for_process_start()
3332            .expect("resume pending reset on process start");
3333
3334        assert_eq!(inspection.custody.state, CustodyState::RelaunchRequired);
3335        assert_eq!(
3336            initial_service
3337                .read_reset_marker_locked()
3338                .expect("read reset marker")
3339                .expect("completed reset marker")
3340                .status,
3341            ResetStatus::Complete
3342        );
3343        assert_eq!(
3344            initial_service
3345                .inspect_details()
3346                .expect("inspect again in same process")
3347                .custody
3348                .state,
3349            CustodyState::RelaunchRequired
3350        );
3351
3352        let restarted_service = service(store, data_root);
3353        assert_eq!(
3354            restarted_service
3355                .inspect_for_process_start()
3356                .expect("acknowledge reset on next process start")
3357                .custody
3358                .state,
3359            CustodyState::Absent
3360        );
3361        assert!(!restarted_service.paths.reset_path.exists());
3362    }
3363
3364    #[test]
3365    fn process_start_preserves_failed_reset_resumed_during_same_call() {
3366        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3367        let data_root = temporary_directory.path().to_path_buf();
3368        let store = FakeStore::empty();
3369        let initial_service = service(store.clone(), data_root.clone());
3370        let created = initial_service.create(receipt()).expect("create identity");
3371        let identity = created.identity.expect("created public identity");
3372        initial_service
3373            .reset(
3374                identity.identity_ref(),
3375                ReceiptRef::new("process-start-failed").expect("valid reset receipt"),
3376            )
3377            .expect("record reset intent");
3378        store.set_fail_delete(true);
3379        assert_eq!(
3380            initial_service.inspect().expect("fail pending reset").state,
3381            CustodyState::ResetFailed
3382        );
3383        assert_eq!(
3384            initial_service
3385                .read_reset_marker_locked()
3386                .expect("read reset marker")
3387                .expect("failed reset marker")
3388                .status,
3389            ResetStatus::Failed
3390        );
3391
3392        store.set_fail_delete(false);
3393        let inspection = initial_service
3394            .inspect_for_process_start()
3395            .expect("resume failed reset on process start");
3396
3397        assert_eq!(inspection.custody.state, CustodyState::RelaunchRequired);
3398        assert_eq!(
3399            initial_service
3400                .read_reset_marker_locked()
3401                .expect("read reset marker")
3402                .expect("completed reset marker")
3403                .status,
3404            ResetStatus::Complete
3405        );
3406
3407        let restarted_service = service(store, data_root);
3408        assert_eq!(
3409            restarted_service
3410                .inspect_for_process_start()
3411                .expect("acknowledge reset on next process start")
3412                .custody
3413                .state,
3414            CustodyState::Absent
3415        );
3416        assert!(!restarted_service.paths.reset_path.exists());
3417    }
3418
3419    #[test]
3420    fn reset_identity_mismatch_is_reset_failed_and_never_deletes() {
3421        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3422        let store = FakeStore::empty();
3423        let service = service(store.clone(), temporary_directory.path().to_path_buf());
3424        let created = service.create(receipt()).expect("create identity");
3425        let identity = created.identity.expect("created public identity");
3426        service
3427            .reset(
3428                identity.identity_ref(),
3429                ReceiptRef::new("reset-action-4").expect("valid reset receipt"),
3430            )
3431            .expect("record reset intent");
3432        let deletes_before = store.state.lock().expect("lock fake store").deletes;
3433        store.set_read_mode(FakeReadMode::Substitute([2; 32]));
3434
3435        assert_eq!(
3436            service.inspect().expect("inspect reset mismatch").state,
3437            CustodyState::ResetFailed
3438        );
3439        assert_eq!(
3440            store.state.lock().expect("lock fake store").deletes,
3441            deletes_before
3442        );
3443    }
3444
3445    #[test]
3446    fn public_outputs_and_app_data_do_not_contain_the_secret() {
3447        let temporary_directory = tempfile::tempdir().expect("create temporary directory");
3448        let store = FakeStore::empty();
3449        let service = service(store, temporary_directory.path().to_path_buf());
3450        let result = service.create(receipt()).expect("create identity");
3451        let secret_hex = hex::encode([1; 32]);
3452
3453        assert!(!format!("{result:?}").contains(&secret_hex));
3454        for entry in
3455            std::fs::read_dir(temporary_directory.path()).expect("read public identity directory")
3456        {
3457            let entry = entry.expect("read public identity entry");
3458            if entry.file_type().expect("read entry type").is_file() {
3459                let contents =
3460                    std::fs::read_to_string(entry.path()).expect("read public identity document");
3461                assert!(!contents.contains(&secret_hex));
3462                assert!(!contents.contains("nsec"));
3463            }
3464        }
3465    }
3466}
3467
Served at tenant.openagents/omega Member data and write actions are omitted.