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 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}