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