mod hosted;
mod relay_tls;
use std::{
collections::HashMap,
net::SocketAddr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, bail};
use api::{
descriptor_trust::{
EndpointDescriptorSetDocument, parse_endpoint_descriptor_set, trusted_live_entries,
},
heddle::api::common::EndpointDescriptor,
};
pub use hosted::HostedClient;
use iroh::{EndpointAddr, EndpointId, RelayUrl};
use reqwest::header::CONTENT_TYPE;
pub use thread_api::{contract, credentials::Credentials, rpc};
const DESCRIPTOR_PATH: &str = "/.well-known/heddle/iroh-endpoint";
const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024;
pub struct ConnectionOptions<C = Credentials> {
pub http_client: reqwest::Client,
pub trusted_descriptors: DescriptorKeyring,
pub credential: C,
pub timeout: Duration,
pub tls_ca_certificate_pem: Option<String>,
}
pub struct DescriptorKeyring {
keys: HashMap<String, [u8; 32]>,
}
impl DescriptorKeyring {
pub fn new(keys: impl IntoIterator<Item = (String, [u8; 32])>) -> Result<Self> {
let mut trusted = HashMap::new();
for (id, key) in keys {
if id.is_empty() || trusted.insert(id, key).is_some() {
bail!("descriptor trust requires nonempty, distinct key IDs");
}
}
if trusted.is_empty() {
bail!("at least one trusted descriptor key is required");
}
Ok(Self { keys: trusted })
}
pub fn verify_set(
&self,
document: &EndpointDescriptorSetDocument,
now_unix_millis: i64,
preferred_region: Option<&str>,
) -> Result<VerifiedEndpointDescriptor> {
let root_key = self
.keys
.get(&document.root_key_id)
.context("endpoint descriptor root signing key is not trusted")?;
let (live, _rejects) = trusted_live_entries(document, root_key, now_unix_millis);
let mut preferred = None;
let mut fallback = None;
for verified in live {
if validate_descriptor(&verified.endpoint_descriptor, now_unix_millis).is_err() {
continue;
}
let mapped = VerifiedEndpointDescriptor(verified.endpoint_descriptor);
if preferred_region.is_some_and(|region| verified.region == region) {
preferred = Some(mapped);
break;
}
if fallback.is_none() {
fallback = Some(mapped);
}
}
preferred
.or(fallback)
.context("no live root-attested Iroh descriptor is trusted")
}
}
#[derive(Clone, Debug)]
pub struct VerifiedEndpointDescriptor(EndpointDescriptor);
impl VerifiedEndpointDescriptor {
pub fn endpoint_addr(&self) -> Result<EndpointAddr> {
let endpoint_id: EndpointId = self
.0
.endpoint_id
.parse()
.context("parse endpoint descriptor Iroh endpoint id")?;
let mut address = EndpointAddr::new(endpoint_id);
for relay in &self.0.relay_urls {
address = address.with_relay_url(
relay
.parse()
.with_context(|| format!("parse endpoint descriptor relay URL {relay}"))?,
);
}
for direct in &self.0.direct_addresses {
let direct: SocketAddr = direct
.parse()
.with_context(|| format!("parse endpoint descriptor direct address {direct}"))?;
address = address.with_ip_addr(direct);
}
Ok(address)
}
pub fn relay_urls(&self) -> Result<Vec<RelayUrl>> {
self.0
.relay_urls
.iter()
.map(|relay| {
relay
.parse()
.with_context(|| format!("parse endpoint descriptor relay URL {relay}"))
})
.collect()
}
pub fn document(&self) -> &EndpointDescriptor {
&self.0
}
}
pub async fn fetch_endpoint_descriptor(
url: &str,
keys: &DescriptorKeyring,
http: &reqwest::Client,
) -> Result<VerifiedEndpointDescriptor> {
let response = http
.get(url)
.send()
.await
.context("fetch signed Iroh endpoint descriptor")?
.error_for_status()
.context("fetch signed Iroh endpoint descriptor")?;
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.map(str::trim);
if !content_type.is_some_and(|value| value.eq_ignore_ascii_case("application/json")) {
bail!("endpoint descriptor response must use application/json");
}
if response
.content_length()
.is_some_and(|length| length > MAX_DESCRIPTOR_BYTES as u64)
{
bail!("signed endpoint descriptor is oversized");
}
let body = response
.bytes()
.await
.context("read signed Iroh endpoint descriptor")?;
if body.len() > MAX_DESCRIPTOR_BYTES {
bail!("signed endpoint descriptor is oversized");
}
verify_descriptor_set_body(&body, keys)
}
pub fn verify_descriptor_set_body(
body: &[u8],
keys: &DescriptorKeyring,
) -> Result<VerifiedEndpointDescriptor> {
let document =
parse_endpoint_descriptor_set(body).context("decode Iroh endpoint descriptor set")?;
let preferred_region = std::env::var("HEDDLE_REMOTE_IROH_REGION")
.ok()
.filter(|value| !value.is_empty());
keys.verify_set(
&document,
i64::try_from(now()?.as_millis()).context("bootstrap timestamp overflow")?,
preferred_region.as_deref(),
)
}
pub fn descriptor_url(server: &str) -> Result<String> {
let authority = server
.strip_prefix("https://")
.unwrap_or(server)
.trim_end_matches('/');
if server.starts_with("http://") || authority.is_empty() || authority.contains('/') {
bail!("native hosted bootstrap requires an HTTPS server authority");
}
Ok(format!("https://{authority}{DESCRIPTOR_PATH}"))
}
fn validate_descriptor(descriptor: &EndpointDescriptor, now_unix_millis: i64) -> Result<()> {
if descriptor.version != 1 || descriptor.endpoint_id.is_empty() {
bail!("unsupported endpoint descriptor version or empty endpoint id");
}
if descriptor.issued_at_unix_millis > now_unix_millis
|| descriptor.expires_at_unix_millis <= now_unix_millis
{
bail!("endpoint descriptor is expired or not yet valid");
}
if !descriptor
.supported_alpns
.iter()
.any(|alpn| alpn == api::HOSTED_ALPN_V1)
{
bail!("endpoint descriptor does not support the hosted Iroh ALPN");
}
if descriptor.relay_urls.is_empty() && descriptor.direct_addresses.is_empty() {
bail!("endpoint descriptor has no relay or direct address");
}
Ok(())
}
fn now() -> Result<Duration> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before the Unix epoch")
}
#[cfg(test)]
mod tests {
use api::{
HOSTED_ALPN_V1,
descriptor_trust::{
AttestedEndpointDescriptorEntry, EndpointDescriptorSetDocument, SET_VERSION,
ephemeral_attestation_bytes, parse_endpoint_descriptor_set,
},
heddle::api::common::{EndpointDescriptor, SignedEndpointDescriptor},
signing,
};
use ed25519_dalek::{Signer, SigningKey};
use prost::Message;
use super::*;
const NOW: i64 = 1_750_000_000_000;
struct Fixture {
root_public: [u8; 32],
document: EndpointDescriptorSetDocument,
}
fn fixture() -> Fixture {
let root = SigningKey::from_bytes(&[7; 32]);
let ephemeral = SigningKey::from_bytes(&[9; 32]);
let ephemeral_public_key = ephemeral.verifying_key().to_bytes();
let attestation = ephemeral_attestation_bytes(
"root:ephemeral",
&ephemeral_public_key,
NOW,
NOW + 60_000,
"us-west-2",
);
let attestation_signature = root.sign(&attestation);
let descriptor = EndpointDescriptor {
version: 1,
endpoint_id: hex::encode(ephemeral_public_key),
relay_urls: vec!["https://relay.example.test".to_string()],
supported_alpns: vec![HOSTED_ALPN_V1.to_vec()],
direct_addresses: vec!["203.0.113.8:4433".to_string()],
issued_at_unix_millis: NOW,
expires_at_unix_millis: NOW + 60_000,
rotation: None,
};
let signed = SignedEndpointDescriptor {
signature: ephemeral
.sign(&signing::endpoint_descriptor_bytes(&descriptor))
.to_bytes()
.to_vec(),
key_id: "root:ephemeral".to_string(),
descriptor: Some(descriptor),
};
Fixture {
root_public: root.verifying_key().to_bytes(),
document: EndpointDescriptorSetDocument {
version: SET_VERSION,
root_key_id: "root".to_string(),
entries: vec![AttestedEndpointDescriptorEntry {
ephemeral_key_id: "root:ephemeral".to_string(),
ephemeral_public_key: hex::encode(ephemeral_public_key),
not_before_unix_millis: NOW,
not_after_unix_millis: NOW + 60_000,
region: "us-west-2".to_string(),
attestation_signature: hex::encode(attestation_signature.to_bytes()),
signed_descriptor: hex::encode(signed.encode_to_vec()),
}],
},
}
}
#[test]
fn explicit_descriptor_trust_rejects_empty_and_ambiguous_key_sets() {
assert!(DescriptorKeyring::new([]).is_err());
assert!(DescriptorKeyring::new([(String::new(), [1; 32])]).is_err());
assert!(
DescriptorKeyring::new([("key".into(), [1; 32]), ("key".into(), [2; 32])]).is_err()
);
}
#[test]
fn bootstrap_url_is_https_and_targets_the_descriptor_route() {
assert_eq!(
descriptor_url("127.0.0.1:8421").unwrap(),
"https://127.0.0.1:8421/.well-known/heddle/iroh-endpoint"
);
assert!(descriptor_url("http://example.com").is_err());
}
#[test]
fn json_descriptor_set_parses_and_a_bad_protobuf_body_fails_closed() {
let fixture = fixture();
let body = serde_json::to_vec(&fixture.document).expect("serialize descriptor set");
parse_endpoint_descriptor_set(&body).expect("JSON descriptor set must parse");
let keys = DescriptorKeyring::new([("root".to_string(), fixture.root_public)])
.expect("trusted descriptor root");
keys.verify_set(&fixture.document, NOW, Some("us-west-2"))
.expect("root-attested JSON set must verify");
let protobuf = SignedEndpointDescriptor {
signature: vec![1; 64],
key_id: "root".to_string(),
descriptor: None,
}
.encode_to_vec();
let parse_error = parse_endpoint_descriptor_set(&protobuf)
.expect_err("protobuf must not parse as a JSON descriptor set");
assert!(
parse_error.to_string().contains("malformed"),
"unexpected parse error: {parse_error}"
);
let verify_error = verify_descriptor_set_body(&protobuf, &keys)
.expect_err("protobuf body must fail closed");
assert!(
verify_error
.to_string()
.contains("decode Iroh endpoint descriptor set"),
"unexpected verify error: {verify_error}"
);
}
#[test]
fn descriptor_set_rejects_tampering() {
let fixture = fixture();
let keys = DescriptorKeyring::new([("root".to_string(), fixture.root_public)])
.expect("trusted descriptor root");
let mut tampered = fixture.document;
tampered.entries[0].region = "eu-central-1".to_string();
assert!(keys.verify_set(&tampered, NOW, None).is_err());
}
}