1use 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
37const HEDDLE_CREDENTIAL_ENV: &str = "HEDDLE_CREDENTIAL";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CredentialSource {
43 Env(PathBuf),
45 Keystore,
47 Unauthenticated,
49}
50
51impl CredentialSource {
52 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
62pub struct ResolvedHostedCredential {
65 pub token: Option<AuthToken>,
67 pub proof_key_pem: Option<String>,
69 pub(crate) renewable: Option<RenewableAuthorityCredential>,
75 pub subject: Option<String>,
77 pub credential_id: Option<String>,
79 pub expires_at: Option<String>,
81 pub source: CredentialSource,
83}
84
85impl std::fmt::Debug for ResolvedHostedCredential {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 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
101fn heddle_credential_env_path() -> Result<Option<PathBuf>> {
106 match std::env::var(HEDDLE_CREDENTIAL_ENV) {
107 Ok(value) => {
108 if value.is_empty() {
109 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
133pub 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 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
197pub fn resolve_active_bearer() -> Result<Option<AuthToken>> {
201 let server = credentials::default_server()?;
202 Ok(resolve_hosted_credential(server.as_deref())?.token)
203}
204
205pub struct HostedSession {
214 config: ClientConfig,
215 renewable_authority_credential: Option<RenewableAuthorityCredential>,
216}
217
218impl HostedSession {
219 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}