Skip to main content

heddle_client/grpc_hosted/
session.rs

1//! Hosted-session open seam.
2//!
3//! "Open a usable hosted session for a remote" used to be hand-assembled at
4//! every hosted entry point (push, pull, fetch, clone, support, approval,
5//! lazy hydration): resolve the auth token, fall back to the credential
6//! store, attach the proof key, build the validated client config, connect,
7//! then rotate only an eligible stored authority credential. This module owns
8//! that assembly so the command modules choose only intent + remote and call
9//! one seam.
10//!
11//! # Single credential resolution order
12//!
13//! [`resolve_hosted_credential`] is the ONE precedence every hosted entry
14//! point follows:
15//!
16//! 1. `HEDDLE_CREDENTIAL=<path>` — a path to a `.hcred`. When set it is
17//!    AUTHORITATIVE: an unreadable / expired / verify-failed / server-mismatch
18//!    file is a hard error, never a silent fall-through to the keystore (a
19//!    silent fallback would let an agent push as the human). Loaded through the
20//!    verifying [`crate::credential_file::load_credential_file`] chokepoint.
21//! 2. The per-server keystore entry (`resolve_credential_for_server`).
22//! 3. Unauthenticated.
23
24use std::{net::SocketAddr, path::PathBuf};
25
26use anyhow::{Context, Result};
27use cli_shared::{ClientConfig, UserConfig};
28use crypto::{Ed25519Signer, Signer};
29use tonic::transport::Channel;
30use wire::{AuthToken, ProtocolError};
31
32use crate::{
33    credentials,
34    grpc_hosted::{HostedGrpcClient, RenewableAuthorityCredential},
35};
36
37/// Environment variable naming a `.hcred` path the runtime authenticates with.
38const HEDDLE_CREDENTIAL_ENV: &str = "HEDDLE_CREDENTIAL";
39
40/// Where a resolved hosted credential originated. Reported by `auth status`.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CredentialSource {
43    /// The `.hcred` at `HEDDLE_CREDENTIAL=<path>`.
44    Env(PathBuf),
45    /// The per-server entry in the keystore (`credentials.toml`).
46    Keystore,
47    /// No credential resolved for this server.
48    Unauthenticated,
49}
50
51impl CredentialSource {
52    /// Stable label for the `auth status` `source` field.
53    pub fn label(&self) -> String {
54        match self {
55            CredentialSource::Env(path) => format!("env:{}", path.display()),
56            CredentialSource::Keystore => "keystore".to_string(),
57            CredentialSource::Unauthenticated => "none".to_string(),
58        }
59    }
60}
61
62/// A credential resolved by the single hosted-auth precedence. Fully describes
63/// the selected bearer so callers never re-read env/keystore themselves.
64pub struct ResolvedHostedCredential {
65    /// Bearer token, absent when unauthenticated.
66    pub token: Option<AuthToken>,
67    /// Device proof key PEM bound to `token`, when the source carries one.
68    pub proof_key_pem: Option<String>,
69    /// Renewal binding — populated ONLY for a keystore authority credential.
70    /// An env `.hcred` is never renewed (we cannot rewrite the pointed-at file
71    /// and must not touch the keystore on its behalf). Crate-internal: the
72    /// renewal type is module-private and only [`HostedSession::build`]
73    /// consumes it.
74    pub(crate) renewable: Option<RenewableAuthorityCredential>,
75    /// Authenticated subject.
76    pub subject: Option<String>,
77    /// Authority credential id, when present (rotation anchor).
78    pub credential_id: Option<String>,
79    /// RFC 3339 expiry, when the credential carries one.
80    pub expires_at: Option<String>,
81    /// Which mechanism produced this credential.
82    pub source: CredentialSource,
83}
84
85impl std::fmt::Debug for ResolvedHostedCredential {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        // Redact the secret bearer + proof key so a resolved credential can
88        // never leak them into logs, tracing, or a test panic message.
89        f.debug_struct("ResolvedHostedCredential")
90            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
91            .field("proof_key_pem", &self.proof_key_pem.as_ref().map(|_| "<redacted>"))
92            .field("renewable", &self.renewable.is_some())
93            .field("subject", &self.subject)
94            .field("credential_id", &self.credential_id)
95            .field("expires_at", &self.expires_at)
96            .field("source", &self.source)
97            .finish()
98    }
99}
100
101/// Read + validate `HEDDLE_CREDENTIAL`. Returns the `.hcred` path when the
102/// variable is set to a non-empty value. Guards against a caller passing
103/// credential *contents* instead of a path — a path never starts with `{`
104/// (the `.hcred` JSON object) and never contains a newline.
105fn heddle_credential_env_path() -> Result<Option<PathBuf>> {
106    match std::env::var(HEDDLE_CREDENTIAL_ENV) {
107        Ok(value) => {
108            if value.is_empty() {
109                // Fail closed rather than fall through to the keystore: a set-but-empty
110                // value is almost always a failed shell expansion (e.g.
111                // `export HEDDLE_CREDENTIAL=$UNSET`), and silently authenticating as
112                // the stored (human) identity is exactly the confusion this resolver
113                // exists to prevent.
114                anyhow::bail!(
115                    "HEDDLE_CREDENTIAL is set but empty; unset it to use the stored \
116                     credential, or point it at a .hcred file"
117                );
118            }
119            if value.starts_with('{') || value.contains('\n') {
120                anyhow::bail!(
121                    "HEDDLE_CREDENTIAL takes a file path, not credential contents"
122                );
123            }
124            Ok(Some(PathBuf::from(value)))
125        }
126        Err(std::env::VarError::NotPresent) => Ok(None),
127        Err(err @ std::env::VarError::NotUnicode(_)) => {
128            anyhow::bail!("HEDDLE_CREDENTIAL is not valid UTF-8: {err}")
129        }
130    }
131}
132
133/// Resolve the hosted credential for `server_key` following the single
134/// precedence order documented on this module.
135///
136/// `HEDDLE_CREDENTIAL`, when set, is authoritative: any failure to load, verify,
137/// or match it to `server_key` is a hard error rather than a silent fall-through
138/// to the keystore.
139pub fn resolve_hosted_credential(server_key: Option<&str>) -> Result<ResolvedHostedCredential> {
140    if let Some(path) = heddle_credential_env_path()? {
141        let verified = crate::credential_file::load_credential_file(&path)
142            .with_context(|| format!("loading HEDDLE_CREDENTIAL {}", path.display()))?;
143        if let Some(target) = server_key
144            && !server_keys_match(&verified.server, target)
145        {
146            anyhow::bail!(
147                "HEDDLE_CREDENTIAL {} authenticates server {:?}, but this operation targets {:?}; \
148                 point HEDDLE_CREDENTIAL at a credential minted for {}",
149                path.display(),
150                verified.server,
151                target,
152                target,
153            );
154        }
155        return Ok(ResolvedHostedCredential {
156            token: Some(AuthToken::new(verified.token, "hcred-env")),
157            proof_key_pem: Some(verified.proof_key_pem),
158            renewable: None,
159            subject: Some(verified.subject),
160            credential_id: verified.credential_id,
161            expires_at: verified.expires_at,
162            source: CredentialSource::Env(path),
163        });
164    }
165
166    if let Some(key) = server_key {
167        // Propagate a malformed credentials.toml instead of swallowing it: a
168        // parse error here used to fall through to an unauthenticated request,
169        // which the server rejects with an opaque "missing authorization
170        // metadata" — hiding the real cause. A *missing* file returns Ok(None)
171        // and falls through to unauthenticated cleanly.
172        if let Some(cred) = credentials::resolve_credential_for_server(key)? {
173            let renewable = RenewableAuthorityCredential::from_stored(&cred);
174            return Ok(ResolvedHostedCredential {
175                token: Some(AuthToken::new(cred.token, "credential-store")),
176                proof_key_pem: cred.private_key_pem,
177                renewable,
178                subject: Some(cred.subject),
179                credential_id: cred.credential_id,
180                expires_at: cred.expires_at,
181                source: CredentialSource::Keystore,
182            });
183        }
184    }
185
186    Ok(ResolvedHostedCredential {
187        token: None,
188        proof_key_pem: None,
189        renewable: None,
190        subject: None,
191        credential_id: None,
192        expires_at: None,
193        source: CredentialSource::Unauthenticated,
194    })
195}
196
197/// Resolve the locally active bearer token, if any, using the default server
198/// for keystore lookup. For read/display paths that need the active token
199/// without a specific target remote. Still honors `HEDDLE_CREDENTIAL` first.
200pub fn resolve_active_bearer() -> Result<Option<AuthToken>> {
201    let server = credentials::default_server()?;
202    Ok(resolve_hosted_credential(server.as_deref())?.token)
203}
204
205/// A validated, connectable hosted-session configuration.
206///
207/// Building runs the fallible TLS/auth validation up front, so callers that
208/// must not leave partial on-disk artifacts (clone, push) — or that build the
209/// config on one thread and connect on another (lazy hydration) — can
210/// prevalidate before any irreversible work, then `connect()` afterwards.
211/// Callers with no such ordering constraint use
212/// [`HostedGrpcClient::open_session`], which builds and connects in one call.
213pub struct HostedSession {
214    config: ClientConfig,
215    renewable_authority_credential: Option<RenewableAuthorityCredential>,
216}
217
218impl HostedSession {
219    /// Build a session from the exact stored authority credential selected by
220    /// an auth command. Unlike `CredentialFallback`, configured/env tokens
221    /// cannot replace the selected bearer. The matching device proof key is
222    /// required and validated before the command performs any mutation.
223    pub fn build_stored_credential(user_config: &UserConfig, server_key: &str) -> Result<Self> {
224        let credential = credentials::get_server_credential(server_key)?.ok_or_else(|| {
225            anyhow::anyhow!(weft_client_shim::HostedRecoveryAdvice::auth_required(
226                server_key
227            ))
228        })?;
229        let proof_key = validated_stored_proof_key(&credential, server_key)?;
230        let authenticated_principal = validated_authenticated_principal(&credential)?;
231        let renewable_authority_credential = RenewableAuthorityCredential::from_stored(&credential);
232        let token = AuthToken::new(credential.token, "credential-store");
233        let mut config = user_config.heddle_client_config(Some(token))?;
234        config = config
235            .with_server_key(server_key.to_string())
236            .with_auth_proof_key_pem(proof_key)
237            .with_authenticated_principal(authenticated_principal);
238        Ok(Self {
239            config,
240            renewable_authority_credential,
241        })
242    }
243
244    /// Resolve auth + build the validated client config for a hosted session.
245    /// Runs the single [`resolve_hosted_credential`] precedence
246    /// (`HEDDLE_CREDENTIAL` → keystore → unauthenticated), server-key
247    /// attachment, and proof-key attachment from either the resolved credential
248    /// or the matching shared same-host device identity — the assembly the
249    /// command modules used to hand-roll.
250    pub fn build(user_config: &UserConfig, server_key: Option<String>) -> Result<Self> {
251        let ResolvedHostedCredential {
252            token,
253            proof_key_pem: mut credential_proof_key,
254            renewable: renewable_authority_credential,
255            subject: resolved_credential_subject,
256            ..
257        } = resolve_hosted_credential(server_key.as_deref())?;
258
259        if credential_proof_key.is_none()
260            && let Some(ref key) = server_key
261            && let Some(token) = token.as_ref()
262        {
263            credential_proof_key = shared_device_proof_key(key, &token.id)?;
264        }
265
266        let mut config = user_config.heddle_client_config(token)?;
267        if let Some(key) = server_key {
268            config = config.with_server_key(key);
269        }
270        if let Some(pem) = credential_proof_key
271            && config.auth_proof_key_pem.is_none()
272        {
273            config = config.with_auth_proof_key_pem(pem);
274        }
275        if config.auth_proof_key_pem.is_some() {
276            let token = config.token.as_ref().ok_or_else(|| {
277                anyhow::anyhow!(
278                    "hosted request signing has a proof key but no authenticated bearer token"
279                )
280            })?;
281            let subject = crate::device_flow::authenticated_subject(&token.id)
282                .context("reading the hosted bearer token's authenticated principal")?;
283            if resolved_credential_subject
284                .as_deref()
285                .is_some_and(|resolved| resolved != subject.as_str())
286            {
287                anyhow::bail!(
288                    "resolved credential subject does not match the bearer token's authenticated principal"
289                );
290            }
291            config = config.with_authenticated_principal(format!("principal:{subject}"));
292        }
293        Ok(Self {
294            config,
295            renewable_authority_credential,
296        })
297    }
298
299    /// Explicitly allow cleartext to non-loopback hosts for this session
300    /// (CLI `--insecure` or remote `insecure = true`). Does not disable an
301    /// allow already set via user config / env.
302    pub fn with_allow_insecure(mut self, allow: bool) -> Self {
303        if allow {
304            self.config.allow_insecure = true;
305        }
306        self
307    }
308
309    /// Connect and evaluate renewal for an eligible stored authority token.
310    ///
311    /// The eligibility check runs immediately after connect and requires the
312    /// active bearer to remain byte-for-byte identical to the stored one-block
313    /// authority credential selected during [`HostedSession::build`]. Explicit
314    /// config/env and attenuated tokens are never rotated here.
315    pub async fn connect(&self, addr: SocketAddr) -> Result<HostedGrpcClient, ProtocolError> {
316        let mut client = HostedGrpcClient::connect(addr, &self.config).await?;
317        client
318            .auto_rotate_if_needed(self.renewable_authority_credential.as_ref())
319            .await;
320        Ok(client)
321    }
322
323    /// Finish a validated session over a channel whose URI/TLS policy was
324    /// already established by the auth command's endpoint connector.
325    pub async fn connect_channel(
326        &self,
327        channel: Channel,
328    ) -> Result<HostedGrpcClient, ProtocolError> {
329        let mut client = HostedGrpcClient::from_channel(channel, &self.config)?;
330        client
331            .auto_rotate_if_needed(self.renewable_authority_credential.as_ref())
332            .await;
333        Ok(client)
334    }
335}
336
337fn validated_authenticated_principal(credential: &credentials::ServerCredential) -> Result<String> {
338    let subject = crate::device_flow::authenticated_subject(&credential.token)
339        .context("reading the stored credential's authenticated principal")?;
340    if subject != credential.subject {
341        anyhow::bail!(
342            "stored credential subject does not match the bearer token's authenticated principal"
343        );
344    }
345    Ok(format!("principal:{subject}"))
346}
347
348fn validated_stored_proof_key(
349    credential: &credentials::ServerCredential,
350    server_key: &str,
351) -> Result<String> {
352    let pem = credential.private_key_pem.as_deref().ok_or_else(|| {
353        anyhow::anyhow!(
354            "stored credential for {server_key} has no device proof key; run `heddle auth login --server {server_key}` first"
355        )
356    })?;
357    let signer = Ed25519Signer::from_pem(pem)
358        .map_err(|error| anyhow::anyhow!("stored device proof key is invalid: {error}"))?;
359    let token_key = crate::device_flow::effective_pop_public_key_hex(&credential.token)
360        .context("reading the stored credential's effective proof key")?;
361    if !token_key.eq_ignore_ascii_case(&hex::encode(signer.public_key())) {
362        anyhow::bail!("stored device proof key does not match the credential Biscuit");
363    }
364    Ok(pem.to_string())
365}
366
367fn shared_device_proof_key(server_key: &str, token: &str) -> Result<Option<String>> {
368    let identity = repo::identity::load_device(&repo::identity::device_identity_path())
369        .context("loading this host's shared device identity")?;
370    let Some(identity) = identity else {
371        return Ok(None);
372    };
373    if !server_keys_match(&identity.server, server_key)
374        || !token_proof_key_matches(token, &identity.public_key)
375    {
376        return Ok(None);
377    }
378    Ok(Some(identity.private_key_pem))
379}
380
381fn token_proof_key_matches(token: &str, expected_public_key_hex: &str) -> bool {
382    crate::device_flow::effective_pop_public_key_hex(token)
383        .is_ok_and(|proof_key| proof_key.eq_ignore_ascii_case(expected_public_key_hex))
384}
385
386fn server_keys_match(left: &str, right: &str) -> bool {
387    fn without_scheme(value: &str) -> &str {
388        value
389            .strip_prefix("http://")
390            .or_else(|| value.strip_prefix("https://"))
391            .or_else(|| value.strip_prefix("heddle://"))
392            .unwrap_or(value)
393    }
394
395    without_scheme(left) == without_scheme(right)
396}
397
398impl HostedGrpcClient {
399    /// Open a usable hosted session in one call: resolve auth, build the
400    /// validated client config, connect, and evaluate eligible stored-authority
401    /// renewal. Callers that must prevalidate the config before irreversible
402    /// work build a [`HostedSession`] first, then [`HostedSession::connect`].
403    pub async fn open_session(
404        addr: SocketAddr,
405        user_config: &UserConfig,
406        server_key: Option<String>,
407    ) -> Result<Self> {
408        Self::open_session_with_insecure(addr, user_config, server_key, false).await
409    }
410
411    /// Like [`open_session`], but allows cleartext to non-loopback when
412    /// `allow_insecure` is true (CLI `--insecure` / remote `insecure = true`).
413    pub async fn open_session_with_insecure(
414        addr: SocketAddr,
415        user_config: &UserConfig,
416        server_key: Option<String>,
417        allow_insecure: bool,
418    ) -> Result<Self> {
419        Ok(HostedSession::build(user_config, server_key)?
420            .with_allow_insecure(allow_insecure)
421            .connect(addr)
422            .await?)
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    //! The connect/renewal-eligibility invariant lives here, in the one seam
429    //! every hosted entry point opens its session through. This source-presence
430    //! guard replaces the per-call-site checks: an eligible stored authority
431    //! credential must be considered immediately after connect, while explicit
432    //! and attenuated tokens carry no renewal binding.
433
434    use biscuit_auth::{Biscuit, KeyPair};
435    use crypto::{Ed25519Signer, Signer};
436
437    use super::{HostedSession, validated_authenticated_principal, validated_stored_proof_key};
438    use crate::credentials;
439
440    fn proof_bound_credential(signer: &Ed25519Signer) -> credentials::ServerCredential {
441        let token = Biscuit::builder()
442            .fact(r#"user("alice")"#)
443            .expect("user fact")
444            .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
445            .expect("proof key fact")
446            .build(&KeyPair::new())
447            .expect("mint credential")
448            .to_base64()
449            .expect("encode credential");
450        credentials::ServerCredential {
451            token,
452            subject: "alice".to_string(),
453            device_id: Some("device-1".to_string()),
454            credential_id: None,
455            private_key_pem: Some(signer.to_pem().expect("proof PEM")),
456            expires_at: None,
457        }
458    }
459
460    #[test]
461    fn stored_session_rejects_missing_or_mismatched_proof_keys_before_connect() {
462        let signer = Ed25519Signer::generate().expect("proof signer");
463        let mut missing = proof_bound_credential(&signer);
464        missing.private_key_pem = None;
465        let error = validated_stored_proof_key(&missing, "grpc.example")
466            .expect_err("a missing proof key must fail before connect");
467        assert!(error.to_string().contains("has no device proof key"));
468
469        let wrong_signer = Ed25519Signer::generate().expect("wrong proof signer");
470        let mut mismatched = proof_bound_credential(&signer);
471        mismatched.private_key_pem = Some(wrong_signer.to_pem().expect("wrong PEM"));
472        let error = validated_stored_proof_key(&mismatched, "grpc.example")
473            .expect_err("a mismatched proof key must fail before connect");
474        assert!(error.to_string().contains("does not match"));
475    }
476
477    #[test]
478    fn stored_session_accepts_the_proof_key_bound_into_its_biscuit() {
479        let signer = Ed25519Signer::generate().expect("proof signer");
480        let credential = proof_bound_credential(&signer);
481        let pem =
482            validated_stored_proof_key(&credential, "grpc.example").expect("matching proof key");
483        assert_eq!(pem, credential.private_key_pem.unwrap());
484    }
485
486    #[test]
487    fn stored_session_rejects_a_subject_that_disagrees_with_its_biscuit() {
488        let signer = Ed25519Signer::generate().expect("proof signer");
489        let mut credential = proof_bound_credential(&signer);
490        credential.subject = "mallory".to_string();
491
492        let error = validated_authenticated_principal(&credential)
493            .expect_err("stored metadata cannot replace the authenticated token subject");
494
495        assert!(error.to_string().contains("does not match"));
496    }
497
498    #[test]
499    fn session_connect_checks_eligible_renewal_after_connect() {
500        let source = include_str!("session.rs");
501        let connect_idx = source
502            .find("HostedGrpcClient::connect(addr, &self.config)")
503            .expect("session.rs must connect with the resolved addr");
504        let after_connect = &source[connect_idx..];
505        let rotate_offset = after_connect
506            .find("auto_rotate_if_needed")
507            .expect("auto_rotate_if_needed must appear in session.rs");
508        assert!(
509            rotate_offset < 400,
510            "auto_rotate_if_needed must follow HostedGrpcClient::connect within the \
511             same async block (found {rotate_offset} chars later)",
512        );
513    }
514
515    /// Mint a device-authority token whose effective PoP key is `signer`.
516    fn mint_authority_token(subject: &str, signer: &Ed25519Signer) -> String {
517        biscuit_auth::Biscuit::builder()
518            .fact(format!("user(\"{subject}\")").as_str())
519            .expect("user fact")
520            .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
521            .expect("proof key fact")
522            .build(&biscuit_auth::KeyPair::new())
523            .expect("mint token")
524            .to_base64()
525            .expect("encode token")
526    }
527
528    /// Write a verifiable `.hcred` at `path` for `server`, minting a fresh
529    /// device token bound to a fresh proof key.
530    fn write_sample_hcred(path: &std::path::Path, server: &str, subject: &str) {
531        let signer = Ed25519Signer::generate().expect("proof key");
532        let token = mint_authority_token(subject, &signer);
533        crate::credential_file::write_credential_file(
534            path,
535            &crate::credential_file::VerifiedCredential {
536                server: server.to_string(),
537                kind: crate::credential_file::CredentialKind::Device,
538                subject: subject.to_string(),
539                token,
540                proof_key_pem: signer.to_pem().expect("proof PEM"),
541                expires_at: None,
542                credential_id: None,
543                provenance: None,
544            },
545        )
546        .expect("write sample .hcred");
547    }
548
549    /// Run `f` with `HEDDLE_HOME` at a fresh temp dir and `HEDDLE_CREDENTIAL`
550    /// cleared, restoring both afterward. Serialized via the credentials lock.
551    fn with_isolated_env<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
552        let _guard = crate::credentials::lock_test_env();
553        let home = tempfile::TempDir::new().expect("temp Heddle home");
554        let previous_home = std::env::var_os("HEDDLE_HOME");
555        let previous_credential = std::env::var_os("HEDDLE_CREDENTIAL");
556        unsafe {
557            std::env::set_var("HEDDLE_HOME", home.path());
558            std::env::remove_var("HEDDLE_CREDENTIAL");
559        }
560        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(home.path())));
561        unsafe {
562            match previous_home {
563                Some(path) => std::env::set_var("HEDDLE_HOME", path),
564                None => std::env::remove_var("HEDDLE_HOME"),
565            }
566            match previous_credential {
567                Some(path) => std::env::set_var("HEDDLE_CREDENTIAL", path),
568                None => std::env::remove_var("HEDDLE_CREDENTIAL"),
569            }
570        }
571        match result {
572            Ok(value) => value,
573            Err(payload) => std::panic::resume_unwind(payload),
574        }
575    }
576
577    #[test]
578    fn env_credential_resolves_and_reports_env_source() {
579        use super::{CredentialSource, resolve_hosted_credential};
580        with_isolated_env(|home| {
581            let path = home.join("agent.hcred");
582            write_sample_hcred(&path, "grpc.heddle.test", "alice");
583            unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &path) };
584
585            let resolved =
586                resolve_hosted_credential(Some("grpc.heddle.test")).expect("resolve env credential");
587            assert!(resolved.token.is_some(), "env credential is authoritative");
588            assert!(resolved.proof_key_pem.is_some(), "env .hcred carries its key");
589            assert!(
590                resolved.renewable.is_none(),
591                "an env credential is never renewed"
592            );
593            assert_eq!(resolved.subject.as_deref(), Some("alice"));
594            assert_eq!(resolved.source, CredentialSource::Env(path));
595        });
596    }
597
598    #[test]
599    fn env_credential_inline_contents_are_rejected() {
600        use super::resolve_hosted_credential;
601        with_isolated_env(|_home| {
602            unsafe {
603                std::env::set_var("HEDDLE_CREDENTIAL", "{\"format\":\"heddle-credential\"}");
604            }
605            let error = resolve_hosted_credential(Some("grpc.heddle.test"))
606                .expect_err("inline contents must be rejected");
607            assert!(
608                error.to_string().contains("takes a file path"),
609                "unexpected error: {error}"
610            );
611        });
612    }
613
614    #[test]
615    fn env_credential_server_mismatch_is_a_hard_error_with_no_keystore_fallback() {
616        use super::resolve_hosted_credential;
617        with_isolated_env(|home| {
618            // A perfectly good keystore credential exists for the target
619            // server. It must NOT be used to paper over a mismatched
620            // HEDDLE_CREDENTIAL — silent fallback would let an agent push as
621            // the human.
622            credentials::store_server_credential(
623                "grpc.target.test",
624                credentials::ServerCredential {
625                    token: "keystore-token".to_string(),
626                    subject: "human".to_string(),
627                    device_id: None,
628                    credential_id: None,
629                    private_key_pem: None,
630                    expires_at: None,
631                },
632            )
633            .expect("seed keystore");
634
635            let path = home.join("other.hcred");
636            write_sample_hcred(&path, "grpc.other.test", "agent");
637            unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &path) };
638
639            let error = resolve_hosted_credential(Some("grpc.target.test"))
640                .expect_err("server mismatch must be a hard error");
641            let message = error.to_string();
642            assert!(message.contains("grpc.other.test"), "message: {message}");
643            assert!(message.contains("grpc.target.test"), "message: {message}");
644        });
645    }
646
647    #[test]
648    fn env_credential_unreadable_is_a_hard_error_with_no_keystore_fallback() {
649        use super::resolve_hosted_credential;
650        with_isolated_env(|home| {
651            credentials::store_server_credential(
652                "grpc.target.test",
653                credentials::ServerCredential {
654                    token: "keystore-token".to_string(),
655                    subject: "human".to_string(),
656                    device_id: None,
657                    credential_id: None,
658                    private_key_pem: None,
659                    expires_at: None,
660                },
661            )
662            .expect("seed keystore");
663
664            let missing = home.join("does-not-exist.hcred");
665            unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &missing) };
666
667            resolve_hosted_credential(Some("grpc.target.test"))
668                .expect_err("an unreadable HEDDLE_CREDENTIAL must never fall back to the keystore");
669        });
670    }
671
672    #[test]
673    fn env_credential_is_authoritative_and_not_renewable_over_a_stored_parent() {
674        use crypto::{Ed25519Signer, Signer};
675
676        with_isolated_env(|home| {
677            let parent_signer = Ed25519Signer::generate().expect("parent proof key");
678            let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
679            let parent_token = biscuit_auth::Biscuit::builder()
680                .fact(r#"user("alice")"#)
681                .expect("user fact")
682                .fact(r#"credential_id("cred-parent")"#)
683                .expect("credential fact")
684                .fact(
685                    format!(
686                        "device_pop_key(\"{}\")",
687                        hex::encode(parent_signer.public_key())
688                    )
689                    .as_str(),
690                )
691                .expect("proof key fact")
692                .fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
693                .expect("expiry fact")
694                .build(&biscuit_auth::KeyPair::new())
695                .expect("mint parent")
696                .to_base64()
697                .expect("encode parent");
698            let server = "127.0.0.1:8421";
699            let stored_parent = credentials::ServerCredential {
700                token: parent_token.clone(),
701                subject: "alice".to_string(),
702                device_id: Some("device-parent".to_string()),
703                credential_id: Some("cred-parent".to_string()),
704                private_key_pem: Some(parent_signer.to_pem().expect("parent PEM")),
705                expires_at: Some(expires_at.to_rfc3339()),
706            };
707            assert!(
708                credentials::token_needs_rotation(&stored_parent),
709                "the stored parent fixture must exercise the renewal window"
710            );
711            credentials::store_server_credential(server, stored_parent)
712                .expect("store nearly expired parent");
713
714            // Without HEDDLE_CREDENTIAL, the keystore parent is the active,
715            // renewable authority.
716            let stored_session =
717                HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
718                    .expect("build stored-parent session");
719            assert_eq!(
720                stored_session
721                    .config
722                    .token
723                    .as_ref()
724                    .map(|token| token.id.as_str()),
725                Some(parent_token.as_str())
726            );
727            assert!(
728                stored_session.renewable_authority_credential.is_some(),
729                "the exact stored authority token is renewable"
730            );
731
732            // The same child, handed to the runtime as a HEDDLE_CREDENTIAL
733            // .hcred, overrides the keystore and is never renewed.
734            let child_signer = Ed25519Signer::generate().expect("child proof key");
735            let child_token = crate::device_flow::attenuate_for_agent(
736                &parent_token,
737                crate::device_flow::AgentAttenuation {
738                    agent_id: "explicit-child".to_string(),
739                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(3),
740                    allowed_operations: Some(vec!["Push".to_string()]),
741                    allowed_resources: None,
742                    declared_scopes: Vec::new(),
743                },
744                &parent_signer,
745                child_signer.public_key(),
746            )
747            .expect("derive explicit child");
748            let child_hcred = home.join("child.hcred");
749            crate::credential_file::write_credential_file(
750                &child_hcred,
751                &crate::credential_file::VerifiedCredential {
752                    server: server.to_string(),
753                    kind: crate::credential_file::CredentialKind::Agent,
754                    subject: "alice".to_string(),
755                    token: child_token.clone(),
756                    proof_key_pem: child_signer.to_pem().expect("child PEM"),
757                    expires_at: Some(
758                        (chrono::Utc::now() + chrono::Duration::minutes(3)).to_rfc3339(),
759                    ),
760                    credential_id: None,
761                    provenance: None,
762                },
763            )
764            .expect("write child .hcred");
765
766            unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &child_hcred) };
767            let explicit_child_session =
768                HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
769                    .expect("build explicit-child session");
770            assert_eq!(
771                explicit_child_session
772                    .config
773                    .token
774                    .as_ref()
775                    .map(|token| token.id.as_str()),
776                Some(child_token.as_str()),
777                "HEDDLE_CREDENTIAL is authoritative over the keystore"
778            );
779            assert!(
780                explicit_child_session
781                    .renewable_authority_credential
782                    .is_none(),
783                "an env-supplied credential must not borrow the stored parent's renewal identity"
784            );
785        });
786    }
787
788    #[tokio::test]
789    async fn token_only_stored_child_does_not_borrow_the_host_device_key() {
790        use crypto::{Ed25519Signer, Signer};
791        use grpc::heddle::api::v1alpha1::{
792            collaboration_service_client::CollaborationServiceClient,
793            identity_service_client::IdentityServiceClient,
794            registry_service_client::RegistryServiceClient,
795            repo_sync_service_client::RepoSyncServiceClient,
796            repository_service_client::RepositoryServiceClient,
797            state_review_service_client::StateReviewServiceClient,
798            workflow_service_client::WorkflowServiceClient,
799        };
800        use tonic::{Request, metadata::MetadataValue, transport::Endpoint};
801
802        use crate::{auth_cmd, grpc_hosted::HostedGrpcClient};
803
804        with_isolated_env(|home| {
805            let signer = Ed25519Signer::generate().expect("device key");
806            let private_key_pem = signer.to_pem().expect("device PEM");
807
808            let subject = "headless-agent";
809            let credential_id = "cred-headless";
810            let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
811            let token = biscuit_auth::Biscuit::builder()
812                .fact(format!("user(\"{subject}\")").as_str())
813                .expect("user fact")
814                .fact(format!("credential_id(\"{credential_id}\")").as_str())
815                .expect("credential fact")
816                .fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
817                .expect("expiry fact")
818                .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
819                .expect("PoP key fact")
820                .build(&biscuit_auth::KeyPair::new())
821                .expect("mint fixture biscuit")
822                .to_base64()
823                .expect("encode fixture biscuit");
824            let server = "127.0.0.1:8421";
825
826            // Install the root device credential: the keystore gets the token
827            // AND its device key, and the shared device identity is linked.
828            let root_hcred = home.join("root.hcred");
829            crate::credential_file::write_credential_file(
830                &root_hcred,
831                &crate::credential_file::VerifiedCredential {
832                    server: server.to_string(),
833                    kind: crate::credential_file::CredentialKind::Device,
834                    subject: subject.to_string(),
835                    token: token.clone(),
836                    proof_key_pem: private_key_pem.clone(),
837                    expires_at: Some(expires_at.to_rfc3339()),
838                    credential_id: Some(credential_id.to_string()),
839                    provenance: None,
840                },
841            )
842            .expect("write root .hcred");
843            auth_cmd::install_credential_file(&root_hcred).expect("headless credential install");
844
845            let identity = repo::identity::load_device(&repo::identity::device_identity_path())
846                .expect("load device identity")
847                .expect("linked device identity");
848            assert_eq!(identity.public_key, hex::encode(signer.public_key()));
849            assert_eq!(identity.server, server);
850
851            // The stored root credential resolves its own proof key directly.
852            let root_session =
853                HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
854                    .expect("build root session");
855            assert_eq!(
856                root_session.config.auth_proof_key_pem.as_deref(),
857                Some(private_key_pem.as_str()),
858                "a stored root credential resolves its bound device key"
859            );
860
861            // Now replace the keystore entry with a TOKEN-ONLY derived child
862            // (no proof key of its own). Its PoP key differs from the host
863            // device identity, so it must NOT borrow the ancestor device key.
864            let child_signer = Ed25519Signer::generate().expect("child PoP key");
865            let child_token = crate::device_flow::attenuate_for_agent(
866                &token,
867                crate::device_flow::AgentAttenuation {
868                    agent_id: "agent-push".to_string(),
869                    expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
870                    allowed_operations: Some(vec!["Push".to_string()]),
871                    allowed_resources: None,
872                    declared_scopes: vec![("repo".to_string(), "acme/heddle".to_string())],
873                },
874                &signer,
875                child_signer.public_key(),
876            )
877            .expect("derive child token");
878            credentials::store_server_credential(
879                server,
880                credentials::ServerCredential {
881                    token: child_token.clone(),
882                    subject: subject.to_string(),
883                    device_id: None,
884                    credential_id: None,
885                    private_key_pem: None,
886                    expires_at: Some((chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339()),
887                },
888            )
889            .expect("store token-only child");
890
891            let session =
892                HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
893                    .expect("build token-only child session");
894            let config = session.config;
895            assert!(
896                config.auth_proof_key_pem.is_none(),
897                "a token-only child must not silently sign with its ancestor device key"
898            );
899
900            let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
901            let client = HostedGrpcClient {
902                inner: RepoSyncServiceClient::new(channel.clone())
903                    .max_decoding_message_size(wire::MAX_PULL_DECODE_MESSAGE_SIZE),
904                user: RegistryServiceClient::new(channel.clone()),
905                auth: IdentityServiceClient::new(channel.clone()),
906                content: RepositoryServiceClient::new(channel.clone()),
907                workflow: WorkflowServiceClient::new(channel.clone()),
908                collaboration: CollaborationServiceClient::new(channel.clone()),
909                review: StateReviewServiceClient::new(channel),
910                token_header: Some(
911                    MetadataValue::try_from(format!(
912                        "Bearer {}",
913                        config.token.as_ref().expect("session token").id
914                    ))
915                    .expect("bearer metadata"),
916                ),
917                transport: crate::grpc_hosted::helpers::HostedTransportPolicy::from_client_config(
918                    &config,
919                ),
920                auth_proof_key_pem: config.auth_proof_key_pem,
921                authenticated_principal: config.authenticated_principal,
922                server_key: config.server_key,
923                on_human_signature: None,
924            };
925            let method = "/heddle.api.v1alpha1.RepoSyncService/Push";
926            let mut request = Request::new(());
927            client
928                .apply_auth(&mut request, method)
929                .expect("attach hosted auth");
930
931            let metadata = request.metadata();
932            assert!(metadata.get(crypto::pop::HDR_PROOF_TS).is_none());
933            assert!(metadata.get(crypto::pop::HDR_PROOF_NONCE).is_none());
934            assert!(metadata.get(crypto::pop::HDR_PROOF).is_none());
935        });
936    }
937}