Skip to main content

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}