use std::collections::{BTreeMap, BTreeSet, VecDeque};
#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, ensure, Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
pub const OFFLINE_DEVICE_PROTOCOL: &str = "openrtc-offline-device/1";
pub const OFFLINE_PROOF_PROTOCOL: &str = "openrtc-offline-proof/1";
pub const OFFLINE_TRUST_BUNDLE_PROTOCOL: &str = "openrtc-offline-trust-bundle/1";
pub const MAX_OFFLINE_ROLES: usize = 32;
pub const MAX_OFFLINE_CREDENTIALS: usize = 256;
pub const MAX_OFFLINE_REVOKED_SERIALS: usize = 1_024;
pub const MAX_OFFLINE_SWARM_MEMBERS: usize = 100;
pub const MAX_OFFLINE_SWARM_DEGREE: usize = 5;
pub const MAX_OFFLINE_REPLAY_ENTRIES: usize = 4_096;
pub const MAX_OFFLINE_TRUST_HISTORY: usize = 128;
#[cfg(not(target_arch = "wasm32"))]
const MAX_OFFLINE_TRUST_JOURNAL_BYTES: u64 = 8 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
const OFFLINE_TRUST_JOURNAL_PROTOCOL: &str = "openrtc-offline-trust-journal/1";
#[cfg(not(target_arch = "wasm32"))]
pub struct OfflineClient<'a> {
client: &'a crate::Client,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct OfflineEnrollmentOptions {
pub trust_domain: String,
pub device_id: String,
pub enrollment_nonce: String,
pub requested_roles: Vec<String>,
pub requested_assurance: OfflineAssurance,
pub created_at_ms: u64,
}
#[cfg(not(target_arch = "wasm32"))]
pub struct OfflineRuntimeConfig {
pub local_device_id: String,
pub signer: Arc<dyn OfflineSigner>,
pub trust: Arc<Mutex<DurableOfflineTrustState>>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub(crate) struct InstalledOfflineRuntime {
pub(crate) local_device_id: String,
pub(crate) signer: Arc<dyn OfflineSigner>,
pub(crate) trust: Arc<Mutex<DurableOfflineTrustState>>,
pub(crate) desired_revision: u64,
pub(crate) candidates: BTreeMap<String, OfflineDesiredCandidate>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub(crate) struct OfflineDesiredCandidate {
pub(crate) handoff: OfflineCandidateHandoff,
pub(crate) endpoint_addr: iroh::EndpointAddr,
pub(crate) reachable: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl<'a> OfflineClient<'a> {
pub(crate) fn new(client: &'a crate::Client) -> Self {
Self { client }
}
pub async fn create_enrollment_request(
&self,
signer: &dyn OfflineSigner,
options: OfflineEnrollmentOptions,
) -> Result<OfflineEnrollmentRequest> {
let endpoint_id =
self.client.current_node_id().await.ok_or_else(|| {
anyhow!("Iroh endpoint must be started before offline enrollment")
})?;
OfflineEnrollmentRequest::create(
signer,
&options.trust_domain,
&options.device_id,
&endpoint_id,
&options.enrollment_nonce,
options.requested_roles,
options.requested_assurance,
options.created_at_ms,
)
}
pub async fn network_policy(&self) -> crate::client::NetworkPolicy {
self.client.transport_config().await.network_policy
}
pub async fn install_runtime(&self, config: OfflineRuntimeConfig) -> Result<()> {
self.client.install_offline_runtime(config).await
}
pub async fn apply_trust_bundle(
&self,
bundle: OfflineTrustBundle,
at_ms: u64,
) -> Result<OfflineTrustHighWater> {
self.client.apply_offline_trust_bundle(bundle, at_ms).await
}
pub async fn retire_device(&self, device_id: &str) -> Result<bool> {
self.client.retire_offline_device(device_id).await
}
pub async fn register_candidate(&self, candidate: OfflineCandidateHandoff) -> Result<String> {
self.client
.register_offline_candidate_requirement(candidate)
.await
.map_err(anyhow::Error::msg)
}
pub async fn commit_admission(&self, handoff: OfflineAdmissionHandoff) -> Result<String> {
self.client
.commit_offline_admission_handoff(handoff)
.await
.map_err(anyhow::Error::msg)
}
#[cfg(feature = "transport-lan")]
pub async fn observed_lan_peers(&self) -> Vec<crate::local_discovery::LocalPeerSnapshot> {
self.client.list_local_peers().await
}
}
#[cfg(not(target_arch = "wasm32"))]
fn offline_desired_peers_json(
candidates: &BTreeMap<String, OfflineDesiredCandidate>,
) -> Result<String> {
let peers = candidates
.values()
.filter(|candidate| candidate.reachable)
.map(|candidate| {
serde_json::json!({
"deviceId": candidate.handoff.device_id(),
"nodeId": candidate.endpoint_addr.id.to_string(),
"ticket": iroh_tickets::endpoint::EndpointTicket::new(
candidate.endpoint_addr.clone()
).to_string(),
"online": true,
})
})
.collect::<Vec<_>>();
serde_json::to_string(&peers).context("encode offline desired peers")
}
#[cfg(not(target_arch = "wasm32"))]
impl crate::Client {
pub(crate) async fn offline_runtime_is_installed(&self) -> bool {
self.offline_runtime.lock().await.is_some()
}
pub(crate) async fn offline_candidate_is_reachable(
&self,
candidate: &OfflineCandidateHandoff,
) -> bool {
self.offline_runtime
.lock()
.await
.as_ref()
.and_then(|runtime| runtime.candidates.get(candidate.device_id()))
.is_some_and(|current| current.reachable && current.handoff.same_authority(candidate))
}
pub(crate) async fn install_offline_runtime(&self, config: OfflineRuntimeConfig) -> Result<()> {
ensure!(
self.transport_config().await.network_policy == crate::client::NetworkPolicy::LocalOnly,
"offline runtime requires the LocalOnly network policy"
);
let local_device_id = required(&config.local_device_id, "local device id", 192)?;
let local_endpoint_id = self
.current_node_id()
.await
.ok_or_else(|| anyhow!("Iroh endpoint must be started before offline runtime"))?;
let at_ms = crate::coordination::now_millis_u64();
let mut runtime_guard = self.offline_runtime.lock().await;
let trust_domain = {
let trust = config
.trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?;
let credential = trust.trust().credential(&local_device_id, at_ms)?;
ensure!(
credential.body.endpoint_id == local_endpoint_id,
"local offline credential is bound to a different Iroh endpoint"
);
ensure!(
public_key(&credential.body.proof_public_key)? == config.signer.verifying_key()?,
"offline signer does not match the local credential"
);
credential.body.trust_domain.clone()
};
if let Some(current) = runtime_guard.as_ref() {
if current.local_device_id == local_device_id
&& Arc::ptr_eq(¤t.signer, &config.signer)
&& Arc::ptr_eq(¤t.trust, &config.trust)
{
ensure!(
self.native_external_auto_connect_owner_is_current(
&format!("offline:{trust_domain}"),
&local_device_id,
)
.await,
"offline runtime owner is no longer current; explicit Rust-owned retirement is required"
);
return Ok(());
}
return Err(anyhow!(
"offline runtime is already installed; replacement requires an explicit Rust-owned retirement transaction"
));
}
std::sync::Arc::new(self.clone())
.start_external_auto_connect(format!("offline:{trust_domain}"), local_device_id.clone())
.await?;
*runtime_guard = Some(InstalledOfflineRuntime {
local_device_id,
signer: config.signer,
trust: config.trust,
desired_revision: 0,
candidates: BTreeMap::new(),
});
drop(runtime_guard);
#[cfg(feature = "transport-lan")]
for endpoint_addr in self.local_discovery_registry.endpoint_addrs().await {
let _ = self.observe_offline_lan_candidate(endpoint_addr).await;
}
Ok(())
}
#[cfg(feature = "transport-lan")]
pub(crate) async fn observe_offline_lan_candidate(
&self,
endpoint_addr: iroh::EndpointAddr,
) -> Result<bool> {
ensure!(
self.transport_config().await.network_policy == crate::client::NetworkPolicy::LocalOnly,
"offline LAN observations require the LocalOnly network policy"
);
let at_ms = crate::coordination::now_millis_u64();
let trust = self
.offline_runtime
.lock()
.await
.as_ref()
.map(|runtime| runtime.trust.clone())
.ok_or_else(|| anyhow!("offline runtime is not installed"))?;
let device_id = {
let trust = trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?;
trust
.trust()
.device_id_for_endpoint(&endpoint_addr.id.to_string(), at_ms)?
};
let handoff = {
let trust = trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?;
trust
.trust()
.authorize_local_candidate(&device_id, endpoint_addr.clone(), at_ms)?
};
self.register_offline_candidate_requirement(handoff.clone())
.await
.map_err(anyhow::Error::msg)?;
let (revision, peers_json, changed) = {
let mut guard = self.offline_runtime.lock().await;
let runtime = guard
.as_mut()
.ok_or_else(|| anyhow!("offline runtime was removed"))?;
let ticket =
iroh_tickets::endpoint::EndpointTicket::new(endpoint_addr.clone()).to_string();
let changed = runtime
.candidates
.get(&device_id)
.map(|current| {
iroh_tickets::endpoint::EndpointTicket::new(current.endpoint_addr.clone())
.to_string()
!= ticket
|| !current.handoff.same_authority(&handoff)
|| !current.reachable
})
.unwrap_or(true);
if !changed {
return Ok(false);
}
runtime.candidates.insert(
device_id,
OfflineDesiredCandidate {
handoff,
endpoint_addr,
reachable: true,
},
);
runtime.desired_revision = runtime.desired_revision.saturating_add(1).max(1);
(
runtime.desired_revision,
offline_desired_peers_json(&runtime.candidates)?,
changed,
)
};
let _ = std::sync::Arc::new(self.clone())
.submit_external_desired_peers(revision, &peers_json)
.await?;
Ok(changed)
}
#[cfg(feature = "transport-lan")]
pub(crate) async fn expire_offline_lan_candidate(
&self,
endpoint_id: iroh::EndpointId,
) -> Result<bool> {
let (device_id, revision, peers_json) = {
let mut guard = self.offline_runtime.lock().await;
let runtime = guard
.as_mut()
.ok_or_else(|| anyhow!("offline runtime is not installed"))?;
let Some((device_id, candidate)) = runtime
.candidates
.iter_mut()
.find(|(_, candidate)| candidate.endpoint_addr.id == endpoint_id)
else {
return Ok(false);
};
if !candidate.reachable {
return Ok(false);
}
candidate.reachable = false;
let device_id = device_id.clone();
runtime.desired_revision = runtime.desired_revision.saturating_add(1).max(1);
(
device_id,
runtime.desired_revision,
offline_desired_peers_json(&runtime.candidates)?,
)
};
if let Some(local_endpoint_id) = self.current_node_id().await {
let connection_id =
Self::deterministic_connection_id(&local_endpoint_id, &endpoint_id.to_string());
self.offline_proof_attempts
.lock()
.await
.remove(&connection_id);
}
let _ = std::sync::Arc::new(self.clone())
.submit_external_desired_peer_observation_expired(revision, &peers_json, &device_id)
.await?;
Ok(true)
}
pub(crate) async fn retire_offline_device(&self, device_id: &str) -> Result<bool> {
let device_id = required(device_id, "offline device id", 192)?;
let (removed, revision, peers_json, node_id) = {
let mut guard = self.offline_runtime.lock().await;
let runtime = guard
.as_mut()
.ok_or_else(|| anyhow!("offline runtime is not installed"))?;
let removed = runtime.candidates.remove(&device_id);
let Some(removed) = removed else {
return Ok(false);
};
runtime.desired_revision = runtime.desired_revision.saturating_add(1).max(1);
(
removed.handoff,
runtime.desired_revision,
offline_desired_peers_json(&runtime.candidates)?,
removed.endpoint_addr.id.to_string(),
)
};
self.retire_offline_candidate_requirement(&removed).await;
let _ = std::sync::Arc::new(self.clone())
.submit_external_desired_peers(revision, &peers_json)
.await?;
self.retire_offline_desired_route(&device_id, Some(&node_id))
.await;
Ok(true)
}
pub(crate) async fn apply_offline_trust_bundle(
&self,
bundle: OfflineTrustBundle,
at_ms: u64,
) -> Result<OfflineTrustHighWater> {
let (trust, candidates) = {
let guard = self.offline_runtime.lock().await;
let runtime = guard
.as_ref()
.ok_or_else(|| anyhow!("offline runtime is not installed"))?;
(runtime.trust.clone(), runtime.candidates.clone())
};
let prior = {
let trust = trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?;
candidates
.keys()
.filter_map(|device_id| {
trust
.trust()
.credential(device_id, at_ms)
.ok()
.map(|credential| (device_id.clone(), credential.clone()))
})
.collect::<BTreeMap<_, _>>()
};
let high_water = trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?
.apply(bundle, at_ms)?;
let mut retire = Vec::new();
for (device_id, candidate) in &candidates {
let next = trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?
.trust()
.credential(device_id, at_ms)
.cloned();
let changed_identity = match (prior.get(device_id), next.as_ref().ok()) {
(Some(previous), Some(current)) => {
previous.body.serial != current.body.serial
|| previous.body.endpoint_id != current.body.endpoint_id
|| previous.body.proof_public_key != current.body.proof_public_key
}
_ => true,
};
if changed_identity {
retire.push(device_id.clone());
} else {
#[cfg(feature = "transport-lan")]
{
let refreshed = trust
.lock()
.map_err(|_| anyhow!("offline trust state lock poisoned"))?
.trust()
.authorize_local_candidate(
device_id,
candidate.endpoint_addr.clone(),
at_ms,
)?;
self.register_offline_candidate_requirement(refreshed.clone())
.await
.map_err(anyhow::Error::msg)?;
let (revision, peers_json) = {
let mut guard = self.offline_runtime.lock().await;
let runtime = guard
.as_mut()
.ok_or_else(|| anyhow!("offline runtime was removed"))?;
let current = runtime
.candidates
.get_mut(device_id)
.ok_or_else(|| anyhow!("offline candidate disappeared"))?;
current.handoff = refreshed;
runtime.desired_revision =
runtime.desired_revision.saturating_add(1).max(1);
(
runtime.desired_revision,
offline_desired_peers_json(&runtime.candidates)?,
)
};
let _ = std::sync::Arc::new(self.clone())
.submit_external_desired_peers(revision, &peers_json)
.await?;
}
#[cfg(not(feature = "transport-lan"))]
let _ = (device_id, candidate);
}
}
for device_id in retire {
self.retire_offline_device(&device_id).await?;
}
Ok(high_water)
}
}
fn required(value: &str, label: &str, max: usize) -> Result<String> {
let value = value.trim();
ensure!(!value.is_empty(), "{label} is required");
ensure!(value.len() <= max, "{label} exceeds {max} bytes");
ensure!(
!value.chars().any(char::is_control),
"{label} contains control characters"
);
Ok(value.to_string())
}
fn canonical_bytes<T: Serialize>(domain: &str, value: &T) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(domain.as_bytes());
out.push(0);
out.extend_from_slice(&serde_json::to_vec(value).context("encode signed offline payload")?);
Ok(out)
}
fn public_key(value: &str) -> Result<VerifyingKey> {
let bytes = URL_SAFE_NO_PAD
.decode(value)
.context("decode Ed25519 public key")?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow!("Ed25519 public key must contain 32 bytes"))?;
VerifyingKey::from_bytes(&bytes).context("parse Ed25519 public key")
}
fn signature(value: &str) -> Result<Signature> {
let bytes = URL_SAFE_NO_PAD
.decode(value)
.context("decode Ed25519 signature")?;
Signature::from_slice(&bytes).context("parse Ed25519 signature")
}
fn key_id(key: &VerifyingKey) -> String {
format!(
"ed25519:{}",
hex::encode(&Sha256::digest(key.as_bytes())[..12])
)
}
fn digest_json<T: Serialize>(value: &T) -> Result<String> {
Ok(hex::encode(Sha256::digest(
serde_json::to_vec(value).context("encode offline digest payload")?,
)))
}
fn normalized_roles(roles: impl IntoIterator<Item = String>) -> Result<Vec<String>> {
let mut roles = roles
.into_iter()
.map(|role| required(&role, "offline role", 64))
.collect::<Result<BTreeSet<_>>>()?
.into_iter()
.collect::<Vec<_>>();
ensure!(roles.len() <= MAX_OFFLINE_ROLES, "too many offline roles");
roles.shrink_to_fit();
Ok(roles)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum OfflineAssurance {
Software,
HardwareBacked,
Manufacturer,
Gateway,
}
pub trait OfflineSigner: Send + Sync {
fn verifying_key(&self) -> Result<VerifyingKey>;
fn sign(&self, message: &[u8]) -> Result<Signature>;
}
pub struct SoftwareOfflineSigner(SigningKey);
impl SoftwareOfflineSigner {
pub fn generate() -> Result<Self> {
let mut seed = [0u8; 32];
getrandom::getrandom(&mut seed)
.map_err(|error| anyhow!("generate offline device key: {error}"))?;
Ok(Self(SigningKey::from_bytes(&seed)))
}
#[cfg(test)]
fn from_seed(seed: [u8; 32]) -> Self {
Self(SigningKey::from_bytes(&seed))
}
}
impl OfflineSigner for SoftwareOfflineSigner {
fn verifying_key(&self) -> Result<VerifyingKey> {
Ok(self.0.verifying_key())
}
fn sign(&self, message: &[u8]) -> Result<Signature> {
Ok(self.0.sign(message))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineEnrollmentBody {
pub protocol: String,
pub trust_domain: String,
pub device_id: String,
pub endpoint_id: String,
pub proof_public_key: String,
pub enrollment_nonce: String,
pub requested_roles: Vec<String>,
pub requested_assurance: OfflineAssurance,
pub created_at_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineEnrollmentRequest {
pub body: OfflineEnrollmentBody,
pub proof_signature: String,
}
impl OfflineEnrollmentRequest {
#[allow(clippy::too_many_arguments)]
pub fn create(
signer: &dyn OfflineSigner,
trust_domain: &str,
device_id: &str,
endpoint_id: &str,
enrollment_nonce: &str,
requested_roles: Vec<String>,
requested_assurance: OfflineAssurance,
created_at_ms: u64,
) -> Result<Self> {
let proof_key = signer.verifying_key()?;
let body = OfflineEnrollmentBody {
protocol: OFFLINE_DEVICE_PROTOCOL.to_string(),
trust_domain: required(trust_domain, "trust domain", 128)?,
device_id: required(device_id, "device id", 192)?,
endpoint_id: required(endpoint_id, "endpoint id", 192)?,
proof_public_key: URL_SAFE_NO_PAD.encode(proof_key.as_bytes()),
enrollment_nonce: required(enrollment_nonce, "enrollment nonce", 192)?,
requested_roles: normalized_roles(requested_roles)?,
requested_assurance,
created_at_ms,
};
let signed = canonical_bytes("openrtc:offline-enrollment:v1", &body)?;
Ok(Self {
body,
proof_signature: URL_SAFE_NO_PAD.encode(signer.sign(&signed)?.to_bytes()),
})
}
pub fn verify(&self) -> Result<VerifyingKey> {
ensure!(
self.body.protocol == OFFLINE_DEVICE_PROTOCOL,
"unsupported offline enrollment protocol"
);
required(&self.body.trust_domain, "trust domain", 128)?;
required(&self.body.device_id, "device id", 192)?;
required(&self.body.endpoint_id, "endpoint id", 192)?;
required(&self.body.enrollment_nonce, "enrollment nonce", 192)?;
ensure!(
normalized_roles(self.body.requested_roles.clone())? == self.body.requested_roles,
"offline enrollment roles are not canonical"
);
let key = public_key(&self.body.proof_public_key)?;
key.verify(
&canonical_bytes("openrtc:offline-enrollment:v1", &self.body)?,
&signature(&self.proof_signature)?,
)
.context("verify enrollment proof of possession")?;
Ok(key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineDeviceCredentialBody {
pub protocol: String,
pub trust_domain: String,
pub serial: String,
pub issuer_key_id: String,
pub trust_generation: u64,
pub device_id: String,
pub endpoint_id: String,
pub proof_public_key: String,
pub roles: Vec<String>,
pub assurance: OfflineAssurance,
pub not_before_ms: u64,
pub expires_at_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineDeviceCredential {
pub body: OfflineDeviceCredentialBody,
pub issuer_signature: String,
}
impl OfflineDeviceCredential {
#[allow(clippy::too_many_arguments)]
pub fn issue(
issuer: &dyn OfflineSigner,
request: &OfflineEnrollmentRequest,
serial: &str,
trust_generation: u64,
roles: Vec<String>,
assurance: OfflineAssurance,
not_before_ms: u64,
expires_at_ms: u64,
) -> Result<Self> {
request.verify()?;
ensure!(
expires_at_ms > not_before_ms,
"credential expiry is invalid"
);
ensure!(
trust_generation > 0,
"credential generation must be positive"
);
let roles = normalized_roles(roles)?;
ensure!(
roles
.iter()
.all(|role| request.body.requested_roles.contains(role)),
"credential grants a role the device did not request"
);
let issuer_key = issuer.verifying_key()?;
let body = OfflineDeviceCredentialBody {
protocol: OFFLINE_DEVICE_PROTOCOL.to_string(),
trust_domain: request.body.trust_domain.clone(),
serial: required(serial, "credential serial", 192)?,
issuer_key_id: key_id(&issuer_key),
trust_generation,
device_id: request.body.device_id.clone(),
endpoint_id: request.body.endpoint_id.clone(),
proof_public_key: request.body.proof_public_key.clone(),
roles,
assurance,
not_before_ms,
expires_at_ms,
};
let signed = canonical_bytes("openrtc:offline-credential:v1", &body)?;
Ok(Self {
body,
issuer_signature: URL_SAFE_NO_PAD.encode(issuer.sign(&signed)?.to_bytes()),
})
}
pub fn verify(&self, issuer: &VerifyingKey, at_ms: u64) -> Result<VerifyingKey> {
ensure!(
self.body.not_before_ms <= at_ms,
"credential is not active yet"
);
ensure!(at_ms < self.body.expires_at_ms, "credential expired");
self.verify_signed(issuer)
}
fn verify_signed(&self, issuer: &VerifyingKey) -> Result<VerifyingKey> {
ensure!(
self.body.protocol == OFFLINE_DEVICE_PROTOCOL,
"unsupported credential"
);
ensure!(
self.body.issuer_key_id == key_id(issuer),
"credential issuer mismatch"
);
ensure!(
self.body.expires_at_ms > self.body.not_before_ms,
"credential expiry is invalid"
);
ensure!(
self.body.trust_generation > 0,
"credential generation is invalid"
);
required(&self.body.trust_domain, "trust domain", 128)?;
required(&self.body.serial, "credential serial", 192)?;
required(&self.body.device_id, "device id", 192)?;
required(&self.body.endpoint_id, "endpoint id", 192)?;
ensure!(
normalized_roles(self.body.roles.clone())? == self.body.roles,
"credential roles are not canonical"
);
issuer
.verify(
&canonical_bytes("openrtc:offline-credential:v1", &self.body)?,
&signature(&self.issuer_signature)?,
)
.context("verify device credential")?;
public_key(&self.body.proof_public_key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineTrustBundleBody {
pub protocol: String,
pub trust_domain: String,
pub issuer_public_key: String,
pub issuer_key_id: String,
pub generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recovery_from_key_id: Option<String>,
pub credentials: Vec<OfflineDeviceCredential>,
pub revoked_serials: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineTrustBundle {
pub body: OfflineTrustBundleBody,
pub issuer_signature: String,
}
impl OfflineTrustBundle {
#[allow(clippy::too_many_arguments)]
pub fn issue(
issuer: &dyn OfflineSigner,
trust_domain: &str,
generation: u64,
parent_digest: Option<String>,
recovery_from_key_id: Option<String>,
credentials: Vec<OfflineDeviceCredential>,
revoked_serials: Vec<String>,
) -> Result<Self> {
ensure!(generation > 0, "trust-bundle generation must be positive");
ensure!(
credentials.len() <= MAX_OFFLINE_CREDENTIALS,
"too many credentials"
);
ensure!(
revoked_serials.len() <= MAX_OFFLINE_REVOKED_SERIALS,
"too many revoked credential serials"
);
let issuer_key = issuer.verifying_key()?;
let revoked_serials = revoked_serials
.into_iter()
.map(|serial| required(&serial, "revoked serial", 192))
.collect::<Result<BTreeSet<_>>>()?
.into_iter()
.collect();
let body = OfflineTrustBundleBody {
protocol: OFFLINE_TRUST_BUNDLE_PROTOCOL.to_string(),
trust_domain: required(trust_domain, "trust domain", 128)?,
issuer_public_key: URL_SAFE_NO_PAD.encode(issuer_key.as_bytes()),
issuer_key_id: key_id(&issuer_key),
generation,
parent_digest,
recovery_from_key_id,
credentials,
revoked_serials,
};
let signed = canonical_bytes("openrtc:offline-trust-bundle:v1", &body)?;
Ok(Self {
body,
issuer_signature: URL_SAFE_NO_PAD.encode(issuer.sign(&signed)?.to_bytes()),
})
}
pub fn digest(&self) -> Result<String> {
digest_json(self)
}
pub fn verify(&self, at_ms: u64) -> Result<VerifyingKey> {
let issuer = self.verify_signed()?;
for credential in &self.body.credentials {
credential.verify(&issuer, at_ms)?;
}
Ok(issuer)
}
fn verify_signed(&self) -> Result<VerifyingKey> {
ensure!(
self.body.protocol == OFFLINE_TRUST_BUNDLE_PROTOCOL,
"unsupported trust-bundle protocol"
);
ensure!(
self.body.generation > 0,
"trust-bundle generation is invalid"
);
ensure!(
self.body.credentials.len() <= MAX_OFFLINE_CREDENTIALS,
"too many credentials"
);
ensure!(
self.body.revoked_serials.len() <= MAX_OFFLINE_REVOKED_SERIALS,
"too many revoked credential serials"
);
required(&self.body.trust_domain, "trust domain", 128)?;
let issuer = public_key(&self.body.issuer_public_key)?;
ensure!(
self.body.issuer_key_id == key_id(&issuer),
"trust-bundle issuer mismatch"
);
issuer
.verify(
&canonical_bytes("openrtc:offline-trust-bundle:v1", &self.body)?,
&signature(&self.issuer_signature)?,
)
.context("verify trust bundle")?;
let revoked = self
.body
.revoked_serials
.iter()
.map(|serial| required(serial, "revoked serial", 192))
.collect::<Result<BTreeSet<_>>>()?;
ensure!(
revoked.len() == self.body.revoked_serials.len(),
"revoked serials are not canonical"
);
let mut devices = BTreeSet::new();
let mut serials = BTreeSet::new();
for credential in &self.body.credentials {
credential.verify_signed(&issuer)?;
ensure!(
credential.body.trust_domain == self.body.trust_domain,
"credential trust domain mismatch"
);
ensure!(
credential.body.trust_generation <= self.body.generation,
"credential generation is newer than its trust bundle"
);
ensure!(
devices.insert(&credential.body.device_id),
"duplicate device credential"
);
ensure!(
serials.insert(&credential.body.serial),
"duplicate credential serial"
);
ensure!(
!revoked.contains(&credential.body.serial),
"trust bundle includes a revoked credential"
);
}
Ok(issuer)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineTrustHighWater {
pub trust_domain: String,
pub issuer_key_id: String,
pub generation: u64,
pub digest: String,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct OfflineCandidateHandoff {
trust_domain: String,
trust_generation: u64,
credential_serial: String,
device_id: String,
endpoint_addr: iroh::EndpointAddr,
roles: Vec<String>,
assurance: OfflineAssurance,
}
#[cfg(not(target_arch = "wasm32"))]
impl OfflineCandidateHandoff {
pub fn device_id(&self) -> &str {
&self.device_id
}
pub fn endpoint_addr(&self) -> &iroh::EndpointAddr {
&self.endpoint_addr
}
pub fn trust_generation(&self) -> u64 {
self.trust_generation
}
pub(crate) fn same_authority(&self, other: &Self) -> bool {
self.trust_domain == other.trust_domain
&& self.trust_generation == other.trust_generation
&& self.credential_serial == other.credential_serial
&& self.device_id == other.device_id
&& self.endpoint_addr.id == other.endpoint_addr.id
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineTransportBinding {
pub local_endpoint_id: String,
pub remote_endpoint_id: String,
pub transport_stable_id: u64,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct OfflineAdmissionHandoff {
candidate: OfflineCandidateHandoff,
binding: OfflineTransportBinding,
replay_id: String,
}
#[cfg(not(target_arch = "wasm32"))]
impl OfflineAdmissionHandoff {
pub fn device_id(&self) -> &str {
self.candidate.device_id()
}
pub fn remote_endpoint_id(&self) -> &str {
&self.binding.remote_endpoint_id
}
pub fn transport_stable_id(&self) -> u64 {
self.binding.transport_stable_id
}
pub fn roles(&self) -> &[String] {
&self.candidate.roles
}
pub fn assurance(&self) -> OfflineAssurance {
self.candidate.assurance
}
pub fn replay_id(&self) -> &str {
&self.replay_id
}
pub(crate) fn candidate(&self) -> &OfflineCandidateHandoff {
&self.candidate
}
pub(crate) fn binding(&self) -> &OfflineTransportBinding {
&self.binding
}
}
#[derive(Debug, Clone)]
pub struct OfflineTrustState {
pinned_trust_domain: String,
pinned_issuer: VerifyingKey,
recovery_issuers: BTreeMap<String, VerifyingKey>,
current: Option<OfflineTrustBundle>,
}
impl OfflineTrustState {
pub fn new(trust_domain: &str, issuer: VerifyingKey) -> Result<Self> {
Ok(Self {
pinned_trust_domain: required(trust_domain, "trust domain", 128)?,
pinned_issuer: issuer,
recovery_issuers: BTreeMap::new(),
current: None,
})
}
pub fn allow_recovery_issuer(&mut self, issuer: VerifyingKey) {
self.recovery_issuers.insert(key_id(&issuer), issuer);
}
pub fn high_water(&self) -> Result<Option<OfflineTrustHighWater>> {
self.current
.as_ref()
.map(|bundle| {
Ok(OfflineTrustHighWater {
trust_domain: bundle.body.trust_domain.clone(),
issuer_key_id: bundle.body.issuer_key_id.clone(),
generation: bundle.body.generation,
digest: bundle.digest()?,
})
})
.transpose()
}
pub fn apply(&mut self, next: OfflineTrustBundle, at_ms: u64) -> Result<OfflineTrustHighWater> {
let next_issuer = next.verify(at_ms)?;
self.apply_verified(next, next_issuer)
}
#[cfg(not(target_arch = "wasm32"))]
fn apply_historical(&mut self, next: OfflineTrustBundle) -> Result<OfflineTrustHighWater> {
let next_issuer = next.verify_signed()?;
self.apply_verified(next, next_issuer)
}
fn apply_verified(
&mut self,
next: OfflineTrustBundle,
next_issuer: VerifyingKey,
) -> Result<OfflineTrustHighWater> {
ensure!(
next.body.trust_domain == self.pinned_trust_domain,
"trust-domain substitution rejected"
);
let next_digest = next.digest()?;
match &self.current {
None => {
ensure!(
next_issuer == self.pinned_issuer && next.body.recovery_from_key_id.is_none(),
"initial trust bundle must use the pinned issuer"
);
ensure!(
next.body.parent_digest.is_none(),
"initial bundle has a parent"
);
}
Some(current) => {
let current_digest = current.digest()?;
ensure!(
next.body.generation > current.body.generation,
if next.body.generation == current.body.generation
&& next_digest != current_digest
{
"equal-generation trust-bundle fork rejected"
} else {
"trust-bundle rollback rejected"
}
);
ensure!(
next.body.parent_digest.as_deref() == Some(current_digest.as_str()),
"broken trust-bundle lineage rejected"
);
if next.body.issuer_key_id != current.body.issuer_key_id {
ensure!(
next.body.recovery_from_key_id.as_deref()
== Some(current.body.issuer_key_id.as_str()),
"issuer substitution rejected"
);
ensure!(
self.recovery_issuers.get(&next.body.issuer_key_id) == Some(&next_issuer),
"unauthorized recovery issuer rejected"
);
} else {
ensure!(
next.body.recovery_from_key_id.is_none(),
"ordinary trust update cannot claim recovery"
);
}
}
}
let high_water = OfflineTrustHighWater {
trust_domain: next.body.trust_domain.clone(),
issuer_key_id: next.body.issuer_key_id.clone(),
generation: next.body.generation,
digest: next_digest,
};
self.current = Some(next);
Ok(high_water)
}
pub fn credential(&self, device_id: &str, at_ms: u64) -> Result<&OfflineDeviceCredential> {
let bundle = self
.current
.as_ref()
.ok_or_else(|| anyhow!("no trust bundle installed"))?;
let issuer = bundle.verify(at_ms)?;
let credential = bundle
.body
.credentials
.iter()
.find(|credential| credential.body.device_id == device_id)
.ok_or_else(|| anyhow!("device is not in the current trust bundle"))?;
ensure!(
!bundle
.body
.revoked_serials
.contains(&credential.body.serial),
"device credential is revoked"
);
credential.verify(&issuer, at_ms)?;
Ok(credential)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
pub(crate) fn device_id_for_endpoint(&self, endpoint_id: &str, at_ms: u64) -> Result<String> {
let bundle = self
.current
.as_ref()
.ok_or_else(|| anyhow!("no trust bundle installed"))?;
let credential = bundle
.body
.credentials
.iter()
.find(|credential| credential.body.endpoint_id == endpoint_id)
.ok_or_else(|| anyhow!("local observation is not in the current trust bundle"))?;
self.credential(&credential.body.device_id, at_ms)?;
Ok(credential.body.device_id.clone())
}
pub fn verify_connection_proof(
&self,
proof: &OfflineConnectionProof,
expected: &OfflineProofTranscript,
replay_cache: &mut OfflineReplayCache,
at_ms: u64,
) -> Result<OfflineAdmission> {
let credential = self.credential(&expected.presenter_device_id, at_ms)?;
verify_connection_proof(proof, credential, expected, replay_cache)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
pub fn authorize_local_candidate(
&self,
device_id: &str,
endpoint_addr: iroh::EndpointAddr,
at_ms: u64,
) -> Result<OfflineCandidateHandoff> {
ensure!(
crate::local_discovery::endpoint_addr_is_local_only(&endpoint_addr, &[]),
"offline candidate contains a non-local address"
);
let credential = self.credential(device_id, at_ms)?;
ensure!(
credential.body.endpoint_id == endpoint_addr.id.to_string(),
"offline candidate endpoint does not match its credential"
);
let generation = self
.current
.as_ref()
.map(|bundle| bundle.body.generation)
.ok_or_else(|| anyhow!("no trust bundle installed"))?;
Ok(OfflineCandidateHandoff {
trust_domain: credential.body.trust_domain.clone(),
trust_generation: generation,
credential_serial: credential.body.serial.clone(),
device_id: credential.body.device_id.clone(),
endpoint_addr,
roles: credential.body.roles.clone(),
assurance: credential.body.assurance,
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn verify_transport_proof(
&self,
candidate: &OfflineCandidateHandoff,
proof: &OfflineConnectionProof,
expected: &OfflineProofTranscript,
binding: OfflineTransportBinding,
replay_cache: &mut OfflineReplayCache,
at_ms: u64,
) -> Result<OfflineAdmissionHandoff> {
ensure!(
binding.transport_stable_id > 0,
"invalid transport generation"
);
ensure!(
expected.presenter_endpoint_id == binding.remote_endpoint_id
&& expected.verifier_endpoint_id == binding.local_endpoint_id,
"offline proof is not bound to the supplied transport endpoints"
);
ensure!(
expected.transport_stable_id == binding.transport_stable_id,
"offline proof is not bound to the supplied transport generation"
);
ensure!(
candidate.endpoint_addr.id.to_string() == binding.remote_endpoint_id,
"offline candidate changed endpoints"
);
let current = self
.credential(candidate.device_id(), at_ms)
.context("offline candidate credential is no longer current")?;
ensure!(
current.body.serial == candidate.credential_serial
&& current.body.trust_domain == candidate.trust_domain,
"offline candidate trust facts changed"
);
let current_generation = self
.current
.as_ref()
.map(|bundle| bundle.body.generation)
.ok_or_else(|| anyhow!("no trust bundle installed"))?;
ensure!(
current_generation == candidate.trust_generation,
"offline candidate trust generation is stale"
);
let admission = verify_connection_proof(proof, current, expected, replay_cache)?;
ensure!(
admission.authoritative_device_id == candidate.device_id,
"offline admission device mismatch"
);
Ok(OfflineAdmissionHandoff {
candidate: candidate.clone(),
binding,
replay_id: proof.replay_id()?,
})
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OfflineTrustJournal {
protocol: String,
history: Vec<OfflineTrustBundle>,
high_water: OfflineTrustHighWater,
#[serde(default)]
replay_ids: Vec<String>,
}
#[cfg(not(target_arch = "wasm32"))]
fn read_bounded_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())),
};
ensure!(
!metadata.file_type().is_symlink(),
"offline trust path is a symlink"
);
ensure!(
metadata.len() <= MAX_OFFLINE_TRUST_JOURNAL_BYTES,
"offline trust record exceeds the size bound"
);
serde_json::from_slice(
&std::fs::read(path).with_context(|| format!("read {}", path.display()))?,
)
.with_context(|| format!("decode {}", path.display()))
.map(Some)
}
#[cfg(not(target_arch = "wasm32"))]
fn write_private_json(path: &Path, value: &impl Serialize) -> Result<()> {
use std::io::Write as _;
let payload = serde_json::to_vec(value).context("encode offline trust record")?;
ensure!(
payload.len() as u64 <= MAX_OFFLINE_TRUST_JOURNAL_BYTES,
"offline trust record exceeds the size bound"
);
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)
.with_context(|| format!("create offline trust directory {}", parent.display()))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("offline trust path requires a file name"))?;
let temp = parent.join(format!(
".{file_name}.tmp-{}-{}",
std::process::id(),
crate::session_token::generate_nonce()
));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let result = (|| {
let mut file = options
.open(&temp)
.with_context(|| format!("create {}", temp.display()))?;
file.write_all(&payload)
.with_context(|| format!("write {}", temp.display()))?;
file.sync_all()
.with_context(|| format!("sync {}", temp.display()))?;
drop(file);
#[cfg(windows)]
if path.exists() {
std::fs::remove_file(path).with_context(|| format!("replace {}", path.display()))?;
}
std::fs::rename(&temp, path).with_context(|| format!("install {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("secure {}", path.display()))?;
std::fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.with_context(|| format!("sync directory {}", parent.display()))?;
}
Ok::<(), anyhow::Error>(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
#[cfg(not(target_arch = "wasm32"))]
pub struct DurableOfflineTrustState {
state_path: PathBuf,
high_water_path: PathBuf,
state: OfflineTrustState,
history: Vec<OfflineTrustBundle>,
replay_cache: OfflineReplayCache,
}
#[cfg(not(target_arch = "wasm32"))]
impl DurableOfflineTrustState {
pub fn open(
state_path: impl Into<PathBuf>,
trust_domain: &str,
pinned_issuer: VerifyingKey,
recovery_issuers: impl IntoIterator<Item = VerifyingKey>,
at_ms: u64,
) -> Result<Self> {
let state_path = state_path.into();
let high_water_path = state_path.with_extension("high-water.json");
let persisted_anchor = read_bounded_json::<OfflineTrustHighWater>(&high_water_path)?;
let journal = read_bounded_json::<OfflineTrustJournal>(&state_path)?;
ensure!(
journal.is_some() || persisted_anchor.is_none(),
"offline trust journal is missing behind its high-water anchor"
);
let recovery_issuers = recovery_issuers.into_iter().collect::<Vec<_>>();
let mut state = OfflineTrustState::new(trust_domain, pinned_issuer)?;
for issuer in recovery_issuers {
state.allow_recovery_issuer(issuer);
}
let mut history = Vec::new();
let mut replay_cache = OfflineReplayCache::default();
let mut accepted = Vec::new();
if let Some(journal) = journal {
ensure!(
journal.protocol == OFFLINE_TRUST_JOURNAL_PROTOCOL,
"unsupported offline trust journal"
);
ensure!(
!journal.history.is_empty(),
"offline trust journal is empty"
);
ensure!(
journal.history.len() <= MAX_OFFLINE_TRUST_HISTORY,
"offline trust journal exceeds the history bound"
);
for bundle in journal.history.iter().cloned() {
accepted.push(state.apply_historical(bundle)?);
}
ensure!(
accepted.last() == Some(&journal.high_water),
"offline trust journal high-water mismatch"
);
replay_cache =
OfflineReplayCache::from_entries(MAX_OFFLINE_REPLAY_ENTRIES, journal.replay_ids)?;
history = journal.history;
if let Some(anchor) = persisted_anchor.as_ref() {
ensure!(
accepted.iter().any(|water| water == anchor),
"offline trust rollback or fork rejected by high-water anchor"
);
}
state
.current
.as_ref()
.expect("non-empty accepted trust history")
.verify(at_ms)?;
if persisted_anchor.as_ref() != accepted.last() {
write_private_json(
&high_water_path,
accepted.last().expect("non-empty accepted trust history"),
)?;
}
}
Ok(Self {
state_path,
high_water_path,
state,
history,
replay_cache,
})
}
pub fn trust(&self) -> &OfflineTrustState {
&self.state
}
pub fn apply(&mut self, next: OfflineTrustBundle, at_ms: u64) -> Result<OfflineTrustHighWater> {
ensure!(
self.history.len() < MAX_OFFLINE_TRUST_HISTORY,
"offline trust journal history is full"
);
let mut state = self.state.clone();
let high_water = state.apply(next.clone(), at_ms)?;
let mut history = self.history.clone();
history.push(next);
let journal = OfflineTrustJournal {
protocol: OFFLINE_TRUST_JOURNAL_PROTOCOL.to_string(),
history: history.clone(),
high_water: high_water.clone(),
replay_ids: self.replay_cache.entries(),
};
write_private_json(&self.state_path, &journal)?;
let anchor_result = write_private_json(&self.high_water_path, &high_water);
self.state = state;
self.history = history;
anchor_result?;
Ok(high_water)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn verify_transport_proof(
&mut self,
candidate: &OfflineCandidateHandoff,
proof: &OfflineConnectionProof,
expected: &OfflineProofTranscript,
binding: OfflineTransportBinding,
at_ms: u64,
) -> Result<OfflineAdmissionHandoff> {
let mut replay_cache = self.replay_cache.clone();
let handoff = self.state.verify_transport_proof(
candidate,
proof,
expected,
binding,
&mut replay_cache,
at_ms,
)?;
let high_water = self
.state
.high_water()?
.ok_or_else(|| anyhow!("no trust bundle installed"))?;
let journal = OfflineTrustJournal {
protocol: OFFLINE_TRUST_JOURNAL_PROTOCOL.to_string(),
history: self.history.clone(),
high_water,
replay_ids: replay_cache.entries(),
};
write_private_json(&self.state_path, &journal)?;
self.replay_cache = replay_cache;
Ok(handoff)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineProofTranscript {
pub protocol: String,
pub trust_domain: String,
pub credential_serial: String,
pub presenter_device_id: String,
pub presenter_endpoint_id: String,
pub verifier_device_id: String,
pub verifier_endpoint_id: String,
pub presenter_nonce: String,
pub verifier_nonce: String,
pub transport_stable_id: u64,
pub channel_binding: String,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfflineProofChallenge {
pub(crate) protocol: String,
pub(crate) request_id: String,
pub(crate) verifier_device_id: String,
pub(crate) verifier_endpoint_id: String,
pub(crate) verifier_nonce: String,
pub(crate) transport_stable_id: u64,
pub(crate) channel_binding: String,
}
#[cfg(not(target_arch = "wasm32"))]
impl OfflineProofChallenge {
pub(crate) fn validate(&self) -> Result<()> {
ensure!(
self.protocol == OFFLINE_PROOF_PROTOCOL,
"unsupported offline proof challenge"
);
required(&self.request_id, "offline proof request id", 192)?;
required(&self.verifier_device_id, "verifier device id", 192)?;
required(&self.verifier_endpoint_id, "verifier endpoint id", 192)?;
required(&self.verifier_nonce, "verifier nonce", 192)?;
ensure!(
self.transport_stable_id > 0,
"offline proof challenge generation is invalid"
);
required(&self.channel_binding, "channel binding", 512)?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfflineConnectionProof {
pub transcript: OfflineProofTranscript,
pub signature: String,
}
impl OfflineConnectionProof {
pub fn create(signer: &dyn OfflineSigner, transcript: OfflineProofTranscript) -> Result<Self> {
validate_transcript(&transcript)?;
Ok(Self {
signature: URL_SAFE_NO_PAD.encode(
signer
.sign(&canonical_bytes("openrtc:offline-proof:v1", &transcript)?)?
.to_bytes(),
),
transcript,
})
}
pub fn replay_id(&self) -> Result<String> {
digest_json(self)
}
}
fn validate_transcript(transcript: &OfflineProofTranscript) -> Result<()> {
ensure!(
transcript.protocol == OFFLINE_PROOF_PROTOCOL,
"unsupported offline proof"
);
required(&transcript.trust_domain, "trust domain", 128)?;
required(&transcript.credential_serial, "credential serial", 192)?;
required(&transcript.presenter_device_id, "presenter device id", 192)?;
required(
&transcript.presenter_endpoint_id,
"presenter endpoint id",
192,
)?;
required(&transcript.verifier_device_id, "verifier device id", 192)?;
required(
&transcript.verifier_endpoint_id,
"verifier endpoint id",
192,
)?;
required(&transcript.presenter_nonce, "presenter nonce", 192)?;
required(&transcript.verifier_nonce, "verifier nonce", 192)?;
ensure!(
transcript.transport_stable_id > 0,
"offline proof transport generation is invalid"
);
required(&transcript.channel_binding, "channel binding", 512)?;
ensure!(
transcript.presenter_device_id != transcript.verifier_device_id,
"offline proof cannot target the same device"
);
Ok(())
}
#[derive(Debug, Clone)]
pub struct OfflineReplayCache {
capacity: usize,
order: VecDeque<String>,
entries: BTreeSet<String>,
}
impl Default for OfflineReplayCache {
fn default() -> Self {
Self::new(MAX_OFFLINE_REPLAY_ENTRIES)
}
}
impl OfflineReplayCache {
pub fn new(capacity: usize) -> Self {
Self {
capacity: capacity.clamp(1, MAX_OFFLINE_REPLAY_ENTRIES),
order: VecDeque::new(),
entries: BTreeSet::new(),
}
}
pub fn accept(&mut self, replay_id: String) -> Result<()> {
ensure!(
!self.entries.contains(&replay_id),
"offline proof replay rejected"
);
while self.order.len() >= self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.entries.remove(&oldest);
}
}
self.entries.insert(replay_id.clone());
self.order.push_back(replay_id);
Ok(())
}
fn from_entries(capacity: usize, entries: impl IntoIterator<Item = String>) -> Result<Self> {
let mut cache = Self::new(capacity);
for entry in entries {
required(&entry, "offline replay id", 192)?;
cache.accept(entry)?;
}
Ok(cache)
}
fn entries(&self) -> Vec<String> {
self.order.iter().cloned().collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineAdmission {
pub authoritative_device_id: String,
pub credential_serial: String,
pub roles: Vec<String>,
pub assurance: OfflineAssurance,
}
fn verify_connection_proof(
proof: &OfflineConnectionProof,
credential: &OfflineDeviceCredential,
expected: &OfflineProofTranscript,
replay_cache: &mut OfflineReplayCache,
) -> Result<OfflineAdmission> {
validate_transcript(expected)?;
ensure!(
&proof.transcript == expected,
"offline proof transcript mismatch"
);
ensure!(
proof.transcript.trust_domain == credential.body.trust_domain,
"offline proof trust-domain mismatch"
);
ensure!(
proof.transcript.credential_serial == credential.body.serial,
"offline proof credential mismatch"
);
ensure!(
proof.transcript.presenter_device_id == credential.body.device_id
&& proof.transcript.presenter_endpoint_id == credential.body.endpoint_id,
"offline proof identity mismatch"
);
public_key(&credential.body.proof_public_key)?
.verify(
&canonical_bytes("openrtc:offline-proof:v1", &proof.transcript)?,
&signature(&proof.signature)?,
)
.context("verify offline connection proof")?;
replay_cache.accept(proof.replay_id()?)?;
Ok(OfflineAdmission {
authoritative_device_id: credential.body.device_id.clone(),
credential_serial: credential.body.serial.clone(),
roles: credential.body.roles.clone(),
assurance: credential.body.assurance,
})
}
pub fn bounded_swarm_neighbors(
local_device_id: &str,
member_device_ids: impl IntoIterator<Item = String>,
requested_degree: usize,
) -> Result<Vec<String>> {
let local = required(local_device_id, "local device id", 192)?;
let members = member_device_ids
.into_iter()
.map(|member| required(&member, "swarm device id", 192))
.collect::<Result<BTreeSet<_>>>()?;
ensure!(
members.len() <= MAX_OFFLINE_SWARM_MEMBERS,
"offline swarm is too large"
);
ensure!(
members.contains(&local),
"local device is not in the offline swarm"
);
if members.len() <= 1 {
return Ok(Vec::new());
}
let members = members.into_iter().collect::<Vec<_>>();
let index = members.iter().position(|member| member == &local).unwrap();
let degree = requested_degree
.clamp(1, MAX_OFFLINE_SWARM_DEGREE)
.min(members.len() - 1);
let mut neighbors = BTreeSet::new();
for distance in 1..members.len() {
neighbors.insert(members[(index + distance) % members.len()].clone());
if neighbors.len() == degree {
break;
}
neighbors.insert(members[(index + members.len() - distance) % members.len()].clone());
if neighbors.len() == degree {
break;
}
}
Ok(neighbors.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (
SoftwareOfflineSigner,
SoftwareOfflineSigner,
OfflineEnrollmentRequest,
OfflineDeviceCredential,
) {
let issuer = SoftwareOfflineSigner::from_seed([1; 32]);
let device = SoftwareOfflineSigner::from_seed([2; 32]);
let request = OfflineEnrollmentRequest::create(
&device,
"field-a",
"device-a",
"endpoint-a",
"enroll-a",
vec!["sensor".into()],
OfflineAssurance::HardwareBacked,
1_000,
)
.unwrap();
let credential = OfflineDeviceCredential::issue(
&issuer,
&request,
"serial-a",
1,
vec!["sensor".into()],
OfflineAssurance::Software,
1_000,
10_000,
)
.unwrap();
(issuer, device, request, credential)
}
#[test]
fn enrollment_and_credential_require_both_target_and_issuer_signatures() {
let (issuer, _, mut request, credential) = fixture();
request.body.device_id = "attacker".into();
assert!(request.verify().is_err());
credential
.verify(&issuer.verifying_key().unwrap(), 2_000)
.unwrap();
let wrong = SoftwareOfflineSigner::from_seed([9; 32]);
assert!(credential
.verify(&wrong.verifying_key().unwrap(), 2_000)
.is_err());
}
#[test]
fn request_cannot_self_certify_hardware_assurance() {
let (_, _, request, credential) = fixture();
assert_eq!(
request.body.requested_assurance,
OfflineAssurance::HardwareBacked
);
assert_eq!(
credential.body.assurance,
OfflineAssurance::Software,
"the issuer, not the request, certifies credential assurance"
);
}
#[test]
fn trust_bundle_rejects_rollback_fork_and_unapproved_issuer_recovery() {
let (issuer, _, _, credential) = fixture();
let first = OfflineTrustBundle::issue(
&issuer,
"field-a",
1,
None,
None,
vec![credential.clone()],
vec![],
)
.unwrap();
let mut state = OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
let first_water = state.apply(first, 2_000).unwrap();
let fork = OfflineTrustBundle::issue(
&issuer,
"field-a",
1,
None,
None,
vec![credential.clone()],
vec![],
)
.unwrap();
assert!(state.apply(fork, 2_000).is_err());
assert_eq!(state.high_water().unwrap(), Some(first_water.clone()));
let attacker = SoftwareOfflineSigner::from_seed([7; 32]);
let recovery = OfflineTrustBundle::issue(
&attacker,
"field-a",
2,
Some(first_water.digest.clone()),
Some(first_water.issuer_key_id.clone()),
vec![],
vec![credential.body.serial],
)
.unwrap();
assert!(state.apply(recovery, 2_000).is_err());
assert_eq!(state.high_water().unwrap(), Some(first_water));
}
#[test]
fn proof_binds_both_peers_nonces_channel_and_rejects_replay() {
let (_, device, _, credential) = fixture();
let transcript = OfflineProofTranscript {
protocol: OFFLINE_PROOF_PROTOCOL.into(),
trust_domain: "field-a".into(),
credential_serial: "serial-a".into(),
presenter_device_id: "device-a".into(),
presenter_endpoint_id: "endpoint-a".into(),
verifier_device_id: "device-b".into(),
verifier_endpoint_id: "endpoint-b".into(),
presenter_nonce: "presenter-nonce".into(),
verifier_nonce: "verifier-nonce".into(),
transport_stable_id: 7,
channel_binding: "quic-exporter-current-generation".into(),
};
let proof = OfflineConnectionProof::create(&device, transcript.clone()).unwrap();
let mut replay = OfflineReplayCache::new(8);
let (issuer, _, _, _) = fixture();
let bundle =
OfflineTrustBundle::issue(&issuer, "field-a", 1, None, None, vec![credential], vec![])
.unwrap();
let mut state = OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
state.apply(bundle, 2_000).unwrap();
state
.verify_connection_proof(&proof, &transcript, &mut replay, 2_000)
.unwrap();
assert!(state
.verify_connection_proof(&proof, &transcript, &mut replay, 2_000)
.is_err());
let mut wrong_generation = transcript;
wrong_generation.transport_stable_id = 8;
wrong_generation.channel_binding = "retired-generation".into();
assert!(state
.verify_connection_proof(
&proof,
&wrong_generation,
&mut OfflineReplayCache::new(8),
2_000,
)
.is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn durable_trust_restart_rejects_rollback_fork_and_preserves_revocation() {
let (issuer, _, _, credential) = fixture();
let state_dir = std::env::temp_dir().join(format!(
"openrtc-offline-trust-{}",
crate::session_token::generate_nonce()
));
std::fs::create_dir_all(&state_dir).unwrap();
let state_path = state_dir.join("trust.json");
let first = OfflineTrustBundle::issue(
&issuer,
"field-a",
1,
None,
None,
vec![credential.clone()],
vec![],
)
.unwrap();
let mut durable = DurableOfflineTrustState::open(
&state_path,
"field-a",
issuer.verifying_key().unwrap(),
[],
2_000,
)
.unwrap();
let first_water = durable.apply(first.clone(), 2_000).unwrap();
let first_journal = std::fs::read(&state_path).unwrap();
let second = OfflineTrustBundle::issue(
&issuer,
"field-a",
2,
Some(first_water.digest.clone()),
None,
vec![],
vec![credential.body.serial.clone()],
)
.unwrap();
let second_water = durable.apply(second.clone(), 2_000).unwrap();
let second_journal = std::fs::read(&state_path).unwrap();
let invalid_fork = OfflineTrustBundle::issue(
&issuer,
"field-a",
2,
Some(first_water.digest.clone()),
None,
vec![],
vec![],
)
.unwrap();
assert!(durable.apply(invalid_fork, 2_000).is_err());
assert_eq!(
durable.trust().high_water().unwrap(),
Some(second_water.clone())
);
assert_eq!(std::fs::read(&state_path).unwrap(), second_journal);
drop(durable);
let restarted = DurableOfflineTrustState::open(
&state_path,
"field-a",
issuer.verifying_key().unwrap(),
[],
2_000,
)
.unwrap();
assert_eq!(
restarted.trust().high_water().unwrap(),
Some(second_water.clone())
);
assert!(restarted.trust().credential("device-a", 2_000).is_err());
drop(restarted);
std::fs::write(&state_path, &first_journal).unwrap();
let rollback_error = DurableOfflineTrustState::open(
&state_path,
"field-a",
issuer.verifying_key().unwrap(),
[],
2_000,
)
.err()
.expect("rollback must be rejected")
.to_string();
assert!(rollback_error.contains("rollback or fork"));
let fork = OfflineTrustBundle::issue(
&issuer,
"field-a",
2,
Some(first_water.digest),
None,
vec![credential],
vec![],
)
.unwrap();
let mut fork_state =
OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
fork_state.apply(first.clone(), 2_000).unwrap();
let fork_water = fork_state.apply(fork.clone(), 2_000).unwrap();
write_private_json(
&state_path,
&OfflineTrustJournal {
protocol: OFFLINE_TRUST_JOURNAL_PROTOCOL.to_string(),
history: vec![first, fork],
high_water: fork_water,
replay_ids: Vec::new(),
},
)
.unwrap();
let fork_error = DurableOfflineTrustState::open(
&state_path,
"field-a",
issuer.verifying_key().unwrap(),
[],
2_000,
)
.err()
.expect("fork must be rejected")
.to_string();
assert!(fork_error.contains("rollback or fork"));
std::fs::write(&state_path, second_journal).unwrap();
let restored = DurableOfflineTrustState::open(
&state_path,
"field-a",
issuer.verifying_key().unwrap(),
[],
20_000,
)
.unwrap();
assert_eq!(restored.trust().high_water().unwrap(), Some(second_water));
assert!(restored.trust().credential("device-a", 20_000).is_err());
std::fs::remove_dir_all(&state_dir).unwrap();
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
#[test]
fn durable_trust_restart_rejects_an_already_accepted_proof() {
let issuer = SoftwareOfflineSigner::from_seed([11; 32]);
let device = SoftwareOfflineSigner::from_seed([12; 32]);
let local_endpoint = iroh::SecretKey::generate().public();
let remote_endpoint = iroh::SecretKey::generate().public();
let request = OfflineEnrollmentRequest::create(
&device,
"field-replay",
"device-a",
&remote_endpoint.to_string(),
"enroll-replay",
vec!["sensor".into()],
OfflineAssurance::Software,
1_000,
)
.unwrap();
let credential = OfflineDeviceCredential::issue(
&issuer,
&request,
"serial-replay",
1,
vec!["sensor".into()],
OfflineAssurance::Software,
1_000,
10_000,
)
.unwrap();
let bundle = OfflineTrustBundle::issue(
&issuer,
"field-replay",
1,
None,
None,
vec![credential],
vec![],
)
.unwrap();
let state_dir = std::env::temp_dir().join(format!(
"openrtc-offline-replay-{}",
crate::session_token::generate_nonce()
));
std::fs::create_dir_all(&state_dir).unwrap();
let state_path = state_dir.join("trust.json");
let mut durable = DurableOfflineTrustState::open(
&state_path,
"field-replay",
issuer.verifying_key().unwrap(),
[],
2_000,
)
.unwrap();
durable.apply(bundle, 2_000).unwrap();
let endpoint_addr = iroh::EndpointAddr::new(remote_endpoint)
.with_ip_addr("127.0.0.1:4433".parse().unwrap());
let candidate = durable
.trust()
.authorize_local_candidate("device-a", endpoint_addr, 2_000)
.unwrap();
let transcript = OfflineProofTranscript {
protocol: OFFLINE_PROOF_PROTOCOL.into(),
trust_domain: "field-replay".into(),
credential_serial: "serial-replay".into(),
presenter_device_id: "device-a".into(),
presenter_endpoint_id: remote_endpoint.to_string(),
verifier_device_id: "device-b".into(),
verifier_endpoint_id: local_endpoint.to_string(),
presenter_nonce: "presenter-restart".into(),
verifier_nonce: "verifier-restart".into(),
transport_stable_id: 9,
channel_binding: "quic-exporter-generation-9".into(),
};
let proof = OfflineConnectionProof::create(&device, transcript.clone()).unwrap();
let binding = OfflineTransportBinding {
local_endpoint_id: local_endpoint.to_string(),
remote_endpoint_id: remote_endpoint.to_string(),
transport_stable_id: 9,
};
durable
.verify_transport_proof(&candidate, &proof, &transcript, binding.clone(), 2_000)
.unwrap();
drop(durable);
let mut restarted = DurableOfflineTrustState::open(
&state_path,
"field-replay",
issuer.verifying_key().unwrap(),
[],
2_000,
)
.unwrap();
let error = restarted
.verify_transport_proof(&candidate, &proof, &transcript, binding, 2_000)
.expect_err("accepted proof must remain consumed after restart")
.to_string();
assert!(error.contains("replay rejected"));
std::fs::remove_dir_all(state_dir).unwrap();
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
#[test]
fn local_candidate_proof_handoff_is_generation_and_replay_bound() {
let issuer = SoftwareOfflineSigner::from_seed([1; 32]);
let device = SoftwareOfflineSigner::from_seed([2; 32]);
let local_endpoint = iroh::SecretKey::generate().public();
let remote_endpoint = iroh::SecretKey::generate().public();
let request = OfflineEnrollmentRequest::create(
&device,
"field-a",
"device-a",
&remote_endpoint.to_string(),
"enroll-a",
vec!["sensor".into()],
OfflineAssurance::Software,
1_000,
)
.unwrap();
let credential = OfflineDeviceCredential::issue(
&issuer,
&request,
"serial-a",
1,
vec!["sensor".into()],
OfflineAssurance::Software,
1_000,
10_000,
)
.unwrap();
let bundle =
OfflineTrustBundle::issue(&issuer, "field-a", 1, None, None, vec![credential], vec![])
.unwrap();
let mut state = OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
state.apply(bundle, 2_000).unwrap();
let candidate = state
.authorize_local_candidate(
"device-a",
iroh::EndpointAddr::new(remote_endpoint)
.with_ip_addr("127.0.0.1:4433".parse().unwrap()),
2_000,
)
.unwrap();
let transcript = OfflineProofTranscript {
protocol: OFFLINE_PROOF_PROTOCOL.into(),
trust_domain: "field-a".into(),
credential_serial: "serial-a".into(),
presenter_device_id: "device-a".into(),
presenter_endpoint_id: remote_endpoint.to_string(),
verifier_device_id: "device-b".into(),
verifier_endpoint_id: local_endpoint.to_string(),
presenter_nonce: "presenter-nonce".into(),
verifier_nonce: "verifier-nonce".into(),
transport_stable_id: 7,
channel_binding: "quic-exporter-generation-7".into(),
};
let proof = OfflineConnectionProof::create(&device, transcript.clone()).unwrap();
let binding = OfflineTransportBinding {
local_endpoint_id: local_endpoint.to_string(),
remote_endpoint_id: remote_endpoint.to_string(),
transport_stable_id: 7,
};
let mut replay = OfflineReplayCache::new(8);
let handoff = state
.verify_transport_proof(
&candidate,
&proof,
&transcript,
binding.clone(),
&mut replay,
2_000,
)
.unwrap();
assert_eq!(handoff.transport_stable_id(), 7);
assert_eq!(handoff.device_id(), "device-a");
assert!(state
.verify_transport_proof(&candidate, &proof, &transcript, binding, &mut replay, 2_000,)
.is_err());
let stale_binding = OfflineTransportBinding {
local_endpoint_id: local_endpoint.to_string(),
remote_endpoint_id: remote_endpoint.to_string(),
transport_stable_id: 8,
};
assert!(state
.verify_transport_proof(
&candidate,
&proof,
&transcript,
stale_binding,
&mut OfflineReplayCache::new(8),
2_000,
)
.is_err());
}
#[test]
fn bounded_swarm_never_projects_quadratic_degree() {
let members = (0..100)
.map(|index| format!("device-{index:03}"))
.collect::<Vec<_>>();
for local in &members {
let neighbors = bounded_swarm_neighbors(local, members.clone(), 99).unwrap();
assert_eq!(neighbors.len(), MAX_OFFLINE_SWARM_DEGREE);
assert!(!neighbors.contains(local));
}
}
}