1mod collaboration;
4mod content;
5mod state_review;
6pub(crate) mod helpers;
7mod hydration;
8pub mod monorepo;
9pub(crate) mod operation_id;
10pub mod request_signing;
11mod session;
12mod sync;
13mod user;
14
15use cli_shared::{ClientConfig, cleartext_connect_allowed, cleartext_refused_message};
16use crypto::{Ed25519Signer, Signer};
17use grpc::heddle::api::v1alpha1::{
18 KeypairProof, MintBiscuitRequest,
19 collaboration_service_client::CollaborationServiceClient,
20 identity_service_client::IdentityServiceClient, mint_biscuit_request::Proof,
21 registry_service_client::RegistryServiceClient,
22 repo_sync_service_client::RepoSyncServiceClient,
23 repository_service_client::RepositoryServiceClient,
24 state_review_service_client::StateReviewServiceClient,
25 workflow_service_client::WorkflowServiceClient,
26};
27use objects::{object::MarkerName, store::ObjectStore};
28use repo::Repository;
29use tonic::{
30 Request,
31 metadata::MetadataValue,
32 transport::{Certificate, Channel, ClientTlsConfig, Endpoint},
33};
34use wire::ProtocolError;
35
36use crate::credentials;
37
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub(super) struct RenewableAuthorityCredential {
40 token: String,
41 credential_id: String,
42}
43
44impl RenewableAuthorityCredential {
45 pub(super) fn from_stored(credential: &credentials::ServerCredential) -> Option<Self> {
46 let credential_id = credential.credential_id.clone()?;
47 let signer = Ed25519Signer::from_pem(credential.private_key_pem.as_ref()?).ok()?;
48 let biscuit =
49 biscuit_auth::UnverifiedBiscuit::from_base64(credential.token.as_bytes()).ok()?;
50 if biscuit.block_count() != 1 {
51 return None;
52 }
53 let authority = biscuit_auth::builder::BlockBuilder::new()
54 .code(&biscuit.print_block_source(0).ok()?)
55 .ok()?;
56 let credential_ids = authority
57 .facts
58 .iter()
59 .filter_map(|fact| {
60 match (
61 fact.predicate.name.as_str(),
62 fact.predicate.terms.as_slice(),
63 ) {
64 ("credential_id", [biscuit_auth::builder::Term::Str(id)]) => Some(id),
65 _ => None,
66 }
67 })
68 .collect::<Vec<_>>();
69 let [token_credential_id] = credential_ids.as_slice() else {
70 return None;
71 };
72 if token_credential_id.as_str() != credential_id.as_str()
73 || !crate::device_flow::effective_pop_public_key_hex(&credential.token)
74 .is_ok_and(|key| key.eq_ignore_ascii_case(&hex::encode(signer.public_key())))
75 {
76 return None;
77 }
78 Some(Self {
79 token: credential.token.clone(),
80 credential_id,
81 })
82 }
83
84 fn matches_active_client(
85 &self,
86 credential: &credentials::ServerCredential,
87 token_header: Option<&MetadataValue<tonic::metadata::Ascii>>,
88 ) -> bool {
89 credential.token == self.token
90 && credential.credential_id.as_deref() == Some(self.credential_id.as_str())
91 && token_header
92 .and_then(|header| header.to_str().ok())
93 .and_then(|header| header.strip_prefix("Bearer "))
94 == Some(self.token.as_str())
95 }
96}
97
98pub struct HostedGrpcClient {
99 pub(super) inner: RepoSyncServiceClient<Channel>,
100 pub(super) user: RegistryServiceClient<Channel>,
101 pub(super) auth: IdentityServiceClient<Channel>,
102 pub(super) content: RepositoryServiceClient<Channel>,
103 pub(super) workflow: WorkflowServiceClient<Channel>,
104 pub(super) collaboration: CollaborationServiceClient<Channel>,
105 pub(super) review: StateReviewServiceClient<Channel>,
106 pub(super) token_header: Option<MetadataValue<tonic::metadata::Ascii>>,
107 transport: helpers::HostedTransportPolicy,
108 pub(super) auth_proof_key_pem: Option<String>,
109 authenticated_principal: Option<String>,
110 server_key: Option<String>,
115 on_human_signature: Option<request_signing::HumanSignatureCallback>,
120}
121
122impl HostedGrpcClient {
123 pub async fn connect(
124 addr: std::net::SocketAddr,
125 config: &ClientConfig,
126 ) -> Result<Self, ProtocolError> {
127 if !cleartext_connect_allowed(addr, config.tls_enabled, config.allow_insecure) {
131 return Err(ProtocolError::InvalidState(cleartext_refused_message(addr)));
132 }
133 let scheme = if config.tls_enabled { "https" } else { "http" };
134 let mut endpoint = Endpoint::from_shared(format!("{scheme}://{addr}"))
135 .map_err(|err| ProtocolError::InvalidState(err.to_string()))?;
136 if config.tls_enabled {
137 let mut tls = ClientTlsConfig::new();
138 if let Some(domain_name) = &config.tls_domain_name {
139 tls = tls.domain_name(domain_name.clone());
140 }
141 if let Some(ca_pem) = &config.tls_ca_certificate_pem {
142 tls = tls.ca_certificate(Certificate::from_pem(ca_pem.as_bytes()));
143 }
144 endpoint = endpoint
145 .tls_config(tls)
146 .map_err(|err| ProtocolError::InvalidState(err.to_string()))?;
147 }
148 let channel = endpoint
149 .connect()
150 .await
151 .map_err(|err| ProtocolError::Io(std::io::Error::other(err.to_string())))?;
152 Self::from_channel(channel, config)
153 }
154
155 pub(super) fn from_channel(
156 channel: Channel,
157 config: &ClientConfig,
158 ) -> Result<Self, ProtocolError> {
159 let token_header = config
160 .token
161 .as_ref()
162 .map(|token| MetadataValue::try_from(format!("Bearer {}", token.id)))
163 .transpose()
164 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
165 let transport = helpers::HostedTransportPolicy::from_client_config(config);
166 if config.auth_proof_key_pem.is_some() && config.authenticated_principal.is_none() {
167 return Err(ProtocolError::AuthenticationFailed(
168 "hosted request signing requires an authenticated principal".to_string(),
169 ));
170 }
171 Ok(Self {
172 inner: RepoSyncServiceClient::new(channel.clone())
179 .max_decoding_message_size(wire::MAX_PULL_DECODE_MESSAGE_SIZE),
180 user: RegistryServiceClient::new(channel.clone()),
181 auth: IdentityServiceClient::new(channel.clone()),
182 content: RepositoryServiceClient::new(channel.clone()),
183 workflow: WorkflowServiceClient::new(channel.clone()),
184 collaboration: CollaborationServiceClient::new(channel.clone()),
185 review: StateReviewServiceClient::new(channel.clone()),
186 token_header,
187 transport,
188 auth_proof_key_pem: config.auth_proof_key_pem.clone(),
189 authenticated_principal: config.authenticated_principal.clone(),
190 server_key: config.server_key.clone(),
191 on_human_signature: None,
192 })
193 }
194
195 pub fn with_human_signature_callback(
203 mut self,
204 callback: request_signing::HumanSignatureCallback,
205 ) -> Self {
206 self.on_human_signature = Some(callback);
207 self
208 }
209
210 fn device_signer(&self) -> Result<Option<Ed25519Signer>, ProtocolError> {
216 match &self.auth_proof_key_pem {
217 Some(pem) => Ed25519Signer::from_pem(pem)
218 .map(Some)
219 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string())),
220 None => Ok(None),
221 }
222 }
223
224 fn stable_signing_identity(&self) -> Result<&str, ProtocolError> {
225 self.authenticated_principal
226 .as_deref()
227 .filter(|principal| {
228 principal
229 .strip_prefix("principal:")
230 .is_some_and(|subject| !subject.trim().is_empty())
231 })
232 .ok_or_else(|| {
233 ProtocolError::AuthenticationFailed(
234 "hosted request signing requires the bearer token's stable authenticated principal"
235 .to_string(),
236 )
237 })
238 }
239
240 pub(in crate::grpc_hosted) fn apply_signed_auth<T: prost::Message>(
249 &self,
250 request: &mut Request<T>,
251 method_path: &str,
252 ) -> Result<Option<request_signing::SignedRequestContext>, ProtocolError> {
253 self.apply_auth(request, method_path)?;
254 let Some(signer) = self.device_signer()? else {
255 return Ok(None);
256 };
257 let message_bytes = request.get_ref().encode_to_vec();
258 let identity = self.stable_signing_identity()?;
259 let ctx =
260 request_signing::attach_pop(request, &signer, identity, method_path, &message_bytes)?;
261 Ok(Some(ctx))
262 }
263
264 pub(in crate::grpc_hosted) fn require_human_sig_context(
269 &self,
270 ctx: Option<request_signing::SignedRequestContext>,
271 ) -> Result<request_signing::SignedRequestContext, ProtocolError> {
272 ctx.ok_or_else(|| {
273 ProtocolError::AuthorizationFailed(
274 "this action requires user verification, but the client has no device key to \
275 sign the request; run `heddle auth login` first"
276 .to_string(),
277 )
278 })
279 }
280
281 pub(in crate::grpc_hosted) fn request_human_signature(
286 &self,
287 method_path: &str,
288 ctx: &request_signing::SignedRequestContext,
289 action_url: Option<String>,
290 ) -> Result<request_signing::WebAuthnAssertion, ProtocolError> {
291 let callback = self.on_human_signature.as_ref().ok_or_else(|| {
292 ProtocolError::AuthorizationFailed(format!(
293 "action {method_path} requires user verification, but no WebAuthn signer is \
294 configured for this client"
295 ))
296 })?;
297 let challenge = request_signing::human_challenge(&ctx.canonical);
298 let req = request_signing::HumanSignatureRequest {
299 method_path: method_path.to_string(),
300 action_summary: format!("Authorize {method_path}"),
301 challenge,
302 canonical: ctx.canonical.clone(),
303 action_url,
306 };
307 callback(req)
308 }
309
310 pub(super) fn apply_auth<T>(
311 &self,
312 request: &mut Request<T>,
313 method_path: &str,
314 ) -> Result<(), ProtocolError> {
315 if let Some(token) = &self.token_header {
316 request
317 .metadata_mut()
318 .insert("authorization", token.clone());
319 if let Some(pem) = &self.auth_proof_key_pem {
320 let signer = Ed25519Signer::from_pem(pem)
321 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
322 let raw = token
323 .to_str()
324 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
325 let bearer = raw
326 .strip_prefix("Bearer ")
327 .or_else(|| raw.strip_prefix("bearer "))
328 .unwrap_or(raw);
329 let proof_ts = std::time::SystemTime::now()
330 .duration_since(std::time::UNIX_EPOCH)
331 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?
332 .as_secs()
333 .to_string();
334 let mut nonce_bytes = [0u8; 16];
335 rand::fill(&mut nonce_bytes);
336 let nonce = hex::encode(nonce_bytes);
337 let signature =
338 crypto::pop::sign_pop(&signer, bearer, &proof_ts, "POST", method_path, &nonce)
339 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
340 use base64::Engine;
341 let encoded = base64::engine::general_purpose::STANDARD.encode(signature);
342 let proof = MetadataValue::try_from(encoded)
343 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
344 request.metadata_mut().insert(crypto::pop::HDR_PROOF, proof);
345 let proof_ts = MetadataValue::try_from(proof_ts)
346 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
347 request
348 .metadata_mut()
349 .insert(crypto::pop::HDR_PROOF_TS, proof_ts);
350 let nonce = MetadataValue::try_from(nonce)
351 .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
352 request
353 .metadata_mut()
354 .insert(crypto::pop::HDR_PROOF_NONCE, nonce);
355 }
356 }
357 Ok(())
358 }
359
360 pub(super) async fn auto_rotate_if_needed(
367 &mut self,
368 renewable: Option<&RenewableAuthorityCredential>,
369 ) {
370 let Some(renewable) = renewable else {
371 return;
372 };
373 let server_key = match &self.server_key {
374 Some(k) => k.clone(),
375 None => return,
376 };
377 self.rotate_credential_for_server(&server_key, renewable)
378 .await;
379 }
380
381 async fn rotate_credential_for_server(
382 &mut self,
383 server_key: &str,
384 renewable: &RenewableAuthorityCredential,
385 ) {
386 let cred = match credentials::resolve_credential_for_server(server_key) {
388 Ok(Some(c)) => c,
389 Ok(None) => return,
390 Err(err) => {
391 tracing::warn!("credential rotation: failed to load credential: {err}");
392 return;
393 }
394 };
395
396 if !renewable.matches_active_client(&cred, self.token_header.as_ref()) {
400 tracing::debug!(
401 "credential rotation: active bearer no longer matches the selected stored credential"
402 );
403 return;
404 }
405
406 if !credentials::token_needs_rotation(&cred) {
409 return;
410 }
411
412 let public_key_id = match &cred.credential_id {
417 Some(id) => id.clone(),
418 None => {
419 tracing::debug!("credential rotation: no credential_id stored, skipping");
420 return;
421 }
422 };
423 let private_key_pem = match &cred.private_key_pem {
424 Some(pem) => pem.clone(),
425 None => {
426 tracing::debug!("credential rotation: no private_key_pem stored, skipping");
427 return;
428 }
429 };
430
431 let signer = match Ed25519Signer::from_pem(&private_key_pem) {
437 Ok(s) => s,
438 Err(err) => {
439 tracing::warn!("credential rotation: failed to load signing key: {err}");
440 return;
441 }
442 };
443 let timestamp = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
444 Ok(d) => d.as_secs(),
445 Err(err) => {
446 tracing::warn!("credential rotation: clock skew: {err}");
447 return;
448 }
449 };
450 let canonical = format!("{timestamp}\n{public_key_id}\n");
451 let signature = match signer.sign(canonical.as_bytes()) {
452 Ok(sig) => sig,
453 Err(err) => {
454 tracing::warn!("credential rotation: failed to sign challenge: {err}");
455 return;
456 }
457 };
458
459 let mut request = Request::new(MintBiscuitRequest {
460 subject: cred.subject.clone(),
461 requested_scope: String::new(),
462 user_agent: String::new(),
463 ip: String::new(),
464 proof: Some(Proof::Keypair(KeypairProof {
465 public_key_id,
466 timestamp,
467 signature,
468 })),
469 client_operation_id: String::new(),
470 });
471 let _ = &mut request;
474
475 let response = match self.auth.mint_biscuit(request).await {
476 Ok(r) => r.into_inner(),
477 Err(status) => {
478 tracing::warn!(
479 "credential rotation: MintBiscuit failed: {} — continuing with existing token",
480 status.message()
481 );
482 return;
483 }
484 };
485
486 let expires_at_secs = response
488 .expires_at
489 .as_ref()
490 .map(|t| t.seconds.max(0))
491 .unwrap_or(0);
492 let new_expires_at = if expires_at_secs > 0 {
493 chrono::DateTime::from_timestamp(expires_at_secs, 0)
494 .map(|dt| dt.to_rfc3339())
495 .unwrap_or_else(|| expires_at_secs.to_string())
496 } else {
497 String::new()
498 };
499
500 tracing::debug!(
501 "credential rotation: rotated successfully, new expiry: {}",
502 new_expires_at
503 );
504
505 let updated = credentials::ServerCredential {
510 token: response.token.clone(),
511 subject: if response.subject.is_empty() {
512 cred.subject.clone()
513 } else {
514 response.subject
515 },
516 device_id: cred.device_id.clone(),
517 credential_id: cred.credential_id.clone(),
518 private_key_pem: Some(private_key_pem),
519 expires_at: if new_expires_at.is_empty() {
520 cred.expires_at.clone()
521 } else {
522 Some(new_expires_at)
523 },
524 };
525
526 if let Err(err) = credentials::store_server_credential(server_key, updated) {
527 tracing::warn!("credential rotation: failed to persist updated credential: {err}");
528 }
530
531 match MetadataValue::try_from(format!("Bearer {}", response.token)) {
534 Ok(header) => self.token_header = Some(header),
535 Err(err) => {
536 tracing::warn!("credential rotation: failed to set new token header: {err}");
537 }
538 }
539 }
540
541 pub(super) async fn sync_remote_markers(
542 &mut self,
543 repo: &Repository,
544 repo_path: &str,
545 pushed_state: objects::object::StateId,
546 ) -> Result<(), ProtocolError> {
547 let remote_markers = self
548 .list_refs(repo_path)
549 .await?
550 .into_iter()
551 .filter(|entry| !entry.is_thread)
552 .map(|entry| (entry.name, entry.state_id))
553 .collect::<std::collections::HashMap<_, _>>();
554 for marker in repo.refs().list_markers()? {
555 let Some(state_id) = repo.refs().get_marker(&marker)? else {
556 continue;
557 };
558 if !wire::is_ancestor(repo.store(), state_id, pushed_state)? {
559 continue;
560 }
561
562 let old_value = remote_markers.get(marker.as_str()).copied();
563 if old_value == Some(state_id) {
564 continue;
565 }
566
567 let result = self
568 .update_ref(
569 repo_path,
570 &marker,
571 false,
572 old_value,
573 state_id,
574 true,
575 None,
576 operation_id::ClientOperationId::fresh(
577 "heddle.api.v1alpha1.RepoSyncService/UpdateRef",
578 )
579 .to_wire(),
580 )
581 .await?;
582 if !result.success {
583 return Err(ProtocolError::InvalidState(
584 result
585 .error
586 .unwrap_or_else(|| format!("failed to sync marker '{marker}'")),
587 ));
588 }
589 }
590 Ok(())
591 }
592
593 pub(super) async fn sync_local_markers(
594 &mut self,
595 repo: &Repository,
596 repo_path: &str,
597 ) -> Result<(), ProtocolError> {
598 let remote_markers = self.list_refs(repo_path).await?;
599 for marker in remote_markers.into_iter().filter(|entry| !entry.is_thread) {
600 if !repo.store().has_state(&marker.state_id)? {
601 continue;
602 }
603 let marker_name = MarkerName::from(marker.name.as_str());
604 match repo.refs().get_marker(&marker_name)? {
605 Some(existing) if existing == marker.state_id => {}
606 Some(existing) => repo.refs().set_marker_cas(
607 &marker_name,
608 refs::RefExpectation::Value(existing),
609 &marker.state_id,
610 )?,
611 None => repo.refs().create_marker(&marker_name, &marker.state_id)?,
612 }
613 }
614 Ok(())
615 }
616}
617
618pub use collaboration::{HostedDiscussion, HostedDiscussionTurn};
619pub use hydration::{LazyHostedHydrator, PullMaterialization, register_hosted_factory};
620pub use monorepo::{MonorepoCloneOp, MonorepoClonePlan, SkippedChild};
621pub use session::{
622 CredentialSource, HostedSession, ResolvedHostedCredential, resolve_active_bearer,
623 resolve_hosted_credential,
624};
625pub use sync::HostedRefEntry;
626
627#[cfg(test)]
628mod tests {
629 use base64::Engine as _;
630 use biscuit_auth::{Biscuit, KeyPair};
631
632 use super::*;
633
634 fn test_client(auth_proof_key_pem: String, token: &str) -> HostedGrpcClient {
635 let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
636 let config = ClientConfig::default();
637 HostedGrpcClient {
638 inner: RepoSyncServiceClient::new(channel.clone())
639 .max_decoding_message_size(wire::MAX_PULL_DECODE_MESSAGE_SIZE),
640 user: RegistryServiceClient::new(channel.clone()),
641 auth: IdentityServiceClient::new(channel.clone()),
642 content: RepositoryServiceClient::new(channel.clone()),
643 workflow: WorkflowServiceClient::new(channel.clone()),
644 collaboration: CollaborationServiceClient::new(channel.clone()),
645 review: StateReviewServiceClient::new(channel.clone()),
646 token_header: Some(
647 MetadataValue::try_from(format!("Bearer {token}")).expect("valid bearer header"),
648 ),
649 transport: helpers::HostedTransportPolicy::from_client_config(&config),
650 auth_proof_key_pem: Some(auth_proof_key_pem),
651 authenticated_principal: Some("principal:alice".to_string()),
652 server_key: None,
653 on_human_signature: None,
654 }
655 }
656
657 #[tokio::test]
658 async fn signed_client_fails_closed_without_an_authenticated_principal() {
659 let signer = Ed25519Signer::generate().expect("proof signer");
660 let config =
661 ClientConfig::default().with_auth_proof_key_pem(signer.to_pem().expect("proof PEM"));
662 let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
663
664 let error = match HostedGrpcClient::from_channel(channel, &config) {
665 Ok(_) => panic!("proof signing without a principal must fail closed"),
666 Err(error) => error,
667 };
668
669 assert!(error.to_string().contains("authenticated principal"));
670 }
671
672 #[test]
673 fn renewable_authority_binding_requires_exact_token_and_credential_id() {
674 let signer = Ed25519Signer::generate().expect("proof signer");
675 let token = Biscuit::builder()
676 .fact(r#"user("alice")"#)
677 .expect("user fact")
678 .fact(r#"credential_id("cred-1")"#)
679 .expect("credential fact")
680 .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
681 .expect("proof key fact")
682 .build(&KeyPair::new())
683 .expect("mint authority token")
684 .to_base64()
685 .expect("encode authority token");
686 let credential = credentials::ServerCredential {
687 token: token.clone(),
688 subject: "alice".to_string(),
689 device_id: Some("device-1".to_string()),
690 credential_id: Some("cred-1".to_string()),
691 private_key_pem: Some(signer.to_pem().expect("proof PEM")),
692 expires_at: None,
693 };
694 let binding = RenewableAuthorityCredential::from_stored(&credential)
695 .expect("valid stored authority credential");
696 let mut mismatched_claim = credential.clone();
697 mismatched_claim.credential_id = Some("cred-2".to_string());
698 assert!(
699 RenewableAuthorityCredential::from_stored(&mismatched_claim).is_none(),
700 "the stored credential id must match the authority token claim"
701 );
702 let attenuated_token = biscuit_auth::UnverifiedBiscuit::from_base64(token.as_bytes())
703 .expect("parse authority token")
704 .append(
705 biscuit_auth::builder::BlockBuilder::new()
706 .fact(r#"agent("child")"#)
707 .expect("child fact"),
708 )
709 .expect("append child block")
710 .to_base64()
711 .expect("encode attenuated token");
712 let mut attenuated_credential = credential.clone();
713 attenuated_credential.token = attenuated_token;
714 assert!(
715 RenewableAuthorityCredential::from_stored(&attenuated_credential).is_none(),
716 "an attenuated token is never an authority-renewal source"
717 );
718 let matching_header =
719 MetadataValue::try_from(format!("Bearer {token}")).expect("matching bearer header");
720 assert!(binding.matches_active_client(&credential, Some(&matching_header)));
721
722 let wrong_header =
723 MetadataValue::try_from("Bearer explicit-child").expect("different bearer header");
724 assert!(!binding.matches_active_client(&credential, Some(&wrong_header)));
725
726 let mut wrong_id = credential.clone();
727 wrong_id.credential_id = Some("cred-2".to_string());
728 assert!(!binding.matches_active_client(&wrong_id, Some(&matching_header)));
729
730 let mut wrong_token = credential;
731 wrong_token.token = "different-stored-token".to_string();
732 assert!(!binding.matches_active_client(&wrong_token, Some(&matching_header)));
733 }
734
735 #[tokio::test]
736 async fn apply_auth_attaches_verifiable_v2_pop_proof() {
737 let signer = Ed25519Signer::generate().expect("generate signer");
738 let pem = signer.to_pem().expect("export signer pem");
739 let token = "test-token";
740 let path = "/heddle.api.v1alpha1.IdentityService/WhoAmI";
741 let client = test_client(pem, token);
742 let mut request = Request::new(());
743
744 client.apply_auth(&mut request, path).expect("apply auth");
745
746 let md = request.metadata();
747 let proof_ts = md
748 .get(crypto::pop::HDR_PROOF_TS)
749 .and_then(|v| v.to_str().ok())
750 .expect("proof timestamp header");
751 let nonce = md
752 .get(crypto::pop::HDR_PROOF_NONCE)
753 .and_then(|v| v.to_str().ok())
754 .expect("proof nonce header");
755 assert!(!nonce.is_empty());
756 assert!(nonce.len() <= 256);
757
758 let proof = md
759 .get(crypto::pop::HDR_PROOF)
760 .and_then(|v| v.to_str().ok())
761 .expect("proof header");
762 let sig = base64::engine::general_purpose::STANDARD
763 .decode(proof)
764 .expect("proof decodes");
765 let canonical = crypto::pop::pop_canonical_payload(token, proof_ts, "POST", path, nonce);
766 crypto::verify_payload_signature(&canonical, "ed25519", signer.public_key(), &sig)
767 .expect("proof verifies against device public key");
768 }
769
770 #[tokio::test]
771 async fn service_account_issue_preserves_custom_proof_and_adds_full_hosted_auth_chain() {
772 use grpc::heddle::api::v1alpha1::IssueServiceAccountCredentialRequest;
773
774 let signer = Ed25519Signer::generate().expect("generate signer");
775 let pem = signer.to_pem().expect("export signer pem");
776 let client = test_client(pem, "stored-biscuit");
777 let mut request = Request::new(IssueServiceAccountCredentialRequest {
778 service_account_id: "sa-1".to_string(),
779 public_key: vec![7; 32],
780 scope: "repo:acme/*".to_string(),
781 ttl_secs: None,
782 client_operation_id: "stable-op-1".to_string(),
783 });
784 request
785 .metadata_mut()
786 .insert("x-heddle-issue-sa-proof-ts", "1700000000".parse().unwrap());
787 request.metadata_mut().insert_bin(
788 "x-heddle-issue-sa-proof-sig-bin",
789 tonic::metadata::MetadataValue::from_bytes(b"service-account-proof"),
790 );
791
792 client
793 .apply_signed_auth(
794 &mut request,
795 "/heddle.api.v1alpha1.IdentityService/IssueServiceAccountCredential",
796 )
797 .expect("prepare signed request");
798 let metadata = request.metadata();
799 assert!(metadata.get("authorization").is_some());
800 assert!(metadata.get(crypto::pop::HDR_PROOF).is_some());
801 assert!(metadata.get(crypto::pop::HDR_PROOF_TS).is_some());
802 assert!(metadata.get(crypto::pop::HDR_PROOF_NONCE).is_some());
803 assert!(metadata.get(request_signing::HDR_SIG_TS).is_some());
804 assert!(metadata.get_bin(request_signing::HDR_SIG_BIN).is_some());
805 assert!(
806 metadata
807 .get_bin(request_signing::HDR_SIG_NONCE_BIN)
808 .is_some()
809 );
810 assert_eq!(
811 metadata
812 .get("x-heddle-issue-sa-proof-ts")
813 .and_then(|value| value.to_str().ok()),
814 Some("1700000000")
815 );
816 assert!(
817 metadata
818 .get_bin("x-heddle-issue-sa-proof-sig-bin")
819 .is_some()
820 );
821 assert_eq!(request.get_ref().client_operation_id, "stable-op-1");
822 }
823}