auths_sdk/workflows/namespace.rs
1//! Namespace management workflows: claim, delegate, transfer, and lookup.
2//!
3//! These workflows build transparency log entries for namespace operations,
4//! canonicalize and sign them, and return the signed payload ready for
5//! submission to a registry server at `/v1/log/entries`.
6
7use chrono::{DateTime, Utc};
8
9use auths_core::ports::namespace::{
10 Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName,
11 PlatformContext, VerificationChallenge,
12};
13use auths_core::signing::{PassphraseProvider, SecureSigner};
14use auths_core::storage::keychain::KeyAlias;
15use auths_transparency::entry::{EntryBody, EntryContent, EntryType};
16use auths_verifier::CanonicalDid;
17use auths_verifier::types::IdentityDID;
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD as BASE64;
20use thiserror::Error;
21
22/// Errors from namespace management workflows.
23#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum NamespaceError {
26 /// The namespace is already claimed by another identity.
27 #[error("namespace '{ecosystem}/{package_name}' is already claimed")]
28 AlreadyClaimed {
29 /// The package ecosystem (e.g. "npm", "crates.io").
30 ecosystem: String,
31 /// The package name within the ecosystem.
32 package_name: String,
33 },
34
35 /// The namespace was not found in the registry.
36 #[error("namespace '{ecosystem}/{package_name}' not found")]
37 NotFound {
38 /// The package ecosystem.
39 ecosystem: String,
40 /// The package name.
41 package_name: String,
42 },
43
44 /// The caller is not authorized to manage this namespace.
45 #[error("not authorized to manage namespace '{ecosystem}/{package_name}'")]
46 Unauthorized {
47 /// The package ecosystem.
48 ecosystem: String,
49 /// The package name.
50 package_name: String,
51 },
52
53 /// The ecosystem string is invalid.
54 #[error("invalid ecosystem: {0}")]
55 InvalidEcosystem(String),
56
57 /// The package name string is invalid.
58 #[error("invalid package name: {0}")]
59 InvalidPackageName(String),
60
61 /// A supplied DID is not a valid `did:keri:` identity.
62 #[error("invalid did: {0}")]
63 InvalidDid(String),
64
65 /// A network operation failed.
66 #[error("network error: {0}")]
67 NetworkError(String),
68
69 /// A signing operation failed.
70 #[error("signing error: {0}")]
71 SigningError(String),
72
73 /// Serialization or canonicalization failed.
74 #[error("serialization error: {0}")]
75 SerializationError(String),
76
77 /// Namespace verification failed (wraps port-level error).
78 #[error("verification failed: {0}")]
79 VerificationFailed(#[from] NamespaceVerifyError),
80}
81
82/// Command to delegate namespace authority to another identity.
83///
84/// Args:
85/// * `ecosystem`: Package ecosystem identifier.
86/// * `package_name`: Package name within the ecosystem.
87/// * `delegate_did`: DID of the identity receiving delegation.
88/// * `registry_url`: Base URL of the registry server.
89///
90/// Usage:
91/// ```ignore
92/// let cmd = DelegateNamespaceCommand {
93/// ecosystem: "npm".into(),
94/// package_name: "my-package".into(),
95/// delegate_did: "did:keri:Edelegate...".into(),
96/// registry_url: "https://registry.example.com".into(),
97/// };
98/// ```
99pub struct DelegateNamespaceCommand {
100 /// Package ecosystem identifier.
101 pub ecosystem: String,
102 /// Package name within the ecosystem.
103 pub package_name: String,
104 /// DID of the identity receiving delegation.
105 pub delegate_did: String,
106 /// Base URL of the registry server.
107 pub registry_url: String,
108}
109
110/// Command to transfer namespace ownership to a new identity.
111///
112/// Args:
113/// * `ecosystem`: Package ecosystem identifier.
114/// * `package_name`: Package name within the ecosystem.
115/// * `new_owner_did`: DID of the identity receiving ownership.
116/// * `registry_url`: Base URL of the registry server.
117///
118/// Usage:
119/// ```ignore
120/// let cmd = TransferNamespaceCommand {
121/// ecosystem: "npm".into(),
122/// package_name: "my-package".into(),
123/// new_owner_did: "did:keri:Enewowner...".into(),
124/// registry_url: "https://registry.example.com".into(),
125/// };
126/// ```
127pub struct TransferNamespaceCommand {
128 /// Package ecosystem identifier.
129 pub ecosystem: String,
130 /// Package name within the ecosystem.
131 pub package_name: String,
132 /// DID of the identity receiving ownership.
133 pub new_owner_did: String,
134 /// Base URL of the registry server.
135 pub registry_url: String,
136}
137
138/// Result of a successful namespace claim.
139///
140/// Args:
141/// * `ecosystem`: The claimed ecosystem.
142/// * `package_name`: The claimed package name.
143/// * `owner_did`: DID of the new owner.
144/// * `log_sequence`: Sequence number assigned by the transparency log.
145pub struct NamespaceClaimResult {
146 /// The claimed ecosystem.
147 pub ecosystem: String,
148 /// The claimed package name.
149 pub package_name: String,
150 /// DID of the new owner.
151 pub owner_did: String,
152 /// Sequence number assigned by the transparency log.
153 pub log_sequence: u128,
154}
155
156/// Namespace information returned by a lookup.
157///
158/// Args:
159/// * `ecosystem`: The namespace's ecosystem.
160/// * `package_name`: The namespace's package name.
161/// * `owner_did`: DID of the current owner.
162/// * `delegates`: DIDs of identities with delegated authority.
163pub struct NamespaceInfo {
164 /// The namespace's ecosystem.
165 pub ecosystem: String,
166 /// The namespace's package name.
167 pub package_name: String,
168 /// DID of the current owner.
169 pub owner_did: String,
170 /// DIDs of identities with delegated authority.
171 pub delegates: Vec<String>,
172}
173
174/// A signed entry ready for submission to the registry.
175///
176/// Contains the serialized entry content and the base64-encoded actor signature.
177/// Submit this to `POST /v1/log/entries` as `{ "content": ..., "actor_sig": "..." }`.
178///
179/// Args:
180/// * `content`: The serialized entry content as a JSON value.
181/// * `actor_sig`: Base64-encoded Ed25519 signature over the canonical content.
182pub struct SignedEntry {
183 /// The serialized entry content as a JSON value.
184 pub content: serde_json::Value,
185 /// Base64-encoded Ed25519 signature over the canonical content.
186 pub actor_sig: String,
187}
188
189impl SignedEntry {
190 /// Serialize to the JSON body expected by `POST /v1/log/entries`.
191 ///
192 /// Usage:
193 /// ```ignore
194 /// let body = signed_entry.to_request_body();
195 /// // POST body to registry
196 /// ```
197 pub fn to_request_body(&self) -> serde_json::Value {
198 serde_json::json!({
199 "content": self.content,
200 "actor_sig": self.actor_sig,
201 })
202 }
203}
204
205fn validate_ecosystem(ecosystem: &str) -> Result<(), NamespaceError> {
206 if ecosystem.is_empty() {
207 return Err(NamespaceError::InvalidEcosystem(
208 "ecosystem must not be empty".into(),
209 ));
210 }
211 Ok(())
212}
213
214fn validate_package_name(package_name: &str) -> Result<(), NamespaceError> {
215 if package_name.is_empty() {
216 return Err(NamespaceError::InvalidPackageName(
217 "package name must not be empty".into(),
218 ));
219 }
220 Ok(())
221}
222
223fn build_and_sign_entry(
224 content: &EntryContent,
225 signer: &dyn SecureSigner,
226 passphrase_provider: &dyn PassphraseProvider,
227 signer_alias: &KeyAlias,
228) -> Result<SignedEntry, NamespaceError> {
229 let canonical_bytes = content
230 .canonicalize()
231 .map_err(|e| NamespaceError::SerializationError(e.to_string()))?;
232
233 let sig_bytes = signer
234 .sign_with_alias(signer_alias, passphrase_provider, &canonical_bytes)
235 .map_err(|e| NamespaceError::SigningError(e.to_string()))?;
236
237 let content_value = serde_json::to_value(content)
238 .map_err(|e| NamespaceError::SerializationError(e.to_string()))?;
239
240 Ok(SignedEntry {
241 content: content_value,
242 actor_sig: BASE64.encode(&sig_bytes),
243 })
244}
245
246/// Build and sign a `NamespaceDelegate` entry.
247///
248/// Creates an `EntryContent` with a `NamespaceDelegate` body, canonicalizes
249/// it, and signs it with the caller's key.
250///
251/// Args:
252/// * `cmd`: The delegate command with ecosystem, package name, delegate DID, and registry URL.
253/// * `actor_did`: The DID of the current namespace owner.
254/// * `signer`: Signing backend for creating the cryptographic signature.
255/// * `passphrase_provider`: Provider for obtaining key decryption passphrases.
256/// * `signer_alias`: Keychain alias of the signing key.
257///
258/// Usage:
259/// ```ignore
260/// let signed = sign_namespace_delegate(cmd, &actor_did, &signer, provider, &alias)?;
261/// ```
262pub fn sign_namespace_delegate(
263 cmd: &DelegateNamespaceCommand,
264 actor_did: &IdentityDID,
265 signer: &dyn SecureSigner,
266 passphrase_provider: &dyn PassphraseProvider,
267 signer_alias: &KeyAlias,
268) -> Result<SignedEntry, NamespaceError> {
269 validate_ecosystem(&cmd.ecosystem)?;
270 validate_package_name(&cmd.package_name)?;
271
272 let delegate_identity = IdentityDID::parse(&cmd.delegate_did)
273 .map_err(|e| NamespaceError::InvalidDid(e.to_string()))?;
274
275 #[allow(clippy::disallowed_methods)]
276 // INVARIANT: actor_did is an IdentityDID from storage, always valid
277 let canonical_actor = CanonicalDid::new_unchecked(actor_did.as_str());
278
279 let content = EntryContent {
280 entry_type: EntryType::NamespaceDelegate,
281 body: EntryBody::NamespaceDelegate {
282 ecosystem: cmd.ecosystem.clone(),
283 package_name: cmd.package_name.clone(),
284 delegate_did: delegate_identity,
285 },
286 actor_did: canonical_actor,
287 };
288
289 build_and_sign_entry(&content, signer, passphrase_provider, signer_alias)
290}
291
292/// Build and sign a `NamespaceTransfer` entry.
293///
294/// Creates an `EntryContent` with a `NamespaceTransfer` body, canonicalizes
295/// it, and signs it with the caller's key.
296///
297/// Args:
298/// * `cmd`: The transfer command with ecosystem, package name, new owner DID, and registry URL.
299/// * `actor_did`: The DID of the current namespace owner.
300/// * `signer`: Signing backend for creating the cryptographic signature.
301/// * `passphrase_provider`: Provider for obtaining key decryption passphrases.
302/// * `signer_alias`: Keychain alias of the signing key.
303///
304/// Usage:
305/// ```ignore
306/// let signed = sign_namespace_transfer(cmd, &actor_did, &signer, provider, &alias)?;
307/// ```
308pub fn sign_namespace_transfer(
309 cmd: &TransferNamespaceCommand,
310 actor_did: &IdentityDID,
311 signer: &dyn SecureSigner,
312 passphrase_provider: &dyn PassphraseProvider,
313 signer_alias: &KeyAlias,
314) -> Result<SignedEntry, NamespaceError> {
315 validate_ecosystem(&cmd.ecosystem)?;
316 validate_package_name(&cmd.package_name)?;
317
318 let new_owner_identity = IdentityDID::parse(&cmd.new_owner_did)
319 .map_err(|e| NamespaceError::InvalidDid(e.to_string()))?;
320
321 #[allow(clippy::disallowed_methods)]
322 // INVARIANT: actor_did is an IdentityDID from storage, always valid
323 let canonical_actor = CanonicalDid::new_unchecked(actor_did.as_str());
324
325 let content = EntryContent {
326 entry_type: EntryType::NamespaceTransfer,
327 body: EntryBody::NamespaceTransfer {
328 ecosystem: cmd.ecosystem.clone(),
329 package_name: cmd.package_name.clone(),
330 new_owner_did: new_owner_identity,
331 },
332 actor_did: canonical_actor,
333 };
334
335 build_and_sign_entry(&content, signer, passphrase_provider, signer_alias)
336}
337
338/// Parse a registry response JSON into a [`NamespaceClaimResult`].
339///
340/// Args:
341/// * `ecosystem`: The claimed ecosystem.
342/// * `package_name`: The claimed package name.
343/// * `owner_did`: DID of the claiming identity.
344/// * `response`: The JSON response body from the registry.
345///
346/// Usage:
347/// ```ignore
348/// let result = parse_claim_response("npm", "pkg", "did:keri:E...", &response_json)?;
349/// ```
350pub fn parse_claim_response(
351 ecosystem: &str,
352 package_name: &str,
353 owner_did: &str,
354 response: &serde_json::Value,
355) -> NamespaceClaimResult {
356 let log_sequence: u128 = response
357 .get("sequence")
358 .and_then(|v| v.as_u64())
359 .map(u128::from)
360 .unwrap_or(0);
361
362 NamespaceClaimResult {
363 ecosystem: ecosystem.to_string(),
364 package_name: package_name.to_string(),
365 owner_did: owner_did.to_string(),
366 log_sequence,
367 }
368}
369
370/// Parse a registry lookup response JSON into a [`NamespaceInfo`].
371///
372/// Args:
373/// * `ecosystem`: The queried ecosystem.
374/// * `package_name`: The queried package name.
375/// * `body`: The JSON response body from the registry.
376///
377/// Usage:
378/// ```ignore
379/// let info = parse_lookup_response("npm", "pkg", &response_json);
380/// ```
381pub fn parse_lookup_response(
382 ecosystem: &str,
383 package_name: &str,
384 body: &serde_json::Value,
385) -> NamespaceInfo {
386 let owner_did = body
387 .get("owner_did")
388 .and_then(|v| v.as_str())
389 .unwrap_or_default()
390 .to_string();
391
392 let delegates = body
393 .get("delegates")
394 .and_then(|v| v.as_array())
395 .map(|arr| {
396 arr.iter()
397 .filter_map(|v| v.as_str().map(String::from))
398 .collect()
399 })
400 .unwrap_or_default();
401
402 NamespaceInfo {
403 ecosystem: ecosystem.to_string(),
404 package_name: package_name.to_string(),
405 owner_did,
406 delegates,
407 }
408}
409
410/// An in-progress namespace verification session.
411///
412/// Returned by [`initiate_namespace_claim`]. The CLI displays the challenge
413/// instructions, waits for user confirmation, then calls [`complete`](Self::complete).
414///
415/// Usage:
416/// ```ignore
417/// let session = initiate_namespace_claim(&verifier, eco, pkg, did, platform).await?;
418/// println!("{}", session.challenge.instructions);
419/// // wait for user...
420/// let result = session.complete(&verifier, &signer, &passphrase, &alias).await?;
421/// ```
422pub struct NamespaceVerificationSession {
423 /// The verification challenge issued by the adapter.
424 pub challenge: VerificationChallenge,
425 /// The ecosystem being verified.
426 pub ecosystem: Ecosystem,
427 /// The package being claimed.
428 pub package_name: PackageName,
429 /// The DID claiming ownership.
430 pub controller_did: CanonicalDid,
431 /// Platform identity context for cross-referencing.
432 pub platform: PlatformContext,
433}
434
435impl NamespaceVerificationSession {
436 /// Complete the verification after the user has performed the challenge.
437 ///
438 /// Borrows `self` so the caller can retry on transient failures.
439 /// Calls `verifier.verify()`, then signs the namespace claim entry
440 /// with the proof attached.
441 ///
442 /// Args:
443 /// * `now`: Current time (injected at presentation boundary).
444 /// * `verifier`: The namespace verifier adapter.
445 /// * `signer`: Signing backend for creating the cryptographic signature.
446 /// * `passphrase_provider`: Provider for obtaining key decryption passphrases.
447 /// * `signer_alias`: Keychain alias of the signing key.
448 pub async fn complete_ref(
449 &self,
450 now: DateTime<Utc>,
451 verifier: &dyn NamespaceVerifier,
452 signer: &dyn SecureSigner,
453 passphrase_provider: &dyn PassphraseProvider,
454 signer_alias: &KeyAlias,
455 ) -> Result<VerifiedClaimResult, NamespaceError> {
456 let proof = verifier
457 .verify(
458 now,
459 &self.package_name,
460 &self.controller_did,
461 &self.platform,
462 &self.challenge,
463 )
464 .await?;
465
466 let content = EntryContent {
467 entry_type: EntryType::NamespaceClaim,
468 body: EntryBody::NamespaceClaim {
469 ecosystem: self.ecosystem.as_str().to_string(),
470 package_name: self.package_name.as_str().to_string(),
471 proof_url: proof.proof_url.to_string(),
472 verification_method: format!("{:?}", proof.method),
473 },
474 actor_did: self.controller_did.clone(),
475 };
476
477 let signed = build_and_sign_entry(&content, signer, passphrase_provider, signer_alias)?;
478
479 Ok(VerifiedClaimResult {
480 signed_entry: signed,
481 proof,
482 })
483 }
484}
485
486/// Result of a successful verified namespace claim.
487pub struct VerifiedClaimResult {
488 /// The signed entry ready for submission to the registry.
489 pub signed_entry: SignedEntry,
490 /// The ownership proof from the verifier adapter.
491 pub proof: NamespaceOwnershipProof,
492}
493
494/// Phase 1: Initiate namespace verification.
495///
496/// Returns a [`NamespaceVerificationSession`] that the CLI can display
497/// (challenge instructions), then complete after user action.
498///
499/// Args:
500/// * `now`: Current time (injected at presentation boundary).
501/// * `verifier`: The namespace verifier adapter for the target ecosystem.
502/// * `ecosystem`: The ecosystem being claimed.
503/// * `package_name`: The package to claim.
504/// * `controller_did`: The DID making the claim.
505/// * `platform`: Verified platform identity context.
506///
507/// Usage:
508/// ```ignore
509/// let session = initiate_namespace_claim(now, &verifier, eco, pkg, did, ctx).await?;
510/// ```
511pub async fn initiate_namespace_claim(
512 now: DateTime<Utc>,
513 verifier: &dyn NamespaceVerifier,
514 ecosystem: Ecosystem,
515 package_name: PackageName,
516 controller_did: CanonicalDid,
517 platform: PlatformContext,
518) -> Result<NamespaceVerificationSession, NamespaceError> {
519 let challenge = verifier
520 .initiate(now, &package_name, &controller_did, &platform)
521 .await?;
522
523 Ok(NamespaceVerificationSession {
524 challenge,
525 ecosystem,
526 package_name,
527 controller_did,
528 platform,
529 })
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::testing::fakes::FakeSecureSigner;
536 use auths_core::PrefilledPassphraseProvider;
537
538 fn actor() -> IdentityDID {
539 IdentityDID::parse("did:keri:EActor").unwrap()
540 }
541
542 #[test]
543 fn sign_namespace_delegate_rejects_malformed_delegate_did() {
544 let cmd = DelegateNamespaceCommand {
545 ecosystem: "npm".into(),
546 package_name: "pkg".into(),
547 delegate_did: "not-a-did".into(),
548 registry_url: "https://registry.example.com".into(),
549 };
550 let result = sign_namespace_delegate(
551 &cmd,
552 &actor(),
553 &FakeSecureSigner,
554 &PrefilledPassphraseProvider::new(""),
555 &KeyAlias::new_unchecked("alias"),
556 );
557 assert!(matches!(result, Err(NamespaceError::InvalidDid(_))));
558 }
559
560 #[test]
561 fn sign_namespace_transfer_rejects_malformed_new_owner_did() {
562 let cmd = TransferNamespaceCommand {
563 ecosystem: "npm".into(),
564 package_name: "pkg".into(),
565 new_owner_did: "did:web:example.com".into(),
566 registry_url: "https://registry.example.com".into(),
567 };
568 let result = sign_namespace_transfer(
569 &cmd,
570 &actor(),
571 &FakeSecureSigner,
572 &PrefilledPassphraseProvider::new(""),
573 &KeyAlias::new_unchecked("alias"),
574 );
575 assert!(matches!(result, Err(NamespaceError::InvalidDid(_))));
576 }
577}