Skip to repository content3467 lines · 137.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:01:44.259Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
custody.rs
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