Skip to main content

weft_client/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Native v2 Weft client: descriptor-trusted Iroh transport and
3//! [`thread_api::Remote::discover`].
4//!
5//! The application supplies the HTTP client, descriptor trust keys, and
6//! credential. This crate does not read `HEDDLE_HOME` or mint credentials.
7//! Admin RPCs that still live only on Weft (`weftctl`) are not shipped here.
8
9mod hosted;
10mod relay_tls;
11
12use std::{
13    collections::HashMap,
14    net::SocketAddr,
15    time::{Duration, SystemTime, UNIX_EPOCH},
16};
17
18use anyhow::{Context, Result, bail};
19use api::{
20    descriptor_trust::{
21        EndpointDescriptorSetDocument, parse_endpoint_descriptor_set, trusted_live_entries,
22    },
23    heddle::api::common::EndpointDescriptor,
24};
25pub use hosted::HostedClient;
26use iroh::{EndpointAddr, EndpointId, RelayUrl};
27use reqwest::header::CONTENT_TYPE;
28pub use thread_api::{contract, credentials::Credentials, rpc};
29
30const DESCRIPTOR_PATH: &str = "/.well-known/heddle/iroh-endpoint";
31const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024;
32
33/// Explicit inputs for a hosted connection. Files, environment, TLS policy,
34/// credential storage and trust-anchor selection belong to the application.
35pub struct ConnectionOptions<C = Credentials> {
36    pub http_client: reqwest::Client,
37    pub trusted_descriptors: DescriptorKeyring,
38    pub credential: C,
39    pub timeout: Duration,
40    /// Same PEM unary HTTPS already merged into reqwest (`HEDDLE_REMOTE_TLS_CA_CERT`).
41    pub tls_ca_certificate_pem: Option<String>,
42}
43
44/// Trust anchors come from the application, including key rotation policy.
45pub struct DescriptorKeyring {
46    keys: HashMap<String, [u8; 32]>,
47}
48
49impl DescriptorKeyring {
50    pub fn new(keys: impl IntoIterator<Item = (String, [u8; 32])>) -> Result<Self> {
51        let mut trusted = HashMap::new();
52        for (id, key) in keys {
53            if id.is_empty() || trusted.insert(id, key).is_some() {
54                bail!("descriptor trust requires nonempty, distinct key IDs");
55            }
56        }
57        if trusted.is_empty() {
58            bail!("at least one trusted descriptor key is required");
59        }
60        Ok(Self { keys: trusted })
61    }
62
63    pub fn verify_set(
64        &self,
65        document: &EndpointDescriptorSetDocument,
66        now_unix_millis: i64,
67        preferred_region: Option<&str>,
68    ) -> Result<VerifiedEndpointDescriptor> {
69        let root_key = self
70            .keys
71            .get(&document.root_key_id)
72            .context("endpoint descriptor root signing key is not trusted")?;
73        let (live, _rejects) = trusted_live_entries(document, root_key, now_unix_millis);
74        let mut preferred = None;
75        let mut fallback = None;
76        for verified in live {
77            if validate_descriptor(&verified.endpoint_descriptor, now_unix_millis).is_err() {
78                continue;
79            }
80            let mapped = VerifiedEndpointDescriptor(verified.endpoint_descriptor);
81            if preferred_region.is_some_and(|region| verified.region == region) {
82                preferred = Some(mapped);
83                break;
84            }
85            if fallback.is_none() {
86                fallback = Some(mapped);
87            }
88        }
89        preferred
90            .or(fallback)
91            .context("no live root-attested Iroh descriptor is trusted")
92    }
93}
94
95/// Endpoint descriptor after two-layer root attestation, expiry, ALPN, and
96/// address validation.
97#[derive(Clone, Debug)]
98pub struct VerifiedEndpointDescriptor(EndpointDescriptor);
99
100impl VerifiedEndpointDescriptor {
101    pub fn endpoint_addr(&self) -> Result<EndpointAddr> {
102        let endpoint_id: EndpointId = self
103            .0
104            .endpoint_id
105            .parse()
106            .context("parse endpoint descriptor Iroh endpoint id")?;
107        let mut address = EndpointAddr::new(endpoint_id);
108        for relay in &self.0.relay_urls {
109            address = address.with_relay_url(
110                relay
111                    .parse()
112                    .with_context(|| format!("parse endpoint descriptor relay URL {relay}"))?,
113            );
114        }
115        for direct in &self.0.direct_addresses {
116            let direct: SocketAddr = direct
117                .parse()
118                .with_context(|| format!("parse endpoint descriptor direct address {direct}"))?;
119            address = address.with_ip_addr(direct);
120        }
121        Ok(address)
122    }
123
124    pub fn relay_urls(&self) -> Result<Vec<RelayUrl>> {
125        self.0
126            .relay_urls
127            .iter()
128            .map(|relay| {
129                relay
130                    .parse()
131                    .with_context(|| format!("parse endpoint descriptor relay URL {relay}"))
132            })
133            .collect()
134    }
135
136    pub fn document(&self) -> &EndpointDescriptor {
137        &self.0
138    }
139}
140
141/// Fetch `/.well-known/heddle/iroh-endpoint` as a JSON descriptor set and
142/// verify it against the application's root keyring.
143pub async fn fetch_endpoint_descriptor(
144    url: &str,
145    keys: &DescriptorKeyring,
146    http: &reqwest::Client,
147) -> Result<VerifiedEndpointDescriptor> {
148    let response = http
149        .get(url)
150        .send()
151        .await
152        .context("fetch signed Iroh endpoint descriptor")?
153        .error_for_status()
154        .context("fetch signed Iroh endpoint descriptor")?;
155    let content_type = response
156        .headers()
157        .get(CONTENT_TYPE)
158        .and_then(|value| value.to_str().ok())
159        .and_then(|value| value.split(';').next())
160        .map(str::trim);
161    if !content_type.is_some_and(|value| value.eq_ignore_ascii_case("application/json")) {
162        bail!("endpoint descriptor response must use application/json");
163    }
164    if response
165        .content_length()
166        .is_some_and(|length| length > MAX_DESCRIPTOR_BYTES as u64)
167    {
168        bail!("signed endpoint descriptor is oversized");
169    }
170    let body = response
171        .bytes()
172        .await
173        .context("read signed Iroh endpoint descriptor")?;
174    if body.len() > MAX_DESCRIPTOR_BYTES {
175        bail!("signed endpoint descriptor is oversized");
176    }
177    verify_descriptor_set_body(&body, keys)
178}
179
180/// Parse a JSON endpoint-descriptor set and verify it. A protobuf body fails
181/// closed at the JSON parser.
182pub fn verify_descriptor_set_body(
183    body: &[u8],
184    keys: &DescriptorKeyring,
185) -> Result<VerifiedEndpointDescriptor> {
186    let document =
187        parse_endpoint_descriptor_set(body).context("decode Iroh endpoint descriptor set")?;
188    let preferred_region = std::env::var("HEDDLE_REMOTE_IROH_REGION")
189        .ok()
190        .filter(|value| !value.is_empty());
191    keys.verify_set(
192        &document,
193        i64::try_from(now()?.as_millis()).context("bootstrap timestamp overflow")?,
194        preferred_region.as_deref(),
195    )
196}
197
198pub fn descriptor_url(server: &str) -> Result<String> {
199    let authority = server
200        .strip_prefix("https://")
201        .unwrap_or(server)
202        .trim_end_matches('/');
203    if server.starts_with("http://") || authority.is_empty() || authority.contains('/') {
204        bail!("native hosted bootstrap requires an HTTPS server authority");
205    }
206    Ok(format!("https://{authority}{DESCRIPTOR_PATH}"))
207}
208
209fn validate_descriptor(descriptor: &EndpointDescriptor, now_unix_millis: i64) -> Result<()> {
210    if descriptor.version != 1 || descriptor.endpoint_id.is_empty() {
211        bail!("unsupported endpoint descriptor version or empty endpoint id");
212    }
213    if descriptor.issued_at_unix_millis > now_unix_millis
214        || descriptor.expires_at_unix_millis <= now_unix_millis
215    {
216        bail!("endpoint descriptor is expired or not yet valid");
217    }
218    if !descriptor
219        .supported_alpns
220        .iter()
221        .any(|alpn| alpn == api::HOSTED_ALPN_V1)
222    {
223        bail!("endpoint descriptor does not support the hosted Iroh ALPN");
224    }
225    if descriptor.relay_urls.is_empty() && descriptor.direct_addresses.is_empty() {
226        bail!("endpoint descriptor has no relay or direct address");
227    }
228    Ok(())
229}
230
231fn now() -> Result<Duration> {
232    SystemTime::now()
233        .duration_since(UNIX_EPOCH)
234        .context("system clock is before the Unix epoch")
235}
236
237#[cfg(test)]
238mod tests {
239    use api::{
240        HOSTED_ALPN_V1,
241        descriptor_trust::{
242            AttestedEndpointDescriptorEntry, EndpointDescriptorSetDocument, SET_VERSION,
243            ephemeral_attestation_bytes, parse_endpoint_descriptor_set,
244        },
245        heddle::api::common::{EndpointDescriptor, SignedEndpointDescriptor},
246        signing,
247    };
248    use ed25519_dalek::{Signer, SigningKey};
249    use prost::Message;
250
251    use super::*;
252
253    const NOW: i64 = 1_750_000_000_000;
254
255    struct Fixture {
256        root_public: [u8; 32],
257        document: EndpointDescriptorSetDocument,
258    }
259
260    fn fixture() -> Fixture {
261        let root = SigningKey::from_bytes(&[7; 32]);
262        let ephemeral = SigningKey::from_bytes(&[9; 32]);
263        let ephemeral_public_key = ephemeral.verifying_key().to_bytes();
264        let attestation = ephemeral_attestation_bytes(
265            "root:ephemeral",
266            &ephemeral_public_key,
267            NOW,
268            NOW + 60_000,
269            "us-west-2",
270        );
271        let attestation_signature = root.sign(&attestation);
272        let descriptor = EndpointDescriptor {
273            version: 1,
274            endpoint_id: hex::encode(ephemeral_public_key),
275            relay_urls: vec!["https://relay.example.test".to_string()],
276            supported_alpns: vec![HOSTED_ALPN_V1.to_vec()],
277            direct_addresses: vec!["203.0.113.8:4433".to_string()],
278            issued_at_unix_millis: NOW,
279            expires_at_unix_millis: NOW + 60_000,
280            rotation: None,
281        };
282        let signed = SignedEndpointDescriptor {
283            signature: ephemeral
284                .sign(&signing::endpoint_descriptor_bytes(&descriptor))
285                .to_bytes()
286                .to_vec(),
287            key_id: "root:ephemeral".to_string(),
288            descriptor: Some(descriptor),
289        };
290        Fixture {
291            root_public: root.verifying_key().to_bytes(),
292            document: EndpointDescriptorSetDocument {
293                version: SET_VERSION,
294                root_key_id: "root".to_string(),
295                entries: vec![AttestedEndpointDescriptorEntry {
296                    ephemeral_key_id: "root:ephemeral".to_string(),
297                    ephemeral_public_key: hex::encode(ephemeral_public_key),
298                    not_before_unix_millis: NOW,
299                    not_after_unix_millis: NOW + 60_000,
300                    region: "us-west-2".to_string(),
301                    attestation_signature: hex::encode(attestation_signature.to_bytes()),
302                    signed_descriptor: hex::encode(signed.encode_to_vec()),
303                }],
304            },
305        }
306    }
307
308    #[test]
309    fn explicit_descriptor_trust_rejects_empty_and_ambiguous_key_sets() {
310        assert!(DescriptorKeyring::new([]).is_err());
311        assert!(DescriptorKeyring::new([(String::new(), [1; 32])]).is_err());
312        assert!(
313            DescriptorKeyring::new([("key".into(), [1; 32]), ("key".into(), [2; 32])]).is_err()
314        );
315    }
316
317    #[test]
318    fn bootstrap_url_is_https_and_targets_the_descriptor_route() {
319        assert_eq!(
320            descriptor_url("127.0.0.1:8421").unwrap(),
321            "https://127.0.0.1:8421/.well-known/heddle/iroh-endpoint"
322        );
323        assert!(descriptor_url("http://example.com").is_err());
324    }
325
326    #[test]
327    fn json_descriptor_set_parses_and_a_bad_protobuf_body_fails_closed() {
328        let fixture = fixture();
329        let body = serde_json::to_vec(&fixture.document).expect("serialize descriptor set");
330        parse_endpoint_descriptor_set(&body).expect("JSON descriptor set must parse");
331        let keys = DescriptorKeyring::new([("root".to_string(), fixture.root_public)])
332            .expect("trusted descriptor root");
333        keys.verify_set(&fixture.document, NOW, Some("us-west-2"))
334            .expect("root-attested JSON set must verify");
335
336        let protobuf = SignedEndpointDescriptor {
337            signature: vec![1; 64],
338            key_id: "root".to_string(),
339            descriptor: None,
340        }
341        .encode_to_vec();
342        let parse_error = parse_endpoint_descriptor_set(&protobuf)
343            .expect_err("protobuf must not parse as a JSON descriptor set");
344        assert!(
345            parse_error.to_string().contains("malformed"),
346            "unexpected parse error: {parse_error}"
347        );
348        let verify_error = verify_descriptor_set_body(&protobuf, &keys)
349            .expect_err("protobuf body must fail closed");
350        assert!(
351            verify_error
352                .to_string()
353                .contains("decode Iroh endpoint descriptor set"),
354            "unexpected verify error: {verify_error}"
355        );
356    }
357
358    #[test]
359    fn descriptor_set_rejects_tampering() {
360        let fixture = fixture();
361        let keys = DescriptorKeyring::new([("root".to_string(), fixture.root_public)])
362            .expect("trusted descriptor root");
363        let mut tampered = fixture.document;
364        tampered.entries[0].region = "eu-central-1".to_string();
365        assert!(keys.verify_set(&tampered, NOW, None).is_err());
366    }
367}