Skip to main content

heddle_client/grpc_hosted/
mod.rs

1//! Hosted gRPC client for the transport rewrite.
2
3mod content;
4mod helpers;
5mod hydration;
6pub mod monorepo;
7pub mod request_signing;
8mod session;
9mod sync;
10mod tree_edit;
11mod user;
12
13use cli_shared::ClientConfig;
14use crypto::{Ed25519Signer, Signer};
15use grpc::heddle::v1::{
16    KeypairProof, MintBiscuitRequest, auth_service_client::AuthServiceClient,
17    content_service_client::ContentServiceClient,
18    hosted_user_service_client::HostedUserServiceClient, mint_biscuit_request::Proof,
19    repo_sync_service_client::RepoSyncServiceClient,
20    tree_edit_service_client::TreeEditServiceClient,
21};
22use objects::{object::MarkerName, store::ObjectStore};
23use repo::Repository;
24use tonic::{
25    Request,
26    metadata::MetadataValue,
27    transport::{Certificate, Channel, ClientTlsConfig, Endpoint},
28};
29use wire::ProtocolError;
30
31use crate::credentials;
32
33pub struct HostedGrpcClient {
34    pub(super) inner: RepoSyncServiceClient<Channel>,
35    pub(super) user: HostedUserServiceClient<Channel>,
36    pub(super) auth: AuthServiceClient<Channel>,
37    pub(super) content: ContentServiceClient<Channel>,
38    pub(super) tree_edit: TreeEditServiceClient<Channel>,
39    pub(super) token_header: Option<MetadataValue<tonic::metadata::Ascii>>,
40    transport: helpers::HostedTransportPolicy,
41    pub(super) auth_proof_key_pem: Option<String>,
42    /// The key used to look up this server's credential in the credential
43    /// store.  When set, `auto_rotate_if_needed` will use it to read and
44    /// update `~/.heddle/credentials.toml` transparently.
45    server_key: Option<String>,
46    /// App-registered WebAuthn signer invoked when a `human`-tier RPC is
47    /// rejected with `x-weft-sig-required: human`. `None` => human-tier RPCs
48    /// surface a typed error rather than looping. See
49    /// [`request_signing::HumanSignatureCallback`].
50    on_human_signature: Option<request_signing::HumanSignatureCallback>,
51}
52
53impl HostedGrpcClient {
54    pub async fn connect(
55        addr: std::net::SocketAddr,
56        config: &ClientConfig,
57    ) -> Result<Self, ProtocolError> {
58        let scheme = if config.tls_enabled { "https" } else { "http" };
59        let mut endpoint = Endpoint::from_shared(format!("{scheme}://{addr}"))
60            .map_err(|err| ProtocolError::InvalidState(err.to_string()))?;
61        if config.tls_enabled {
62            let mut tls = ClientTlsConfig::new();
63            if let Some(domain_name) = &config.tls_domain_name {
64                tls = tls.domain_name(domain_name.clone());
65            }
66            if let Some(ca_pem) = &config.tls_ca_certificate_pem {
67                tls = tls.ca_certificate(Certificate::from_pem(ca_pem.as_bytes()));
68            }
69            endpoint = endpoint
70                .tls_config(tls)
71                .map_err(|err| ProtocolError::InvalidState(err.to_string()))?;
72        }
73        let channel = endpoint
74            .connect()
75            .await
76            .map_err(|err| ProtocolError::Io(std::io::Error::other(err.to_string())))?;
77        let token_header = config
78            .token
79            .as_ref()
80            .map(|token| MetadataValue::try_from(format!("Bearer {}", token.id)))
81            .transpose()
82            .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
83        let transport = helpers::HostedTransportPolicy::from_client_config(config);
84        Ok(Self {
85            // Bound the single-shot, server-controlled sidecar allocation at
86            // the gRPC decode boundary: tonic rejects an oversized inbound
87            // `PullMessage` before its `redactions_blob`/`state_visibility_blob`
88            // `Vec<u8>` is ever materialized. The post-decode
89            // `check_received_transfer_blob_size` calls are kept as cheap
90            // defense-in-depth, but this is the load-bearing guard.
91            inner: RepoSyncServiceClient::new(channel.clone())
92                .max_decoding_message_size(wire::MAX_PULL_DECODE_MESSAGE_SIZE),
93            user: HostedUserServiceClient::new(channel.clone()),
94            auth: AuthServiceClient::new(channel.clone()),
95            content: ContentServiceClient::new(channel.clone()),
96            tree_edit: TreeEditServiceClient::new(channel),
97            token_header,
98            transport,
99            auth_proof_key_pem: config.auth_proof_key_pem.clone(),
100            server_key: config.server_key.clone(),
101            on_human_signature: None,
102        })
103    }
104
105    /// Register the app's WebAuthn signer for the destructive (`human`) tier.
106    ///
107    /// Invoked when a signed RPC is rejected with `x-weft-sig-required: human`;
108    /// the callback produces a [`request_signing::WebAuthnAssertion`] over the
109    /// same action and the call is retried once. With no callback registered, a
110    /// human-tier rejection surfaces a typed error (no loop). The CLI wires a
111    /// terminal-prompt implementation; tapestry a browser ceremony.
112    pub fn with_human_signature_callback(
113        mut self,
114        callback: request_signing::HumanSignatureCallback,
115    ) -> Self {
116        self.on_human_signature = Some(callback);
117        self
118    }
119
120    /// The device signer for request PoP, derived from the same
121    /// `auth_proof_key_pem` the client uses for the `x-heddle-proof` bearer
122    /// proof-of-possession. `None` when the client is anonymous / unauthed —
123    /// signing is then skipped (the server defaults to OBSERVE mode and ignores
124    /// missing signatures on unsigned-tier RPCs).
125    fn device_signer(&self) -> Result<Option<Ed25519Signer>, ProtocolError> {
126        match &self.auth_proof_key_pem {
127            Some(pem) => Ed25519Signer::from_pem(pem)
128                .map(Some)
129                .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string())),
130            None => Ok(None),
131        }
132    }
133
134    /// Stamp bearer auth (token + `x-heddle-proof`) and, for unary requests,
135    /// attach the Tier-1 PoP request signature over the serialized body.
136    ///
137    /// This is the single chokepoint every UNARY authenticated call routes
138    /// through. Streaming call sites (which have no single body to hash) call
139    /// [`Self::apply_auth`] directly instead. Returns the signing context so a
140    /// human-tier retry can re-derive the identical WebAuthn challenge; `None`
141    /// when signing was skipped (anonymous client).
142    pub(in crate::grpc_hosted) fn apply_signed_auth<T: prost::Message>(
143        &self,
144        request: &mut Request<T>,
145        method_path: &str,
146    ) -> Result<Option<request_signing::SignedRequestContext>, ProtocolError> {
147        self.apply_auth(request)?;
148        let Some(signer) = self.device_signer()? else {
149            return Ok(None);
150        };
151        let message_bytes = request.get_ref().encode_to_vec();
152        let ctx = request_signing::attach_pop(request, &signer, method_path, &message_bytes)?;
153        Ok(Some(ctx))
154    }
155
156    /// A human-tier rejection can only be satisfied if the original request was
157    /// PoP-signed (so we have a `SignedRequestContext` to derive the WebAuthn
158    /// challenge from). An anonymous client (no device key) that somehow reaches
159    /// a human-tier RPC has no context — surface a typed error, don't loop.
160    pub(in crate::grpc_hosted) fn require_human_sig_context(
161        &self,
162        ctx: Option<request_signing::SignedRequestContext>,
163    ) -> Result<request_signing::SignedRequestContext, ProtocolError> {
164        ctx.ok_or_else(|| {
165            ProtocolError::AuthorizationFailed(
166                "this action requires user verification, but the client has no device key to \
167                 sign the request; run `heddle auth login` first"
168                    .to_string(),
169            )
170        })
171    }
172
173    /// Invoke the app-registered human-signature callback over the pending
174    /// action. The WebAuthn challenge is client-derived
175    /// (`SHA256(canonical bytes)`) — no server round trip. If no callback is
176    /// registered, surface a typed error rather than looping.
177    pub(in crate::grpc_hosted) fn request_human_signature(
178        &self,
179        method_path: &str,
180        ctx: &request_signing::SignedRequestContext,
181        action_url: Option<String>,
182    ) -> Result<request_signing::WebAuthnAssertion, ProtocolError> {
183        let callback = self.on_human_signature.as_ref().ok_or_else(|| {
184            ProtocolError::AuthorizationFailed(format!(
185                "action {method_path} requires user verification, but no WebAuthn signer is \
186                 configured for this client"
187            ))
188        })?;
189        let challenge = request_signing::human_challenge(&ctx.canonical);
190        let req = request_signing::HumanSignatureRequest {
191            method_path: method_path.to_string(),
192            action_summary: format!("Authorize {method_path}"),
193            challenge,
194            canonical: ctx.canonical.clone(),
195            // Deep-link the server sent on the rejection (weft#338), if any — a display hint
196            // the callback can show; the signed challenge above is unaffected.
197            action_url,
198        };
199        callback(req)
200    }
201
202    pub(super) fn apply_auth<T>(&self, request: &mut Request<T>) -> Result<(), ProtocolError> {
203        if let Some(token) = &self.token_header {
204            request
205                .metadata_mut()
206                .insert("authorization", token.clone());
207            if let Some(pem) = &self.auth_proof_key_pem {
208                let signer = Ed25519Signer::from_pem(pem)
209                    .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
210                let raw = token
211                    .to_str()
212                    .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
213                let bearer = raw
214                    .strip_prefix("Bearer ")
215                    .or_else(|| raw.strip_prefix("bearer "))
216                    .unwrap_or(raw);
217                let proof_ts = std::time::SystemTime::now()
218                    .duration_since(std::time::UNIX_EPOCH)
219                    .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?
220                    .as_secs()
221                    .to_string();
222                let signature = signer
223                    .sign(format!("{bearer}|{proof_ts}").as_bytes())
224                    .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
225                use base64::Engine;
226                let encoded = base64::engine::general_purpose::STANDARD.encode(signature);
227                let proof = MetadataValue::try_from(encoded)
228                    .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
229                request.metadata_mut().insert("x-heddle-proof", proof);
230                let proof_ts = MetadataValue::try_from(proof_ts)
231                    .map_err(|err| ProtocolError::AuthenticationFailed(err.to_string()))?;
232                request.metadata_mut().insert("x-heddle-proof-ts", proof_ts);
233            }
234        }
235        Ok(())
236    }
237
238    /// Transparently rotate the credential for this client if it is near expiry.
239    ///
240    /// No-ops if `server_key` was not set on `ClientConfig` at construction
241    /// time, or if no credential is stored for the server, or if the token is
242    /// not within 10 minutes of expiry.
243    pub async fn auto_rotate_if_needed(&mut self) {
244        let server_key = match &self.server_key {
245            Some(k) => k.clone(),
246            None => return,
247        };
248        self.rotate_credential_for_server(&server_key).await;
249    }
250
251    async fn rotate_credential_for_server(&mut self, server_key: &str) {
252        // Load the stored credential.
253        let cred = match credentials::resolve_credential_for_server(server_key) {
254            Ok(Some(c)) => c,
255            Ok(None) => return,
256            Err(err) => {
257                tracing::warn!("credential rotation: failed to load credential: {err}");
258                return;
259            }
260        };
261
262        // Check whether the Biscuit's stored expiry is within the
263        // rotation window.
264        if !credentials::token_needs_rotation(&cred) {
265            return;
266        }
267
268        // We need both `credential_id` (the public key id the server
269        // will look up) and `private_key_pem` (to sign the renewal
270        // proof). Older credentials without one or the other can't
271        // self-renew; the user falls back to `heddle auth login`.
272        let public_key_id = match &cred.credential_id {
273            Some(id) => id.clone(),
274            None => {
275                tracing::debug!("credential rotation: no credential_id stored, skipping");
276                return;
277            }
278        };
279        let private_key_pem = match &cred.private_key_pem {
280            Some(pem) => pem.clone(),
281            None => {
282                tracing::debug!("credential rotation: no private_key_pem stored, skipping");
283                return;
284            }
285        };
286
287        // Sign the canonical renewal challenge:
288        //   "{timestamp}\n{public_key_id}\n{requested_scope}"
289        // Empty `requested_scope` == reuse the keypair owner's
290        // original scope. The server clamps anyway, so a permissive
291        // hint is fine.
292        let signer = match Ed25519Signer::from_pem(&private_key_pem) {
293            Ok(s) => s,
294            Err(err) => {
295                tracing::warn!("credential rotation: failed to load signing key: {err}");
296                return;
297            }
298        };
299        let timestamp = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
300            Ok(d) => d.as_secs(),
301            Err(err) => {
302                tracing::warn!("credential rotation: clock skew: {err}");
303                return;
304            }
305        };
306        let canonical = format!("{timestamp}\n{public_key_id}\n");
307        let signature = match signer.sign(canonical.as_bytes()) {
308            Ok(sig) => sig,
309            Err(err) => {
310                tracing::warn!("credential rotation: failed to sign challenge: {err}");
311                return;
312            }
313        };
314
315        let mut request = Request::new(MintBiscuitRequest {
316            subject: cred.subject.clone(),
317            requested_scope: String::new(),
318            user_agent: String::new(),
319            ip: String::new(),
320            proof: Some(Proof::Keypair(KeypairProof {
321                public_key_id,
322                timestamp,
323                signature,
324            })),
325            client_operation_id: String::new(),
326        });
327        // MintBiscuit is unauthenticated — the proof is the auth.
328        // We deliberately skip `apply_auth` here.
329        let _ = &mut request;
330
331        let response = match self.auth.mint_biscuit(request).await {
332            Ok(r) => r.into_inner(),
333            Err(status) => {
334                tracing::warn!(
335                    "credential rotation: MintBiscuit failed: {} — continuing with existing token",
336                    status.message()
337                );
338                return;
339            }
340        };
341
342        // Format the new expiry as RFC 3339.
343        let expires_at_secs = response
344            .expires_at
345            .as_ref()
346            .map(|t| t.seconds.max(0))
347            .unwrap_or(0);
348        let new_expires_at = if expires_at_secs > 0 {
349            chrono::DateTime::from_timestamp(expires_at_secs, 0)
350                .map(|dt| dt.to_rfc3339())
351                .unwrap_or_else(|| expires_at_secs.to_string())
352        } else {
353            String::new()
354        };
355
356        tracing::debug!(
357            "credential rotation: rotated successfully, new expiry: {}",
358            new_expires_at
359        );
360
361        // Persist the updated credential. The keypair stays the
362        // same — that's the whole point of the keypair-based renewal
363        // model. We replace `token` (the Biscuit) and bump
364        // `expires_at` to the fresh window.
365        let updated = credentials::ServerCredential {
366            token: response.token.clone(),
367            subject: if response.subject.is_empty() {
368                cred.subject.clone()
369            } else {
370                response.subject
371            },
372            device_id: cred.device_id.clone(),
373            credential_id: cred.credential_id.clone(),
374            private_key_pem: Some(private_key_pem),
375            expires_at: if new_expires_at.is_empty() {
376                cred.expires_at.clone()
377            } else {
378                Some(new_expires_at)
379            },
380        };
381
382        if let Err(err) = credentials::store_server_credential(server_key, updated) {
383            tracing::warn!("credential rotation: failed to persist updated credential: {err}");
384            // Don't bail — the in-memory update below still improves the session.
385        }
386
387        // Update the in-memory token header so the remaining RPCs on this
388        // client instance use the fresh token.
389        match MetadataValue::try_from(format!("Bearer {}", response.token)) {
390            Ok(header) => self.token_header = Some(header),
391            Err(err) => {
392                tracing::warn!("credential rotation: failed to set new token header: {err}");
393            }
394        }
395    }
396
397    pub(super) async fn sync_remote_markers(
398        &mut self,
399        repo: &Repository,
400        repo_path: &str,
401        pushed_state: objects::object::ChangeId,
402    ) -> Result<(), ProtocolError> {
403        let remote_markers = self
404            .list_refs(repo_path)
405            .await?
406            .into_iter()
407            .filter(|entry| !entry.is_thread)
408            .map(|entry| (entry.name, entry.change_id))
409            .collect::<std::collections::HashMap<_, _>>();
410        for marker in repo.refs().list_markers()? {
411            let Some(change_id) = repo.refs().get_marker(&marker)? else {
412                continue;
413            };
414            if !wire::is_ancestor(repo.store(), change_id, pushed_state)? {
415                continue;
416            }
417
418            let old_value = remote_markers.get(marker.as_str()).copied();
419            if old_value == Some(change_id) {
420                continue;
421            }
422
423            let result = self
424                .update_ref(repo_path, &marker, false, old_value, change_id, true, None)
425                .await?;
426            if !result.success {
427                return Err(ProtocolError::InvalidState(
428                    result
429                        .error
430                        .unwrap_or_else(|| format!("failed to sync marker '{marker}'")),
431                ));
432            }
433        }
434        Ok(())
435    }
436
437    pub(super) async fn sync_local_markers(
438        &mut self,
439        repo: &Repository,
440        repo_path: &str,
441    ) -> Result<(), ProtocolError> {
442        let remote_markers = self.list_refs(repo_path).await?;
443        for marker in remote_markers.into_iter().filter(|entry| !entry.is_thread) {
444            if !repo.store().has_state(&marker.change_id)? {
445                continue;
446            }
447            let marker_name = MarkerName::from(marker.name.as_str());
448            match repo.refs().get_marker(&marker_name)? {
449                Some(existing) if existing == marker.change_id => {}
450                Some(existing) => repo.refs().set_marker_cas(
451                    &marker_name,
452                    refs::RefExpectation::Value(existing),
453                    &marker.change_id,
454                )?,
455                None => repo.refs().create_marker(&marker_name, &marker.change_id)?,
456            }
457        }
458        Ok(())
459    }
460}
461
462pub use hydration::{LazyHostedHydrator, PullMaterialization, register_hosted_factory};
463pub use monorepo::{MonorepoCloneOp, MonorepoClonePlan, SkippedChild};
464pub use session::{HostedAuthMode, HostedSession};
465pub use sync::HostedRefEntry;