Skip to main content

heddle_client/grpc_hosted/
mod.rs

1//! Hosted gRPC client for the transport rewrite.
2
3mod 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    /// The key used to look up this server's credential in the credential
111    /// store. When the session also carries an exact renewable-authority
112    /// binding, `auto_rotate_if_needed` uses this key to re-read and update
113    /// `~/.heddle/credentials.toml` transparently.
114    server_key: Option<String>,
115    /// App-registered WebAuthn signer invoked when a `human`-tier RPC is
116    /// rejected with `x-heddle-sig-required: human`. `None` => human-tier RPCs
117    /// surface a typed error rather than looping. See
118    /// [`request_signing::HumanSignatureCallback`].
119    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        // Production remotes require TLS. Cleartext is allowed only for
128        // loopback or when the user explicitly opts in (`allow_insecure` /
129        // `--insecure` / remote.insecure / HEDDLE_REMOTE_INSECURE).
130        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            // Bound the single-shot, server-controlled sidecar allocation at
173            // the gRPC decode boundary: tonic rejects an oversized inbound
174            // `PullMessage` before its `redactions_blob`/`state_visibility_blob`
175            // `Vec<u8>` is ever materialized. The post-decode
176            // `check_received_transfer_blob_size` calls are kept as cheap
177            // defense-in-depth, but this is the load-bearing guard.
178            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    /// Register the app's WebAuthn signer for the destructive (`human`) tier.
196    ///
197    /// Invoked when a signed RPC is rejected with `x-heddle-sig-required: human`;
198    /// the callback produces a [`request_signing::WebAuthnAssertion`] over the
199    /// same action and the call is retried once. With no callback registered, a
200    /// human-tier rejection surfaces a typed error (no loop). The CLI wires a
201    /// terminal-prompt implementation; tapestry a browser ceremony.
202    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    /// The device signer for request PoP, derived from the same
211    /// `auth_proof_key_pem` the client uses for the `x-heddle-proof` bearer
212    /// proof-of-possession. `None` when the client is anonymous / unauthed —
213    /// signing is then skipped (the server defaults to OBSERVE mode and ignores
214    /// missing signatures on unsigned-tier RPCs).
215    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    /// Stamp bearer auth (token + `x-heddle-proof`) and, for unary requests,
241    /// attach the Tier-1 PoP request signature over the serialized body.
242    ///
243    /// This is the single chokepoint every UNARY authenticated call routes
244    /// through. Streaming call sites (which have no single body to hash) call
245    /// [`Self::apply_auth`] directly instead. Returns the signing context so a
246    /// human-tier retry can re-derive the identical WebAuthn challenge; `None`
247    /// when signing was skipped (anonymous client).
248    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    /// A human-tier rejection can only be satisfied if the original request was
265    /// PoP-signed (so we have a `SignedRequestContext` to derive the WebAuthn
266    /// challenge from). An anonymous client (no device key) that somehow reaches
267    /// a human-tier RPC has no context — surface a typed error, don't loop.
268    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    /// Invoke the app-registered human-signature callback over the pending
282    /// action. The WebAuthn challenge is client-derived
283    /// (`SHA256(canonical bytes)`) — no server round trip. If no callback is
284    /// registered, surface a typed error rather than looping.
285    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            // Deep-link the server sent on the rejection (weft#338), if any — a display hint
304            // the callback can show; the signed challenge above is unaffected.
305            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    /// Transparently rotate the credential for this client if it is near expiry.
361    ///
362    /// No-ops unless session construction proved that the active bearer is the
363    /// exact authority credential loaded from this server's credential-store
364    /// row. Explicit config/env tokens and attenuated tokens never enter this
365    /// renewal path.
366    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        // Load the stored credential.
387        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        // The store may have changed after session construction. Renewal is
397        // permitted only while both the active bearer and the current row are
398        // still the exact token + credential id that were selected together.
399        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        // Check whether the Biscuit's stored expiry is within the
407        // rotation window.
408        if !credentials::token_needs_rotation(&cred) {
409            return;
410        }
411
412        // We need both `credential_id` (the public key id the server
413        // will look up) and `private_key_pem` (to sign the renewal
414        // proof). Older credentials without one or the other can't
415        // self-renew; the user falls back to `heddle auth login`.
416        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        // Sign the canonical renewal challenge:
432        //   "{timestamp}\n{public_key_id}\n{requested_scope}"
433        // Empty `requested_scope` == reuse the keypair owner's
434        // original scope. The server clamps anyway, so a permissive
435        // hint is fine.
436        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        // MintBiscuit is unauthenticated — the proof is the auth.
472        // We deliberately skip `apply_auth` here.
473        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        // Format the new expiry as RFC 3339.
487        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        // Persist the updated credential. The keypair stays the
506        // same — that's the whole point of the keypair-based renewal
507        // model. We replace `token` (the Biscuit) and bump
508        // `expires_at` to the fresh window.
509        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            // Don't bail — the in-memory update below still improves the session.
529        }
530
531        // Update the in-memory token header so the remaining RPCs on this
532        // client instance use the fresh token.
533        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}