heddle_client/grpc_hosted/
mod.rs1mod 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 server_key: Option<String>,
46 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 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 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 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 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 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 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 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 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 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 if !credentials::token_needs_rotation(&cred) {
265 return;
266 }
267
268 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 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 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 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 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 }
386
387 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;