Skip to main content

auths_cli/commands/
auth.rs

1use anyhow::{Context, Result, anyhow};
2use clap::{Parser, Subcommand};
3
4use auths_sdk::storage_layout::layout;
5
6use crate::factories::storage::build_auths_context;
7
8use crate::commands::executable::ExecutableCommand;
9use crate::config::CliConfig;
10use crate::ux::format::{JsonResponse, is_json_mode};
11
12/// Authenticate with external services using your auths identity.
13#[derive(Parser, Debug, Clone)]
14#[command(
15    about = "Authenticate with external services using your auths identity",
16    after_help = "Examples:
17  auths auth challenge --nonce abc123def456 --domain example.com
18                        # Sign an authentication challenge
19  auths auth challenge --nonce abc123def456
20                        # Sign challenge for default domain (auths.dev)
21  auths auth verify --nonce abc123def456 --did did:keri:E... --signature <hex>
22                        # Verify a challenge response offline, against the
23                        # registry's current key for that DID
24
25Flow:
26  1. Verifier sends a nonce
27  2. Responder runs: auths auth challenge --nonce <nonce> --domain <domain>
28  3. Verifier runs:  auths auth verify --nonce <nonce> --domain <domain> \\
29                       --did <did> --signature <hex>
30     (offline — the signature is checked against the registry's in-force
31      key, never a key the responder supplied)
32
33Related:
34  auths id     — Manage your identity
35  auths sign   — Sign files and commits
36  auths verify — Verify signatures"
37)]
38pub struct AuthCommand {
39    #[clap(subcommand)]
40    pub subcommand: AuthSubcommand,
41}
42
43/// Subcommands for authentication operations.
44#[derive(Subcommand, Debug, Clone)]
45pub enum AuthSubcommand {
46    /// Sign an authentication challenge for DID-based login
47    Challenge {
48        /// The challenge nonce from the authentication server
49        #[arg(long, allow_hyphen_values = true)]
50        nonce: String,
51
52        /// The domain requesting authentication
53        #[arg(long, default_value = "auths.dev")]
54        domain: String,
55    },
56
57    /// Verify a challenge response offline against the registry's current key
58    Verify {
59        /// The challenge nonce this verifier issued
60        #[arg(long, allow_hyphen_values = true)]
61        nonce: String,
62
63        /// The domain the challenge was bound to
64        #[arg(long, default_value = "auths.dev")]
65        domain: String,
66
67        /// The responder's did:keri: controller DID (delegated device DIDs
68        /// fail closed — their liveness needs the delegator's revocation
69        /// verdict)
70        #[arg(long)]
71        did: String,
72
73        /// Hex-encoded signature from the challenge response
74        #[arg(long)]
75        signature: String,
76    },
77}
78
79fn handle_auth_challenge(nonce: &str, domain: &str, ctx: &CliConfig) -> Result<()> {
80    let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
81    let passphrase_provider = ctx.passphrase_provider.clone();
82
83    let auths_ctx = build_auths_context(
84        &repo_path,
85        &ctx.env_config,
86        Some(ctx.passphrase_provider.clone()),
87    )?;
88    let managed = auths_ctx
89        .identity_storage
90        .load_identity()
91        .context("No identity found. Run `auths init` first.")?;
92
93    let controller_did = &managed.controller_did;
94
95    let key_alias_str =
96        super::key_detect::auto_detect_device_key(ctx.repo_path.as_deref(), &ctx.env_config)?;
97    let key_alias = auths_sdk::keychain::KeyAlias::new(&key_alias_str)
98        .map_err(|e| anyhow!("Invalid key alias: {e}"))?;
99
100    let message = auths_sdk::workflows::auth::build_auth_challenge_message(nonce, domain)
101        .context("Failed to build auth challenge payload")?;
102
103    let (signature_bytes, public_key_bytes, _curve) = auths_sdk::keychain::sign_with_key(
104        auths_ctx.key_storage.as_ref(),
105        &key_alias,
106        passphrase_provider.as_ref(),
107        message.as_bytes(),
108    )
109    .with_context(|| format!("Failed to sign auth challenge with key '{}'", key_alias))?;
110
111    let result = auths_sdk::workflows::auth::SignedAuthChallenge {
112        signature_hex: hex::encode(&signature_bytes),
113        public_key_hex: hex::encode(&public_key_bytes),
114        did: controller_did.to_string(),
115    };
116
117    if is_json_mode() {
118        JsonResponse::success(
119            "auth challenge",
120            &serde_json::json!({
121                "signature": result.signature_hex,
122                "public_key": result.public_key_hex,
123                "did": result.did,
124            }),
125        )
126        .print()
127        .map_err(anyhow::Error::from)
128    } else {
129        println!("Signature:  {}", result.signature_hex);
130        println!("Public Key: {}", result.public_key_hex);
131        println!("DID:        {}", result.did);
132        Ok(())
133    }
134}
135
136/// Verify a challenge response offline against the registry's in-force key.
137///
138/// The verifier-side counterpart of `auth challenge`: no auth server, no
139/// network. The DID's KEL is replayed from the local registry and the
140/// signature is checked against its *current* key — a response signed by a
141/// stale pre-rotation key or a stolen device key fails, a response signed by
142/// the identity's in-force key proves it alive.
143fn handle_auth_verify(nonce: &str, domain: &str, did: &str, signature_hex: &str) -> Result<()> {
144    let signature = hex::decode(signature_hex)
145        .context("--signature must be the hex string printed by `auths auth challenge`")?;
146
147    let auths_home = auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))?;
148    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
149        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
150    );
151
152    let verified = auths_sdk::workflows::auth::verify_auth_challenge(
153        &registry, did, nonce, domain, &signature,
154    )
155    .context("Auth challenge verification failed")?;
156
157    if is_json_mode() {
158        JsonResponse::success(
159            "auth verify",
160            &serde_json::json!({
161                "verified": true,
162                "did": verified.did,
163                "public_key": verified.public_key_hex,
164                "curve": verified.curve.to_string(),
165                "nonce": nonce,
166                "domain": domain,
167            }),
168        )
169        .print()
170        .map_err(anyhow::Error::from)
171    } else {
172        println!("✓ Verified — the signature checks out under the registry's current key");
173        println!("DID:        {}", verified.did);
174        println!("Public Key: {}", verified.public_key_hex);
175        println!("Curve:      {}", verified.curve);
176        println!("Domain:     {domain}");
177        Ok(())
178    }
179}
180
181impl ExecutableCommand for AuthCommand {
182    fn execute(&self, ctx: &CliConfig) -> Result<()> {
183        match &self.subcommand {
184            AuthSubcommand::Challenge { nonce, domain } => {
185                handle_auth_challenge(nonce, domain, ctx)
186            }
187            AuthSubcommand::Verify {
188                nonce,
189                domain,
190                did,
191                signature,
192            } => handle_auth_verify(nonce, domain, did, signature),
193        }
194    }
195}