1mod 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
33pub struct ConnectionOptions<C = Credentials> {
36 pub http_client: reqwest::Client,
37 pub trusted_descriptors: DescriptorKeyring,
38 pub credential: C,
39 pub timeout: Duration,
40 pub tls_ca_certificate_pem: Option<String>,
42}
43
44pub 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#[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
141pub 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
180pub 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}