Skip to main content

openrtc/
offline.rs

1//! Offline device enrollment, trust bundles, and generation-bound proof.
2//!
3//! Discovery remains deliberately untrusted. LAN, BLE, QR, and USB adapters
4//! may report candidates, but only these signed facts can authorize an offline
5//! device. The normal `Client` peer-session actor remains the sole owner of
6//! dialing, transport generations, and application-route readiness.
7
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9#[cfg(not(target_arch = "wasm32"))]
10use std::path::{Path, PathBuf};
11#[cfg(not(target_arch = "wasm32"))]
12use std::sync::{Arc, Mutex};
13
14use anyhow::{anyhow, ensure, Context, Result};
15use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
16use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest as _, Sha256};
19
20pub const OFFLINE_DEVICE_PROTOCOL: &str = "openrtc-offline-device/1";
21pub const OFFLINE_PROOF_PROTOCOL: &str = "openrtc-offline-proof/1";
22pub const OFFLINE_TRUST_BUNDLE_PROTOCOL: &str = "openrtc-offline-trust-bundle/1";
23pub const MAX_OFFLINE_ROLES: usize = 32;
24pub const MAX_OFFLINE_CREDENTIALS: usize = 256;
25pub const MAX_OFFLINE_REVOKED_SERIALS: usize = 1_024;
26pub const MAX_OFFLINE_SWARM_MEMBERS: usize = 100;
27pub const MAX_OFFLINE_SWARM_DEGREE: usize = 5;
28pub const MAX_OFFLINE_REPLAY_ENTRIES: usize = 4_096;
29pub const MAX_OFFLINE_TRUST_HISTORY: usize = 128;
30#[cfg(not(target_arch = "wasm32"))]
31const MAX_OFFLINE_TRUST_JOURNAL_BYTES: u64 = 8 * 1024 * 1024;
32#[cfg(not(target_arch = "wasm32"))]
33const OFFLINE_TRUST_JOURNAL_PROTOCOL: &str = "openrtc-offline-trust-journal/1";
34
35#[cfg(not(target_arch = "wasm32"))]
36pub struct OfflineClient<'a> {
37    client: &'a crate::Client,
38}
39
40#[cfg(not(target_arch = "wasm32"))]
41#[derive(Debug, Clone)]
42pub struct OfflineEnrollmentOptions {
43    pub trust_domain: String,
44    pub device_id: String,
45    pub enrollment_nonce: String,
46    pub requested_roles: Vec<String>,
47    pub requested_assurance: OfflineAssurance,
48    pub created_at_ms: u64,
49}
50
51/// Native-only runtime inputs retained by the Rust owner while offline LAN
52/// discovery and admission are enabled.
53///
54/// The signer keeps private-key custody in the host. The durable trust state
55/// owns signed fleet policy, rollback protection, and the accepted-proof replay
56/// window. Neither value is projected to TypeScript.
57#[cfg(not(target_arch = "wasm32"))]
58pub struct OfflineRuntimeConfig {
59    pub local_device_id: String,
60    pub signer: Arc<dyn OfflineSigner>,
61    pub trust: Arc<Mutex<DurableOfflineTrustState>>,
62}
63
64#[cfg(not(target_arch = "wasm32"))]
65#[derive(Clone)]
66pub(crate) struct InstalledOfflineRuntime {
67    pub(crate) local_device_id: String,
68    pub(crate) signer: Arc<dyn OfflineSigner>,
69    pub(crate) trust: Arc<Mutex<DurableOfflineTrustState>>,
70    pub(crate) desired_revision: u64,
71    pub(crate) candidates: BTreeMap<String, OfflineDesiredCandidate>,
72}
73
74#[cfg(not(target_arch = "wasm32"))]
75#[derive(Clone)]
76pub(crate) struct OfflineDesiredCandidate {
77    pub(crate) handoff: OfflineCandidateHandoff,
78    pub(crate) endpoint_addr: iroh::EndpointAddr,
79    pub(crate) reachable: bool,
80}
81
82#[cfg(not(target_arch = "wasm32"))]
83impl<'a> OfflineClient<'a> {
84    pub(crate) fn new(client: &'a crate::Client) -> Self {
85        Self { client }
86    }
87
88    /// Create a request bound to the one native Iroh endpoint owned by this
89    /// client. The caller-supplied signer retains all private-key custody.
90    pub async fn create_enrollment_request(
91        &self,
92        signer: &dyn OfflineSigner,
93        options: OfflineEnrollmentOptions,
94    ) -> Result<OfflineEnrollmentRequest> {
95        let endpoint_id =
96            self.client.current_node_id().await.ok_or_else(|| {
97                anyhow!("Iroh endpoint must be started before offline enrollment")
98            })?;
99        OfflineEnrollmentRequest::create(
100            signer,
101            &options.trust_domain,
102            &options.device_id,
103            &endpoint_id,
104            &options.enrollment_nonce,
105            options.requested_roles,
106            options.requested_assurance,
107            options.created_at_ms,
108        )
109    }
110
111    pub async fn network_policy(&self) -> crate::client::NetworkPolicy {
112        self.client.transport_config().await.network_policy
113    }
114
115    /// Install the native offline trust owner and feed any already-observed
116    /// local candidates into the existing desired-peer actor.
117    pub async fn install_runtime(&self, config: OfflineRuntimeConfig) -> Result<()> {
118        self.client.install_offline_runtime(config).await
119    }
120
121    /// Apply one signed trust update and immediately retire credentials that
122    /// disappeared, were revoked, or changed endpoint identity.
123    pub async fn apply_trust_bundle(
124        &self,
125        bundle: OfflineTrustBundle,
126        at_ms: u64,
127    ) -> Result<OfflineTrustHighWater> {
128        self.client.apply_offline_trust_bundle(bundle, at_ms).await
129    }
130
131    /// Explicitly retire the old endpoint before a device rotates endpoint or
132    /// proof-key identity. A later local observation must pass current trust and
133    /// complete a fresh proof before product traffic resumes.
134    pub async fn retire_device(&self, device_id: &str) -> Result<bool> {
135        self.client.retire_offline_device(device_id).await
136    }
137
138    /// Register trust-validated local intent with the existing admission
139    /// owner. This does not dial or start a retry loop.
140    pub async fn register_candidate(&self, candidate: OfflineCandidateHandoff) -> Result<String> {
141        self.client
142            .register_offline_candidate_requirement(candidate)
143            .await
144            .map_err(anyhow::Error::msg)
145    }
146
147    /// Commit a fresh proof only if its exact physical generation is current.
148    pub async fn commit_admission(&self, handoff: OfflineAdmissionHandoff) -> Result<String> {
149        self.client
150            .commit_offline_admission_handoff(handoff)
151            .await
152            .map_err(anyhow::Error::msg)
153    }
154
155    #[cfg(feature = "transport-lan")]
156    pub async fn observed_lan_peers(&self) -> Vec<crate::local_discovery::LocalPeerSnapshot> {
157        self.client.list_local_peers().await
158    }
159}
160
161#[cfg(not(target_arch = "wasm32"))]
162fn offline_desired_peers_json(
163    candidates: &BTreeMap<String, OfflineDesiredCandidate>,
164) -> Result<String> {
165    let peers = candidates
166        .values()
167        .filter(|candidate| candidate.reachable)
168        .map(|candidate| {
169            serde_json::json!({
170                "deviceId": candidate.handoff.device_id(),
171                "nodeId": candidate.endpoint_addr.id.to_string(),
172                "ticket": iroh_tickets::endpoint::EndpointTicket::new(
173                    candidate.endpoint_addr.clone()
174                ).to_string(),
175                "online": true,
176            })
177        })
178        .collect::<Vec<_>>();
179    serde_json::to_string(&peers).context("encode offline desired peers")
180}
181
182#[cfg(not(target_arch = "wasm32"))]
183impl crate::Client {
184    pub(crate) async fn offline_runtime_is_installed(&self) -> bool {
185        self.offline_runtime.lock().await.is_some()
186    }
187
188    pub(crate) async fn offline_candidate_is_reachable(
189        &self,
190        candidate: &OfflineCandidateHandoff,
191    ) -> bool {
192        self.offline_runtime
193            .lock()
194            .await
195            .as_ref()
196            .and_then(|runtime| runtime.candidates.get(candidate.device_id()))
197            .is_some_and(|current| current.reachable && current.handoff.same_authority(candidate))
198    }
199
200    pub(crate) async fn install_offline_runtime(&self, config: OfflineRuntimeConfig) -> Result<()> {
201        ensure!(
202            self.transport_config().await.network_policy == crate::client::NetworkPolicy::LocalOnly,
203            "offline runtime requires the LocalOnly network policy"
204        );
205        let local_device_id = required(&config.local_device_id, "local device id", 192)?;
206        let local_endpoint_id = self
207            .current_node_id()
208            .await
209            .ok_or_else(|| anyhow!("Iroh endpoint must be started before offline runtime"))?;
210        let at_ms = crate::coordination::now_millis_u64();
211        let mut runtime_guard = self.offline_runtime.lock().await;
212        let trust_domain = {
213            let trust = config
214                .trust
215                .lock()
216                .map_err(|_| anyhow!("offline trust state lock poisoned"))?;
217            let credential = trust.trust().credential(&local_device_id, at_ms)?;
218            ensure!(
219                credential.body.endpoint_id == local_endpoint_id,
220                "local offline credential is bound to a different Iroh endpoint"
221            );
222            ensure!(
223                public_key(&credential.body.proof_public_key)? == config.signer.verifying_key()?,
224                "offline signer does not match the local credential"
225            );
226            credential.body.trust_domain.clone()
227        };
228        if let Some(current) = runtime_guard.as_ref() {
229            if current.local_device_id == local_device_id
230                && Arc::ptr_eq(&current.signer, &config.signer)
231                && Arc::ptr_eq(&current.trust, &config.trust)
232            {
233                ensure!(
234                    self.native_external_auto_connect_owner_is_current(
235                        &format!("offline:{trust_domain}"),
236                        &local_device_id,
237                    )
238                    .await,
239                    "offline runtime owner is no longer current; explicit Rust-owned retirement is required"
240                );
241                return Ok(());
242            }
243            return Err(anyhow!(
244                "offline runtime is already installed; replacement requires an explicit Rust-owned retirement transaction"
245            ));
246        }
247
248        std::sync::Arc::new(self.clone())
249            .start_external_auto_connect(format!("offline:{trust_domain}"), local_device_id.clone())
250            .await?;
251        *runtime_guard = Some(InstalledOfflineRuntime {
252            local_device_id,
253            signer: config.signer,
254            trust: config.trust,
255            desired_revision: 0,
256            candidates: BTreeMap::new(),
257        });
258        drop(runtime_guard);
259
260        #[cfg(feature = "transport-lan")]
261        for endpoint_addr in self.local_discovery_registry.endpoint_addrs().await {
262            // A stale/untrusted observation does not prevent the owner from
263            // installing; it simply never becomes desired-peer input.
264            let _ = self.observe_offline_lan_candidate(endpoint_addr).await;
265        }
266        Ok(())
267    }
268
269    #[cfg(feature = "transport-lan")]
270    pub(crate) async fn observe_offline_lan_candidate(
271        &self,
272        endpoint_addr: iroh::EndpointAddr,
273    ) -> Result<bool> {
274        ensure!(
275            self.transport_config().await.network_policy == crate::client::NetworkPolicy::LocalOnly,
276            "offline LAN observations require the LocalOnly network policy"
277        );
278        let at_ms = crate::coordination::now_millis_u64();
279        let trust = self
280            .offline_runtime
281            .lock()
282            .await
283            .as_ref()
284            .map(|runtime| runtime.trust.clone())
285            .ok_or_else(|| anyhow!("offline runtime is not installed"))?;
286        let device_id = {
287            let trust = trust
288                .lock()
289                .map_err(|_| anyhow!("offline trust state lock poisoned"))?;
290            trust
291                .trust()
292                .device_id_for_endpoint(&endpoint_addr.id.to_string(), at_ms)?
293        };
294        let handoff = {
295            let trust = trust
296                .lock()
297                .map_err(|_| anyhow!("offline trust state lock poisoned"))?;
298            trust
299                .trust()
300                .authorize_local_candidate(&device_id, endpoint_addr.clone(), at_ms)?
301        };
302        self.register_offline_candidate_requirement(handoff.clone())
303            .await
304            .map_err(anyhow::Error::msg)?;
305
306        let (revision, peers_json, changed) = {
307            let mut guard = self.offline_runtime.lock().await;
308            let runtime = guard
309                .as_mut()
310                .ok_or_else(|| anyhow!("offline runtime was removed"))?;
311            let ticket =
312                iroh_tickets::endpoint::EndpointTicket::new(endpoint_addr.clone()).to_string();
313            let changed = runtime
314                .candidates
315                .get(&device_id)
316                .map(|current| {
317                    iroh_tickets::endpoint::EndpointTicket::new(current.endpoint_addr.clone())
318                        .to_string()
319                        != ticket
320                        || !current.handoff.same_authority(&handoff)
321                        || !current.reachable
322                })
323                .unwrap_or(true);
324            if !changed {
325                return Ok(false);
326            }
327            runtime.candidates.insert(
328                device_id,
329                OfflineDesiredCandidate {
330                    handoff,
331                    endpoint_addr,
332                    reachable: true,
333                },
334            );
335            runtime.desired_revision = runtime.desired_revision.saturating_add(1).max(1);
336            (
337                runtime.desired_revision,
338                offline_desired_peers_json(&runtime.candidates)?,
339                changed,
340            )
341        };
342        let _ = std::sync::Arc::new(self.clone())
343            .submit_external_desired_peers(revision, &peers_json)
344            .await?;
345        Ok(changed)
346    }
347
348    /// Remove one expired local observation from dial eligibility while
349    /// retaining its signed credential and any healthy admitted route. This is
350    /// reachability input, not revocation or a transport-close command.
351    #[cfg(feature = "transport-lan")]
352    pub(crate) async fn expire_offline_lan_candidate(
353        &self,
354        endpoint_id: iroh::EndpointId,
355    ) -> Result<bool> {
356        let (device_id, revision, peers_json) = {
357            let mut guard = self.offline_runtime.lock().await;
358            let runtime = guard
359                .as_mut()
360                .ok_or_else(|| anyhow!("offline runtime is not installed"))?;
361            let Some((device_id, candidate)) = runtime
362                .candidates
363                .iter_mut()
364                .find(|(_, candidate)| candidate.endpoint_addr.id == endpoint_id)
365            else {
366                return Ok(false);
367            };
368            if !candidate.reachable {
369                return Ok(false);
370            }
371            candidate.reachable = false;
372            let device_id = device_id.clone();
373            runtime.desired_revision = runtime.desired_revision.saturating_add(1).max(1);
374            (
375                device_id,
376                runtime.desired_revision,
377                offline_desired_peers_json(&runtime.candidates)?,
378            )
379        };
380        if let Some(local_endpoint_id) = self.current_node_id().await {
381            let connection_id =
382                Self::deterministic_connection_id(&local_endpoint_id, &endpoint_id.to_string());
383            self.offline_proof_attempts
384                .lock()
385                .await
386                .remove(&connection_id);
387        }
388        let _ = std::sync::Arc::new(self.clone())
389            .submit_external_desired_peer_observation_expired(revision, &peers_json, &device_id)
390            .await?;
391        Ok(true)
392    }
393
394    pub(crate) async fn retire_offline_device(&self, device_id: &str) -> Result<bool> {
395        let device_id = required(device_id, "offline device id", 192)?;
396        let (removed, revision, peers_json, node_id) = {
397            let mut guard = self.offline_runtime.lock().await;
398            let runtime = guard
399                .as_mut()
400                .ok_or_else(|| anyhow!("offline runtime is not installed"))?;
401            let removed = runtime.candidates.remove(&device_id);
402            let Some(removed) = removed else {
403                return Ok(false);
404            };
405            runtime.desired_revision = runtime.desired_revision.saturating_add(1).max(1);
406            (
407                removed.handoff,
408                runtime.desired_revision,
409                offline_desired_peers_json(&runtime.candidates)?,
410                removed.endpoint_addr.id.to_string(),
411            )
412        };
413        self.retire_offline_candidate_requirement(&removed).await;
414        let _ = std::sync::Arc::new(self.clone())
415            .submit_external_desired_peers(revision, &peers_json)
416            .await?;
417        self.retire_offline_desired_route(&device_id, Some(&node_id))
418            .await;
419        Ok(true)
420    }
421
422    pub(crate) async fn apply_offline_trust_bundle(
423        &self,
424        bundle: OfflineTrustBundle,
425        at_ms: u64,
426    ) -> Result<OfflineTrustHighWater> {
427        let (trust, candidates) = {
428            let guard = self.offline_runtime.lock().await;
429            let runtime = guard
430                .as_ref()
431                .ok_or_else(|| anyhow!("offline runtime is not installed"))?;
432            (runtime.trust.clone(), runtime.candidates.clone())
433        };
434        let prior = {
435            let trust = trust
436                .lock()
437                .map_err(|_| anyhow!("offline trust state lock poisoned"))?;
438            candidates
439                .keys()
440                .filter_map(|device_id| {
441                    trust
442                        .trust()
443                        .credential(device_id, at_ms)
444                        .ok()
445                        .map(|credential| (device_id.clone(), credential.clone()))
446                })
447                .collect::<BTreeMap<_, _>>()
448        };
449        let high_water = trust
450            .lock()
451            .map_err(|_| anyhow!("offline trust state lock poisoned"))?
452            .apply(bundle, at_ms)?;
453
454        let mut retire = Vec::new();
455        for (device_id, candidate) in &candidates {
456            let next = trust
457                .lock()
458                .map_err(|_| anyhow!("offline trust state lock poisoned"))?
459                .trust()
460                .credential(device_id, at_ms)
461                .cloned();
462            let changed_identity = match (prior.get(device_id), next.as_ref().ok()) {
463                (Some(previous), Some(current)) => {
464                    previous.body.serial != current.body.serial
465                        || previous.body.endpoint_id != current.body.endpoint_id
466                        || previous.body.proof_public_key != current.body.proof_public_key
467                }
468                _ => true,
469            };
470            if changed_identity {
471                retire.push(device_id.clone());
472            } else {
473                #[cfg(feature = "transport-lan")]
474                {
475                    // Same device/key under a newer signed bundle: refresh the
476                    // requirement and require a new proof without turning an
477                    // expired observation back into dial eligibility.
478                    let refreshed = trust
479                        .lock()
480                        .map_err(|_| anyhow!("offline trust state lock poisoned"))?
481                        .trust()
482                        .authorize_local_candidate(
483                            device_id,
484                            candidate.endpoint_addr.clone(),
485                            at_ms,
486                        )?;
487                    self.register_offline_candidate_requirement(refreshed.clone())
488                        .await
489                        .map_err(anyhow::Error::msg)?;
490                    let (revision, peers_json) = {
491                        let mut guard = self.offline_runtime.lock().await;
492                        let runtime = guard
493                            .as_mut()
494                            .ok_or_else(|| anyhow!("offline runtime was removed"))?;
495                        let current = runtime
496                            .candidates
497                            .get_mut(device_id)
498                            .ok_or_else(|| anyhow!("offline candidate disappeared"))?;
499                        current.handoff = refreshed;
500                        runtime.desired_revision =
501                            runtime.desired_revision.saturating_add(1).max(1);
502                        (
503                            runtime.desired_revision,
504                            offline_desired_peers_json(&runtime.candidates)?,
505                        )
506                    };
507                    let _ = std::sync::Arc::new(self.clone())
508                        .submit_external_desired_peers(revision, &peers_json)
509                        .await?;
510                }
511                #[cfg(not(feature = "transport-lan"))]
512                let _ = (device_id, candidate);
513            }
514        }
515        for device_id in retire {
516            self.retire_offline_device(&device_id).await?;
517        }
518        Ok(high_water)
519    }
520}
521
522fn required(value: &str, label: &str, max: usize) -> Result<String> {
523    let value = value.trim();
524    ensure!(!value.is_empty(), "{label} is required");
525    ensure!(value.len() <= max, "{label} exceeds {max} bytes");
526    ensure!(
527        !value.chars().any(char::is_control),
528        "{label} contains control characters"
529    );
530    Ok(value.to_string())
531}
532
533fn canonical_bytes<T: Serialize>(domain: &str, value: &T) -> Result<Vec<u8>> {
534    let mut out = Vec::with_capacity(256);
535    out.extend_from_slice(domain.as_bytes());
536    out.push(0);
537    out.extend_from_slice(&serde_json::to_vec(value).context("encode signed offline payload")?);
538    Ok(out)
539}
540
541fn public_key(value: &str) -> Result<VerifyingKey> {
542    let bytes = URL_SAFE_NO_PAD
543        .decode(value)
544        .context("decode Ed25519 public key")?;
545    let bytes: [u8; 32] = bytes
546        .try_into()
547        .map_err(|_| anyhow!("Ed25519 public key must contain 32 bytes"))?;
548    VerifyingKey::from_bytes(&bytes).context("parse Ed25519 public key")
549}
550
551fn signature(value: &str) -> Result<Signature> {
552    let bytes = URL_SAFE_NO_PAD
553        .decode(value)
554        .context("decode Ed25519 signature")?;
555    Signature::from_slice(&bytes).context("parse Ed25519 signature")
556}
557
558fn key_id(key: &VerifyingKey) -> String {
559    format!(
560        "ed25519:{}",
561        hex::encode(&Sha256::digest(key.as_bytes())[..12])
562    )
563}
564
565fn digest_json<T: Serialize>(value: &T) -> Result<String> {
566    Ok(hex::encode(Sha256::digest(
567        serde_json::to_vec(value).context("encode offline digest payload")?,
568    )))
569}
570
571fn normalized_roles(roles: impl IntoIterator<Item = String>) -> Result<Vec<String>> {
572    let mut roles = roles
573        .into_iter()
574        .map(|role| required(&role, "offline role", 64))
575        .collect::<Result<BTreeSet<_>>>()?
576        .into_iter()
577        .collect::<Vec<_>>();
578    ensure!(roles.len() <= MAX_OFFLINE_ROLES, "too many offline roles");
579    roles.shrink_to_fit();
580    Ok(roles)
581}
582
583/// Truthful strength of the target-owned proof key.
584#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
585#[serde(rename_all = "kebab-case")]
586pub enum OfflineAssurance {
587    Software,
588    HardwareBacked,
589    Manufacturer,
590    Gateway,
591}
592
593/// Sign-only boundary used by app-private or hardware-backed storage.
594pub trait OfflineSigner: Send + Sync {
595    fn verifying_key(&self) -> Result<VerifyingKey>;
596    fn sign(&self, message: &[u8]) -> Result<Signature>;
597}
598
599/// Software profile for Rust-native devices without a hardware signer.
600///
601/// The key is intentionally neither serializable nor cloneable. Production
602/// hosts should normally implement [`OfflineSigner`] around their private key
603/// store instead.
604pub struct SoftwareOfflineSigner(SigningKey);
605
606impl SoftwareOfflineSigner {
607    pub fn generate() -> Result<Self> {
608        let mut seed = [0u8; 32];
609        getrandom::getrandom(&mut seed)
610            .map_err(|error| anyhow!("generate offline device key: {error}"))?;
611        Ok(Self(SigningKey::from_bytes(&seed)))
612    }
613
614    #[cfg(test)]
615    fn from_seed(seed: [u8; 32]) -> Self {
616        Self(SigningKey::from_bytes(&seed))
617    }
618}
619
620impl OfflineSigner for SoftwareOfflineSigner {
621    fn verifying_key(&self) -> Result<VerifyingKey> {
622        Ok(self.0.verifying_key())
623    }
624
625    fn sign(&self, message: &[u8]) -> Result<Signature> {
626        Ok(self.0.sign(message))
627    }
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
631#[serde(rename_all = "camelCase")]
632pub struct OfflineEnrollmentBody {
633    pub protocol: String,
634    pub trust_domain: String,
635    pub device_id: String,
636    pub endpoint_id: String,
637    pub proof_public_key: String,
638    pub enrollment_nonce: String,
639    pub requested_roles: Vec<String>,
640    /// Target/host request metadata. Only an issuer-signed credential
641    /// certifies assurance.
642    pub requested_assurance: OfflineAssurance,
643    pub created_at_ms: u64,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
647#[serde(rename_all = "camelCase")]
648pub struct OfflineEnrollmentRequest {
649    pub body: OfflineEnrollmentBody,
650    pub proof_signature: String,
651}
652
653impl OfflineEnrollmentRequest {
654    #[allow(clippy::too_many_arguments)]
655    pub fn create(
656        signer: &dyn OfflineSigner,
657        trust_domain: &str,
658        device_id: &str,
659        endpoint_id: &str,
660        enrollment_nonce: &str,
661        requested_roles: Vec<String>,
662        requested_assurance: OfflineAssurance,
663        created_at_ms: u64,
664    ) -> Result<Self> {
665        let proof_key = signer.verifying_key()?;
666        let body = OfflineEnrollmentBody {
667            protocol: OFFLINE_DEVICE_PROTOCOL.to_string(),
668            trust_domain: required(trust_domain, "trust domain", 128)?,
669            device_id: required(device_id, "device id", 192)?,
670            endpoint_id: required(endpoint_id, "endpoint id", 192)?,
671            proof_public_key: URL_SAFE_NO_PAD.encode(proof_key.as_bytes()),
672            enrollment_nonce: required(enrollment_nonce, "enrollment nonce", 192)?,
673            requested_roles: normalized_roles(requested_roles)?,
674            requested_assurance,
675            created_at_ms,
676        };
677        let signed = canonical_bytes("openrtc:offline-enrollment:v1", &body)?;
678        Ok(Self {
679            body,
680            proof_signature: URL_SAFE_NO_PAD.encode(signer.sign(&signed)?.to_bytes()),
681        })
682    }
683
684    pub fn verify(&self) -> Result<VerifyingKey> {
685        ensure!(
686            self.body.protocol == OFFLINE_DEVICE_PROTOCOL,
687            "unsupported offline enrollment protocol"
688        );
689        required(&self.body.trust_domain, "trust domain", 128)?;
690        required(&self.body.device_id, "device id", 192)?;
691        required(&self.body.endpoint_id, "endpoint id", 192)?;
692        required(&self.body.enrollment_nonce, "enrollment nonce", 192)?;
693        ensure!(
694            normalized_roles(self.body.requested_roles.clone())? == self.body.requested_roles,
695            "offline enrollment roles are not canonical"
696        );
697        let key = public_key(&self.body.proof_public_key)?;
698        key.verify(
699            &canonical_bytes("openrtc:offline-enrollment:v1", &self.body)?,
700            &signature(&self.proof_signature)?,
701        )
702        .context("verify enrollment proof of possession")?;
703        Ok(key)
704    }
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
708#[serde(rename_all = "camelCase")]
709pub struct OfflineDeviceCredentialBody {
710    pub protocol: String,
711    pub trust_domain: String,
712    pub serial: String,
713    pub issuer_key_id: String,
714    pub trust_generation: u64,
715    pub device_id: String,
716    pub endpoint_id: String,
717    pub proof_public_key: String,
718    pub roles: Vec<String>,
719    pub assurance: OfflineAssurance,
720    pub not_before_ms: u64,
721    pub expires_at_ms: u64,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
725#[serde(rename_all = "camelCase")]
726pub struct OfflineDeviceCredential {
727    pub body: OfflineDeviceCredentialBody,
728    pub issuer_signature: String,
729}
730
731impl OfflineDeviceCredential {
732    /// Issue a credential under fleet policy. `assurance` is an explicit
733    /// issuer decision and is never copied from the enrollment request.
734    #[allow(clippy::too_many_arguments)]
735    pub fn issue(
736        issuer: &dyn OfflineSigner,
737        request: &OfflineEnrollmentRequest,
738        serial: &str,
739        trust_generation: u64,
740        roles: Vec<String>,
741        assurance: OfflineAssurance,
742        not_before_ms: u64,
743        expires_at_ms: u64,
744    ) -> Result<Self> {
745        request.verify()?;
746        ensure!(
747            expires_at_ms > not_before_ms,
748            "credential expiry is invalid"
749        );
750        ensure!(
751            trust_generation > 0,
752            "credential generation must be positive"
753        );
754        let roles = normalized_roles(roles)?;
755        ensure!(
756            roles
757                .iter()
758                .all(|role| request.body.requested_roles.contains(role)),
759            "credential grants a role the device did not request"
760        );
761        let issuer_key = issuer.verifying_key()?;
762        let body = OfflineDeviceCredentialBody {
763            protocol: OFFLINE_DEVICE_PROTOCOL.to_string(),
764            trust_domain: request.body.trust_domain.clone(),
765            serial: required(serial, "credential serial", 192)?,
766            issuer_key_id: key_id(&issuer_key),
767            trust_generation,
768            device_id: request.body.device_id.clone(),
769            endpoint_id: request.body.endpoint_id.clone(),
770            proof_public_key: request.body.proof_public_key.clone(),
771            roles,
772            assurance,
773            not_before_ms,
774            expires_at_ms,
775        };
776        let signed = canonical_bytes("openrtc:offline-credential:v1", &body)?;
777        Ok(Self {
778            body,
779            issuer_signature: URL_SAFE_NO_PAD.encode(issuer.sign(&signed)?.to_bytes()),
780        })
781    }
782
783    pub fn verify(&self, issuer: &VerifyingKey, at_ms: u64) -> Result<VerifyingKey> {
784        ensure!(
785            self.body.not_before_ms <= at_ms,
786            "credential is not active yet"
787        );
788        ensure!(at_ms < self.body.expires_at_ms, "credential expired");
789        self.verify_signed(issuer)
790    }
791
792    fn verify_signed(&self, issuer: &VerifyingKey) -> Result<VerifyingKey> {
793        ensure!(
794            self.body.protocol == OFFLINE_DEVICE_PROTOCOL,
795            "unsupported credential"
796        );
797        ensure!(
798            self.body.issuer_key_id == key_id(issuer),
799            "credential issuer mismatch"
800        );
801        ensure!(
802            self.body.expires_at_ms > self.body.not_before_ms,
803            "credential expiry is invalid"
804        );
805        ensure!(
806            self.body.trust_generation > 0,
807            "credential generation is invalid"
808        );
809        required(&self.body.trust_domain, "trust domain", 128)?;
810        required(&self.body.serial, "credential serial", 192)?;
811        required(&self.body.device_id, "device id", 192)?;
812        required(&self.body.endpoint_id, "endpoint id", 192)?;
813        ensure!(
814            normalized_roles(self.body.roles.clone())? == self.body.roles,
815            "credential roles are not canonical"
816        );
817        issuer
818            .verify(
819                &canonical_bytes("openrtc:offline-credential:v1", &self.body)?,
820                &signature(&self.issuer_signature)?,
821            )
822            .context("verify device credential")?;
823        public_key(&self.body.proof_public_key)
824    }
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
828#[serde(rename_all = "camelCase")]
829pub struct OfflineTrustBundleBody {
830    pub protocol: String,
831    pub trust_domain: String,
832    pub issuer_public_key: String,
833    pub issuer_key_id: String,
834    pub generation: u64,
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub parent_digest: Option<String>,
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub recovery_from_key_id: Option<String>,
839    pub credentials: Vec<OfflineDeviceCredential>,
840    pub revoked_serials: Vec<String>,
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
844#[serde(rename_all = "camelCase")]
845pub struct OfflineTrustBundle {
846    pub body: OfflineTrustBundleBody,
847    pub issuer_signature: String,
848}
849
850impl OfflineTrustBundle {
851    #[allow(clippy::too_many_arguments)]
852    pub fn issue(
853        issuer: &dyn OfflineSigner,
854        trust_domain: &str,
855        generation: u64,
856        parent_digest: Option<String>,
857        recovery_from_key_id: Option<String>,
858        credentials: Vec<OfflineDeviceCredential>,
859        revoked_serials: Vec<String>,
860    ) -> Result<Self> {
861        ensure!(generation > 0, "trust-bundle generation must be positive");
862        ensure!(
863            credentials.len() <= MAX_OFFLINE_CREDENTIALS,
864            "too many credentials"
865        );
866        ensure!(
867            revoked_serials.len() <= MAX_OFFLINE_REVOKED_SERIALS,
868            "too many revoked credential serials"
869        );
870        let issuer_key = issuer.verifying_key()?;
871        let revoked_serials = revoked_serials
872            .into_iter()
873            .map(|serial| required(&serial, "revoked serial", 192))
874            .collect::<Result<BTreeSet<_>>>()?
875            .into_iter()
876            .collect();
877        let body = OfflineTrustBundleBody {
878            protocol: OFFLINE_TRUST_BUNDLE_PROTOCOL.to_string(),
879            trust_domain: required(trust_domain, "trust domain", 128)?,
880            issuer_public_key: URL_SAFE_NO_PAD.encode(issuer_key.as_bytes()),
881            issuer_key_id: key_id(&issuer_key),
882            generation,
883            parent_digest,
884            recovery_from_key_id,
885            credentials,
886            revoked_serials,
887        };
888        let signed = canonical_bytes("openrtc:offline-trust-bundle:v1", &body)?;
889        Ok(Self {
890            body,
891            issuer_signature: URL_SAFE_NO_PAD.encode(issuer.sign(&signed)?.to_bytes()),
892        })
893    }
894
895    pub fn digest(&self) -> Result<String> {
896        digest_json(self)
897    }
898
899    pub fn verify(&self, at_ms: u64) -> Result<VerifyingKey> {
900        let issuer = self.verify_signed()?;
901        for credential in &self.body.credentials {
902            credential.verify(&issuer, at_ms)?;
903        }
904        Ok(issuer)
905    }
906
907    fn verify_signed(&self) -> Result<VerifyingKey> {
908        ensure!(
909            self.body.protocol == OFFLINE_TRUST_BUNDLE_PROTOCOL,
910            "unsupported trust-bundle protocol"
911        );
912        ensure!(
913            self.body.generation > 0,
914            "trust-bundle generation is invalid"
915        );
916        ensure!(
917            self.body.credentials.len() <= MAX_OFFLINE_CREDENTIALS,
918            "too many credentials"
919        );
920        ensure!(
921            self.body.revoked_serials.len() <= MAX_OFFLINE_REVOKED_SERIALS,
922            "too many revoked credential serials"
923        );
924        required(&self.body.trust_domain, "trust domain", 128)?;
925        let issuer = public_key(&self.body.issuer_public_key)?;
926        ensure!(
927            self.body.issuer_key_id == key_id(&issuer),
928            "trust-bundle issuer mismatch"
929        );
930        issuer
931            .verify(
932                &canonical_bytes("openrtc:offline-trust-bundle:v1", &self.body)?,
933                &signature(&self.issuer_signature)?,
934            )
935            .context("verify trust bundle")?;
936        let revoked = self
937            .body
938            .revoked_serials
939            .iter()
940            .map(|serial| required(serial, "revoked serial", 192))
941            .collect::<Result<BTreeSet<_>>>()?;
942        ensure!(
943            revoked.len() == self.body.revoked_serials.len(),
944            "revoked serials are not canonical"
945        );
946        let mut devices = BTreeSet::new();
947        let mut serials = BTreeSet::new();
948        for credential in &self.body.credentials {
949            credential.verify_signed(&issuer)?;
950            ensure!(
951                credential.body.trust_domain == self.body.trust_domain,
952                "credential trust domain mismatch"
953            );
954            ensure!(
955                credential.body.trust_generation <= self.body.generation,
956                "credential generation is newer than its trust bundle"
957            );
958            ensure!(
959                devices.insert(&credential.body.device_id),
960                "duplicate device credential"
961            );
962            ensure!(
963                serials.insert(&credential.body.serial),
964                "duplicate credential serial"
965            );
966            ensure!(
967                !revoked.contains(&credential.body.serial),
968                "trust bundle includes a revoked credential"
969            );
970        }
971        Ok(issuer)
972    }
973}
974
975#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
976#[serde(rename_all = "camelCase")]
977pub struct OfflineTrustHighWater {
978    pub trust_domain: String,
979    pub issuer_key_id: String,
980    pub generation: u64,
981    pub digest: String,
982}
983
984/// Trust-validated local reachability input. It is not a dial command and it
985/// is not an admission verdict. Only the existing Rust peer actor may turn it
986/// into a transport attempt.
987#[cfg(not(target_arch = "wasm32"))]
988#[derive(Debug, Clone)]
989pub struct OfflineCandidateHandoff {
990    trust_domain: String,
991    trust_generation: u64,
992    credential_serial: String,
993    device_id: String,
994    endpoint_addr: iroh::EndpointAddr,
995    roles: Vec<String>,
996    assurance: OfflineAssurance,
997}
998
999#[cfg(not(target_arch = "wasm32"))]
1000impl OfflineCandidateHandoff {
1001    pub fn device_id(&self) -> &str {
1002        &self.device_id
1003    }
1004
1005    pub fn endpoint_addr(&self) -> &iroh::EndpointAddr {
1006        &self.endpoint_addr
1007    }
1008
1009    pub fn trust_generation(&self) -> u64 {
1010        self.trust_generation
1011    }
1012
1013    pub(crate) fn same_authority(&self, other: &Self) -> bool {
1014        self.trust_domain == other.trust_domain
1015            && self.trust_generation == other.trust_generation
1016            && self.credential_serial == other.credential_serial
1017            && self.device_id == other.device_id
1018            && self.endpoint_addr.id == other.endpoint_addr.id
1019    }
1020}
1021
1022/// Exact local physical generation to which a fresh offline proof is bound.
1023#[cfg(not(target_arch = "wasm32"))]
1024#[derive(Debug, Clone, PartialEq, Eq)]
1025pub struct OfflineTransportBinding {
1026    pub local_endpoint_id: String,
1027    pub remote_endpoint_id: String,
1028    pub transport_stable_id: u64,
1029}
1030
1031/// Opaque proof verdict consumed by the existing Rust admission owner.
1032#[cfg(not(target_arch = "wasm32"))]
1033#[derive(Debug, Clone)]
1034pub struct OfflineAdmissionHandoff {
1035    candidate: OfflineCandidateHandoff,
1036    binding: OfflineTransportBinding,
1037    replay_id: String,
1038}
1039
1040#[cfg(not(target_arch = "wasm32"))]
1041impl OfflineAdmissionHandoff {
1042    pub fn device_id(&self) -> &str {
1043        self.candidate.device_id()
1044    }
1045
1046    pub fn remote_endpoint_id(&self) -> &str {
1047        &self.binding.remote_endpoint_id
1048    }
1049
1050    pub fn transport_stable_id(&self) -> u64 {
1051        self.binding.transport_stable_id
1052    }
1053
1054    pub fn roles(&self) -> &[String] {
1055        &self.candidate.roles
1056    }
1057
1058    pub fn assurance(&self) -> OfflineAssurance {
1059        self.candidate.assurance
1060    }
1061
1062    pub fn replay_id(&self) -> &str {
1063        &self.replay_id
1064    }
1065
1066    pub(crate) fn candidate(&self) -> &OfflineCandidateHandoff {
1067        &self.candidate
1068    }
1069
1070    pub(crate) fn binding(&self) -> &OfflineTransportBinding {
1071        &self.binding
1072    }
1073}
1074
1075/// Persist this value atomically after [`OfflineTrustState::apply`] succeeds.
1076#[derive(Debug, Clone)]
1077pub struct OfflineTrustState {
1078    pinned_trust_domain: String,
1079    pinned_issuer: VerifyingKey,
1080    recovery_issuers: BTreeMap<String, VerifyingKey>,
1081    current: Option<OfflineTrustBundle>,
1082}
1083
1084impl OfflineTrustState {
1085    pub fn new(trust_domain: &str, issuer: VerifyingKey) -> Result<Self> {
1086        Ok(Self {
1087            pinned_trust_domain: required(trust_domain, "trust domain", 128)?,
1088            pinned_issuer: issuer,
1089            recovery_issuers: BTreeMap::new(),
1090            current: None,
1091        })
1092    }
1093
1094    pub fn allow_recovery_issuer(&mut self, issuer: VerifyingKey) {
1095        self.recovery_issuers.insert(key_id(&issuer), issuer);
1096    }
1097
1098    pub fn high_water(&self) -> Result<Option<OfflineTrustHighWater>> {
1099        self.current
1100            .as_ref()
1101            .map(|bundle| {
1102                Ok(OfflineTrustHighWater {
1103                    trust_domain: bundle.body.trust_domain.clone(),
1104                    issuer_key_id: bundle.body.issuer_key_id.clone(),
1105                    generation: bundle.body.generation,
1106                    digest: bundle.digest()?,
1107                })
1108            })
1109            .transpose()
1110    }
1111
1112    pub fn apply(&mut self, next: OfflineTrustBundle, at_ms: u64) -> Result<OfflineTrustHighWater> {
1113        let next_issuer = next.verify(at_ms)?;
1114        self.apply_verified(next, next_issuer)
1115    }
1116
1117    /// Replay persisted lineage without requiring credentials in historical
1118    /// generations to still be active today. Signatures, canonical structure,
1119    /// issuer recovery, and every parent digest remain fully verified.
1120    #[cfg(not(target_arch = "wasm32"))]
1121    fn apply_historical(&mut self, next: OfflineTrustBundle) -> Result<OfflineTrustHighWater> {
1122        let next_issuer = next.verify_signed()?;
1123        self.apply_verified(next, next_issuer)
1124    }
1125
1126    fn apply_verified(
1127        &mut self,
1128        next: OfflineTrustBundle,
1129        next_issuer: VerifyingKey,
1130    ) -> Result<OfflineTrustHighWater> {
1131        ensure!(
1132            next.body.trust_domain == self.pinned_trust_domain,
1133            "trust-domain substitution rejected"
1134        );
1135        let next_digest = next.digest()?;
1136        match &self.current {
1137            None => {
1138                ensure!(
1139                    next_issuer == self.pinned_issuer && next.body.recovery_from_key_id.is_none(),
1140                    "initial trust bundle must use the pinned issuer"
1141                );
1142                ensure!(
1143                    next.body.parent_digest.is_none(),
1144                    "initial bundle has a parent"
1145                );
1146            }
1147            Some(current) => {
1148                let current_digest = current.digest()?;
1149                ensure!(
1150                    next.body.generation > current.body.generation,
1151                    if next.body.generation == current.body.generation
1152                        && next_digest != current_digest
1153                    {
1154                        "equal-generation trust-bundle fork rejected"
1155                    } else {
1156                        "trust-bundle rollback rejected"
1157                    }
1158                );
1159                ensure!(
1160                    next.body.parent_digest.as_deref() == Some(current_digest.as_str()),
1161                    "broken trust-bundle lineage rejected"
1162                );
1163                if next.body.issuer_key_id != current.body.issuer_key_id {
1164                    ensure!(
1165                        next.body.recovery_from_key_id.as_deref()
1166                            == Some(current.body.issuer_key_id.as_str()),
1167                        "issuer substitution rejected"
1168                    );
1169                    ensure!(
1170                        self.recovery_issuers.get(&next.body.issuer_key_id) == Some(&next_issuer),
1171                        "unauthorized recovery issuer rejected"
1172                    );
1173                } else {
1174                    ensure!(
1175                        next.body.recovery_from_key_id.is_none(),
1176                        "ordinary trust update cannot claim recovery"
1177                    );
1178                }
1179            }
1180        }
1181        let high_water = OfflineTrustHighWater {
1182            trust_domain: next.body.trust_domain.clone(),
1183            issuer_key_id: next.body.issuer_key_id.clone(),
1184            generation: next.body.generation,
1185            digest: next_digest,
1186        };
1187        self.current = Some(next);
1188        Ok(high_water)
1189    }
1190
1191    pub fn credential(&self, device_id: &str, at_ms: u64) -> Result<&OfflineDeviceCredential> {
1192        let bundle = self
1193            .current
1194            .as_ref()
1195            .ok_or_else(|| anyhow!("no trust bundle installed"))?;
1196        let issuer = bundle.verify(at_ms)?;
1197        let credential = bundle
1198            .body
1199            .credentials
1200            .iter()
1201            .find(|credential| credential.body.device_id == device_id)
1202            .ok_or_else(|| anyhow!("device is not in the current trust bundle"))?;
1203        ensure!(
1204            !bundle
1205                .body
1206                .revoked_serials
1207                .contains(&credential.body.serial),
1208            "device credential is revoked"
1209        );
1210        credential.verify(&issuer, at_ms)?;
1211        Ok(credential)
1212    }
1213
1214    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
1215    pub(crate) fn device_id_for_endpoint(&self, endpoint_id: &str, at_ms: u64) -> Result<String> {
1216        let bundle = self
1217            .current
1218            .as_ref()
1219            .ok_or_else(|| anyhow!("no trust bundle installed"))?;
1220        let credential = bundle
1221            .body
1222            .credentials
1223            .iter()
1224            .find(|credential| credential.body.endpoint_id == endpoint_id)
1225            .ok_or_else(|| anyhow!("local observation is not in the current trust bundle"))?;
1226        self.credential(&credential.body.device_id, at_ms)?;
1227        Ok(credential.body.device_id.clone())
1228    }
1229
1230    /// Verify one fresh proof against the currently accepted bundle. This is
1231    /// the safe public entrypoint: callers cannot skip expiry, revocation,
1232    /// issuer, domain, or bundle-generation validation.
1233    pub fn verify_connection_proof(
1234        &self,
1235        proof: &OfflineConnectionProof,
1236        expected: &OfflineProofTranscript,
1237        replay_cache: &mut OfflineReplayCache,
1238        at_ms: u64,
1239    ) -> Result<OfflineAdmission> {
1240        let credential = self.credential(&expected.presenter_device_id, at_ms)?;
1241        verify_connection_proof(proof, credential, expected, replay_cache)
1242    }
1243
1244    /// Convert one local observation into typed admission intent. The result
1245    /// still cannot dial or route traffic.
1246    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
1247    pub fn authorize_local_candidate(
1248        &self,
1249        device_id: &str,
1250        endpoint_addr: iroh::EndpointAddr,
1251        at_ms: u64,
1252    ) -> Result<OfflineCandidateHandoff> {
1253        ensure!(
1254            crate::local_discovery::endpoint_addr_is_local_only(&endpoint_addr, &[]),
1255            "offline candidate contains a non-local address"
1256        );
1257        let credential = self.credential(device_id, at_ms)?;
1258        ensure!(
1259            credential.body.endpoint_id == endpoint_addr.id.to_string(),
1260            "offline candidate endpoint does not match its credential"
1261        );
1262        let generation = self
1263            .current
1264            .as_ref()
1265            .map(|bundle| bundle.body.generation)
1266            .ok_or_else(|| anyhow!("no trust bundle installed"))?;
1267        Ok(OfflineCandidateHandoff {
1268            trust_domain: credential.body.trust_domain.clone(),
1269            trust_generation: generation,
1270            credential_serial: credential.body.serial.clone(),
1271            device_id: credential.body.device_id.clone(),
1272            endpoint_addr,
1273            roles: credential.body.roles.clone(),
1274            assurance: credential.body.assurance,
1275        })
1276    }
1277
1278    /// Verify a fresh connection proof and bind its verdict to one exact
1279    /// physical generation. The returned handoff is opaque to adapters and is
1280    /// committed only by the existing Rust admission owner.
1281    #[cfg(not(target_arch = "wasm32"))]
1282    pub fn verify_transport_proof(
1283        &self,
1284        candidate: &OfflineCandidateHandoff,
1285        proof: &OfflineConnectionProof,
1286        expected: &OfflineProofTranscript,
1287        binding: OfflineTransportBinding,
1288        replay_cache: &mut OfflineReplayCache,
1289        at_ms: u64,
1290    ) -> Result<OfflineAdmissionHandoff> {
1291        ensure!(
1292            binding.transport_stable_id > 0,
1293            "invalid transport generation"
1294        );
1295        ensure!(
1296            expected.presenter_endpoint_id == binding.remote_endpoint_id
1297                && expected.verifier_endpoint_id == binding.local_endpoint_id,
1298            "offline proof is not bound to the supplied transport endpoints"
1299        );
1300        ensure!(
1301            expected.transport_stable_id == binding.transport_stable_id,
1302            "offline proof is not bound to the supplied transport generation"
1303        );
1304        ensure!(
1305            candidate.endpoint_addr.id.to_string() == binding.remote_endpoint_id,
1306            "offline candidate changed endpoints"
1307        );
1308        let current = self
1309            .credential(candidate.device_id(), at_ms)
1310            .context("offline candidate credential is no longer current")?;
1311        ensure!(
1312            current.body.serial == candidate.credential_serial
1313                && current.body.trust_domain == candidate.trust_domain,
1314            "offline candidate trust facts changed"
1315        );
1316        let current_generation = self
1317            .current
1318            .as_ref()
1319            .map(|bundle| bundle.body.generation)
1320            .ok_or_else(|| anyhow!("no trust bundle installed"))?;
1321        ensure!(
1322            current_generation == candidate.trust_generation,
1323            "offline candidate trust generation is stale"
1324        );
1325        let admission = verify_connection_proof(proof, current, expected, replay_cache)?;
1326        ensure!(
1327            admission.authoritative_device_id == candidate.device_id,
1328            "offline admission device mismatch"
1329        );
1330        Ok(OfflineAdmissionHandoff {
1331            candidate: candidate.clone(),
1332            binding,
1333            replay_id: proof.replay_id()?,
1334        })
1335    }
1336}
1337
1338#[cfg(not(target_arch = "wasm32"))]
1339#[derive(Debug, Clone, Serialize, Deserialize)]
1340#[serde(rename_all = "camelCase")]
1341struct OfflineTrustJournal {
1342    protocol: String,
1343    history: Vec<OfflineTrustBundle>,
1344    high_water: OfflineTrustHighWater,
1345    #[serde(default)]
1346    replay_ids: Vec<String>,
1347}
1348
1349#[cfg(not(target_arch = "wasm32"))]
1350fn read_bounded_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
1351    let metadata = match std::fs::symlink_metadata(path) {
1352        Ok(metadata) => metadata,
1353        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1354        Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())),
1355    };
1356    ensure!(
1357        !metadata.file_type().is_symlink(),
1358        "offline trust path is a symlink"
1359    );
1360    ensure!(
1361        metadata.len() <= MAX_OFFLINE_TRUST_JOURNAL_BYTES,
1362        "offline trust record exceeds the size bound"
1363    );
1364    serde_json::from_slice(
1365        &std::fs::read(path).with_context(|| format!("read {}", path.display()))?,
1366    )
1367    .with_context(|| format!("decode {}", path.display()))
1368    .map(Some)
1369}
1370
1371#[cfg(not(target_arch = "wasm32"))]
1372fn write_private_json(path: &Path, value: &impl Serialize) -> Result<()> {
1373    use std::io::Write as _;
1374
1375    let payload = serde_json::to_vec(value).context("encode offline trust record")?;
1376    ensure!(
1377        payload.len() as u64 <= MAX_OFFLINE_TRUST_JOURNAL_BYTES,
1378        "offline trust record exceeds the size bound"
1379    );
1380    let parent = path
1381        .parent()
1382        .filter(|parent| !parent.as_os_str().is_empty())
1383        .unwrap_or_else(|| Path::new("."));
1384    std::fs::create_dir_all(parent)
1385        .with_context(|| format!("create offline trust directory {}", parent.display()))?;
1386    let file_name = path
1387        .file_name()
1388        .and_then(|name| name.to_str())
1389        .ok_or_else(|| anyhow!("offline trust path requires a file name"))?;
1390    let temp = parent.join(format!(
1391        ".{file_name}.tmp-{}-{}",
1392        std::process::id(),
1393        crate::session_token::generate_nonce()
1394    ));
1395    let mut options = std::fs::OpenOptions::new();
1396    options.write(true).create_new(true);
1397    #[cfg(unix)]
1398    {
1399        use std::os::unix::fs::OpenOptionsExt;
1400        options.mode(0o600);
1401    }
1402    let result = (|| {
1403        let mut file = options
1404            .open(&temp)
1405            .with_context(|| format!("create {}", temp.display()))?;
1406        file.write_all(&payload)
1407            .with_context(|| format!("write {}", temp.display()))?;
1408        file.sync_all()
1409            .with_context(|| format!("sync {}", temp.display()))?;
1410        drop(file);
1411        #[cfg(windows)]
1412        if path.exists() {
1413            std::fs::remove_file(path).with_context(|| format!("replace {}", path.display()))?;
1414        }
1415        std::fs::rename(&temp, path).with_context(|| format!("install {}", path.display()))?;
1416        #[cfg(unix)]
1417        {
1418            use std::os::unix::fs::PermissionsExt;
1419            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
1420                .with_context(|| format!("secure {}", path.display()))?;
1421            std::fs::File::open(parent)
1422                .and_then(|directory| directory.sync_all())
1423                .with_context(|| format!("sync directory {}", parent.display()))?;
1424        }
1425        Ok::<(), anyhow::Error>(())
1426    })();
1427    if result.is_err() {
1428        let _ = std::fs::remove_file(&temp);
1429    }
1430    result
1431}
1432
1433/// Native, restart-safe trust journal. The bundle journal is written before a
1434/// separate high-water anchor so a crash can advance but never silently roll
1435/// back accepted trust. Hosts facing malicious whole-disk rollback should put
1436/// the anchor path on platform anti-rollback storage.
1437#[cfg(not(target_arch = "wasm32"))]
1438pub struct DurableOfflineTrustState {
1439    state_path: PathBuf,
1440    high_water_path: PathBuf,
1441    state: OfflineTrustState,
1442    history: Vec<OfflineTrustBundle>,
1443    replay_cache: OfflineReplayCache,
1444}
1445
1446#[cfg(not(target_arch = "wasm32"))]
1447impl DurableOfflineTrustState {
1448    pub fn open(
1449        state_path: impl Into<PathBuf>,
1450        trust_domain: &str,
1451        pinned_issuer: VerifyingKey,
1452        recovery_issuers: impl IntoIterator<Item = VerifyingKey>,
1453        at_ms: u64,
1454    ) -> Result<Self> {
1455        let state_path = state_path.into();
1456        let high_water_path = state_path.with_extension("high-water.json");
1457        let persisted_anchor = read_bounded_json::<OfflineTrustHighWater>(&high_water_path)?;
1458        let journal = read_bounded_json::<OfflineTrustJournal>(&state_path)?;
1459        ensure!(
1460            journal.is_some() || persisted_anchor.is_none(),
1461            "offline trust journal is missing behind its high-water anchor"
1462        );
1463
1464        let recovery_issuers = recovery_issuers.into_iter().collect::<Vec<_>>();
1465        let mut state = OfflineTrustState::new(trust_domain, pinned_issuer)?;
1466        for issuer in recovery_issuers {
1467            state.allow_recovery_issuer(issuer);
1468        }
1469        let mut history = Vec::new();
1470        let mut replay_cache = OfflineReplayCache::default();
1471        let mut accepted = Vec::new();
1472        if let Some(journal) = journal {
1473            ensure!(
1474                journal.protocol == OFFLINE_TRUST_JOURNAL_PROTOCOL,
1475                "unsupported offline trust journal"
1476            );
1477            ensure!(
1478                !journal.history.is_empty(),
1479                "offline trust journal is empty"
1480            );
1481            ensure!(
1482                journal.history.len() <= MAX_OFFLINE_TRUST_HISTORY,
1483                "offline trust journal exceeds the history bound"
1484            );
1485            for bundle in journal.history.iter().cloned() {
1486                accepted.push(state.apply_historical(bundle)?);
1487            }
1488            ensure!(
1489                accepted.last() == Some(&journal.high_water),
1490                "offline trust journal high-water mismatch"
1491            );
1492            replay_cache =
1493                OfflineReplayCache::from_entries(MAX_OFFLINE_REPLAY_ENTRIES, journal.replay_ids)?;
1494            history = journal.history;
1495
1496            if let Some(anchor) = persisted_anchor.as_ref() {
1497                ensure!(
1498                    accepted.iter().any(|water| water == anchor),
1499                    "offline trust rollback or fork rejected by high-water anchor"
1500                );
1501            }
1502            // The journal can outlive earlier credentials, but the current
1503            // generation must still satisfy time validity before startup can
1504            // expose it as usable trust or repair the anchor.
1505            state
1506                .current
1507                .as_ref()
1508                .expect("non-empty accepted trust history")
1509                .verify(at_ms)?;
1510            if persisted_anchor.as_ref() != accepted.last() {
1511                write_private_json(
1512                    &high_water_path,
1513                    accepted.last().expect("non-empty accepted trust history"),
1514                )?;
1515            }
1516        }
1517
1518        Ok(Self {
1519            state_path,
1520            high_water_path,
1521            state,
1522            history,
1523            replay_cache,
1524        })
1525    }
1526
1527    pub fn trust(&self) -> &OfflineTrustState {
1528        &self.state
1529    }
1530
1531    pub fn apply(&mut self, next: OfflineTrustBundle, at_ms: u64) -> Result<OfflineTrustHighWater> {
1532        ensure!(
1533            self.history.len() < MAX_OFFLINE_TRUST_HISTORY,
1534            "offline trust journal history is full"
1535        );
1536        let mut state = self.state.clone();
1537        let high_water = state.apply(next.clone(), at_ms)?;
1538        let mut history = self.history.clone();
1539        history.push(next);
1540        let journal = OfflineTrustJournal {
1541            protocol: OFFLINE_TRUST_JOURNAL_PROTOCOL.to_string(),
1542            history: history.clone(),
1543            high_water: high_water.clone(),
1544            replay_ids: self.replay_cache.entries(),
1545        };
1546        write_private_json(&self.state_path, &journal)?;
1547        let anchor_result = write_private_json(&self.high_water_path, &high_water);
1548        // The journal is authoritative after its atomic install even if the
1549        // secondary anchor sync fails. A restart repairs an older anchor from
1550        // the validated journal; keeping old memory here would enable rollback.
1551        self.state = state;
1552        self.history = history;
1553        anchor_result?;
1554        Ok(high_water)
1555    }
1556
1557    /// Verify and persist one accepted proof replay id before exposing its
1558    /// admission handoff. A crash can therefore lose an uncommitted admission,
1559    /// but cannot make an already-returned proof reusable after restart.
1560    #[cfg(not(target_arch = "wasm32"))]
1561    pub fn verify_transport_proof(
1562        &mut self,
1563        candidate: &OfflineCandidateHandoff,
1564        proof: &OfflineConnectionProof,
1565        expected: &OfflineProofTranscript,
1566        binding: OfflineTransportBinding,
1567        at_ms: u64,
1568    ) -> Result<OfflineAdmissionHandoff> {
1569        let mut replay_cache = self.replay_cache.clone();
1570        let handoff = self.state.verify_transport_proof(
1571            candidate,
1572            proof,
1573            expected,
1574            binding,
1575            &mut replay_cache,
1576            at_ms,
1577        )?;
1578        let high_water = self
1579            .state
1580            .high_water()?
1581            .ok_or_else(|| anyhow!("no trust bundle installed"))?;
1582        let journal = OfflineTrustJournal {
1583            protocol: OFFLINE_TRUST_JOURNAL_PROTOCOL.to_string(),
1584            history: self.history.clone(),
1585            high_water,
1586            replay_ids: replay_cache.entries(),
1587        };
1588        write_private_json(&self.state_path, &journal)?;
1589        self.replay_cache = replay_cache;
1590        Ok(handoff)
1591    }
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1595#[serde(rename_all = "camelCase")]
1596pub struct OfflineProofTranscript {
1597    pub protocol: String,
1598    pub trust_domain: String,
1599    pub credential_serial: String,
1600    pub presenter_device_id: String,
1601    pub presenter_endpoint_id: String,
1602    pub verifier_device_id: String,
1603    pub verifier_endpoint_id: String,
1604    pub presenter_nonce: String,
1605    pub verifier_nonce: String,
1606    /// Exact Rust-owned physical generation covered by this proof.
1607    pub transport_stable_id: u64,
1608    pub channel_binding: String,
1609}
1610
1611/// One verifier-owned challenge carried on the existing native-main admission
1612/// classifier. The responder signs a transcript containing these exact facts.
1613#[cfg(not(target_arch = "wasm32"))]
1614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1615#[serde(rename_all = "camelCase")]
1616pub(crate) struct OfflineProofChallenge {
1617    pub(crate) protocol: String,
1618    pub(crate) request_id: String,
1619    pub(crate) verifier_device_id: String,
1620    pub(crate) verifier_endpoint_id: String,
1621    pub(crate) verifier_nonce: String,
1622    pub(crate) transport_stable_id: u64,
1623    pub(crate) channel_binding: String,
1624}
1625
1626#[cfg(not(target_arch = "wasm32"))]
1627impl OfflineProofChallenge {
1628    pub(crate) fn validate(&self) -> Result<()> {
1629        ensure!(
1630            self.protocol == OFFLINE_PROOF_PROTOCOL,
1631            "unsupported offline proof challenge"
1632        );
1633        required(&self.request_id, "offline proof request id", 192)?;
1634        required(&self.verifier_device_id, "verifier device id", 192)?;
1635        required(&self.verifier_endpoint_id, "verifier endpoint id", 192)?;
1636        required(&self.verifier_nonce, "verifier nonce", 192)?;
1637        ensure!(
1638            self.transport_stable_id > 0,
1639            "offline proof challenge generation is invalid"
1640        );
1641        required(&self.channel_binding, "channel binding", 512)?;
1642        Ok(())
1643    }
1644}
1645
1646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1647#[serde(rename_all = "camelCase")]
1648pub struct OfflineConnectionProof {
1649    pub transcript: OfflineProofTranscript,
1650    pub signature: String,
1651}
1652
1653impl OfflineConnectionProof {
1654    pub fn create(signer: &dyn OfflineSigner, transcript: OfflineProofTranscript) -> Result<Self> {
1655        validate_transcript(&transcript)?;
1656        Ok(Self {
1657            signature: URL_SAFE_NO_PAD.encode(
1658                signer
1659                    .sign(&canonical_bytes("openrtc:offline-proof:v1", &transcript)?)?
1660                    .to_bytes(),
1661            ),
1662            transcript,
1663        })
1664    }
1665
1666    pub fn replay_id(&self) -> Result<String> {
1667        digest_json(self)
1668    }
1669}
1670
1671fn validate_transcript(transcript: &OfflineProofTranscript) -> Result<()> {
1672    ensure!(
1673        transcript.protocol == OFFLINE_PROOF_PROTOCOL,
1674        "unsupported offline proof"
1675    );
1676    required(&transcript.trust_domain, "trust domain", 128)?;
1677    required(&transcript.credential_serial, "credential serial", 192)?;
1678    required(&transcript.presenter_device_id, "presenter device id", 192)?;
1679    required(
1680        &transcript.presenter_endpoint_id,
1681        "presenter endpoint id",
1682        192,
1683    )?;
1684    required(&transcript.verifier_device_id, "verifier device id", 192)?;
1685    required(
1686        &transcript.verifier_endpoint_id,
1687        "verifier endpoint id",
1688        192,
1689    )?;
1690    required(&transcript.presenter_nonce, "presenter nonce", 192)?;
1691    required(&transcript.verifier_nonce, "verifier nonce", 192)?;
1692    ensure!(
1693        transcript.transport_stable_id > 0,
1694        "offline proof transport generation is invalid"
1695    );
1696    required(&transcript.channel_binding, "channel binding", 512)?;
1697    ensure!(
1698        transcript.presenter_device_id != transcript.verifier_device_id,
1699        "offline proof cannot target the same device"
1700    );
1701    Ok(())
1702}
1703
1704/// Bounded replay owner. One instance belongs to the Rust admission owner.
1705#[derive(Debug, Clone)]
1706pub struct OfflineReplayCache {
1707    capacity: usize,
1708    order: VecDeque<String>,
1709    entries: BTreeSet<String>,
1710}
1711
1712impl Default for OfflineReplayCache {
1713    fn default() -> Self {
1714        Self::new(MAX_OFFLINE_REPLAY_ENTRIES)
1715    }
1716}
1717
1718impl OfflineReplayCache {
1719    pub fn new(capacity: usize) -> Self {
1720        Self {
1721            capacity: capacity.clamp(1, MAX_OFFLINE_REPLAY_ENTRIES),
1722            order: VecDeque::new(),
1723            entries: BTreeSet::new(),
1724        }
1725    }
1726
1727    pub fn accept(&mut self, replay_id: String) -> Result<()> {
1728        ensure!(
1729            !self.entries.contains(&replay_id),
1730            "offline proof replay rejected"
1731        );
1732        while self.order.len() >= self.capacity {
1733            if let Some(oldest) = self.order.pop_front() {
1734                self.entries.remove(&oldest);
1735            }
1736        }
1737        self.entries.insert(replay_id.clone());
1738        self.order.push_back(replay_id);
1739        Ok(())
1740    }
1741
1742    fn from_entries(capacity: usize, entries: impl IntoIterator<Item = String>) -> Result<Self> {
1743        let mut cache = Self::new(capacity);
1744        for entry in entries {
1745            required(&entry, "offline replay id", 192)?;
1746            cache.accept(entry)?;
1747        }
1748        Ok(cache)
1749    }
1750
1751    fn entries(&self) -> Vec<String> {
1752        self.order.iter().cloned().collect()
1753    }
1754}
1755
1756#[derive(Debug, Clone, PartialEq, Eq)]
1757pub struct OfflineAdmission {
1758    pub authoritative_device_id: String,
1759    pub credential_serial: String,
1760    pub roles: Vec<String>,
1761    pub assurance: OfflineAssurance,
1762}
1763
1764/// Verify a remote proof against the exact current transport facts.
1765///
1766/// `expected` must be constructed by the connection owner after it has read
1767/// both nonces and the current protected-channel binding. A later local
1768/// generation check remains mandatory before this verdict is committed.
1769fn verify_connection_proof(
1770    proof: &OfflineConnectionProof,
1771    credential: &OfflineDeviceCredential,
1772    expected: &OfflineProofTranscript,
1773    replay_cache: &mut OfflineReplayCache,
1774) -> Result<OfflineAdmission> {
1775    validate_transcript(expected)?;
1776    ensure!(
1777        &proof.transcript == expected,
1778        "offline proof transcript mismatch"
1779    );
1780    ensure!(
1781        proof.transcript.trust_domain == credential.body.trust_domain,
1782        "offline proof trust-domain mismatch"
1783    );
1784    ensure!(
1785        proof.transcript.credential_serial == credential.body.serial,
1786        "offline proof credential mismatch"
1787    );
1788    ensure!(
1789        proof.transcript.presenter_device_id == credential.body.device_id
1790            && proof.transcript.presenter_endpoint_id == credential.body.endpoint_id,
1791        "offline proof identity mismatch"
1792    );
1793    public_key(&credential.body.proof_public_key)?
1794        .verify(
1795            &canonical_bytes("openrtc:offline-proof:v1", &proof.transcript)?,
1796            &signature(&proof.signature)?,
1797        )
1798        .context("verify offline connection proof")?;
1799    replay_cache.accept(proof.replay_id()?)?;
1800    Ok(OfflineAdmission {
1801        authoritative_device_id: credential.body.device_id.clone(),
1802        credential_serial: credential.body.serial.clone(),
1803        roles: credential.body.roles.clone(),
1804        assurance: credential.body.assurance,
1805    })
1806}
1807
1808/// Deterministic bounded-neighbor intent for a signed offline member set.
1809///
1810/// The result is only desired-peer input. The existing Rust actor still owns
1811/// every dial, retry, admission, generation replacement, and withdrawal.
1812pub fn bounded_swarm_neighbors(
1813    local_device_id: &str,
1814    member_device_ids: impl IntoIterator<Item = String>,
1815    requested_degree: usize,
1816) -> Result<Vec<String>> {
1817    let local = required(local_device_id, "local device id", 192)?;
1818    let members = member_device_ids
1819        .into_iter()
1820        .map(|member| required(&member, "swarm device id", 192))
1821        .collect::<Result<BTreeSet<_>>>()?;
1822    ensure!(
1823        members.len() <= MAX_OFFLINE_SWARM_MEMBERS,
1824        "offline swarm is too large"
1825    );
1826    ensure!(
1827        members.contains(&local),
1828        "local device is not in the offline swarm"
1829    );
1830    if members.len() <= 1 {
1831        return Ok(Vec::new());
1832    }
1833    let members = members.into_iter().collect::<Vec<_>>();
1834    let index = members.iter().position(|member| member == &local).unwrap();
1835    let degree = requested_degree
1836        .clamp(1, MAX_OFFLINE_SWARM_DEGREE)
1837        .min(members.len() - 1);
1838    let mut neighbors = BTreeSet::new();
1839    for distance in 1..members.len() {
1840        neighbors.insert(members[(index + distance) % members.len()].clone());
1841        if neighbors.len() == degree {
1842            break;
1843        }
1844        neighbors.insert(members[(index + members.len() - distance) % members.len()].clone());
1845        if neighbors.len() == degree {
1846            break;
1847        }
1848    }
1849    Ok(neighbors.into_iter().collect())
1850}
1851
1852#[cfg(test)]
1853mod tests {
1854    use super::*;
1855
1856    fn fixture() -> (
1857        SoftwareOfflineSigner,
1858        SoftwareOfflineSigner,
1859        OfflineEnrollmentRequest,
1860        OfflineDeviceCredential,
1861    ) {
1862        let issuer = SoftwareOfflineSigner::from_seed([1; 32]);
1863        let device = SoftwareOfflineSigner::from_seed([2; 32]);
1864        let request = OfflineEnrollmentRequest::create(
1865            &device,
1866            "field-a",
1867            "device-a",
1868            "endpoint-a",
1869            "enroll-a",
1870            vec!["sensor".into()],
1871            OfflineAssurance::HardwareBacked,
1872            1_000,
1873        )
1874        .unwrap();
1875        let credential = OfflineDeviceCredential::issue(
1876            &issuer,
1877            &request,
1878            "serial-a",
1879            1,
1880            vec!["sensor".into()],
1881            OfflineAssurance::Software,
1882            1_000,
1883            10_000,
1884        )
1885        .unwrap();
1886        (issuer, device, request, credential)
1887    }
1888
1889    #[test]
1890    fn enrollment_and_credential_require_both_target_and_issuer_signatures() {
1891        let (issuer, _, mut request, credential) = fixture();
1892        request.body.device_id = "attacker".into();
1893        assert!(request.verify().is_err());
1894        credential
1895            .verify(&issuer.verifying_key().unwrap(), 2_000)
1896            .unwrap();
1897        let wrong = SoftwareOfflineSigner::from_seed([9; 32]);
1898        assert!(credential
1899            .verify(&wrong.verifying_key().unwrap(), 2_000)
1900            .is_err());
1901    }
1902
1903    #[test]
1904    fn request_cannot_self_certify_hardware_assurance() {
1905        let (_, _, request, credential) = fixture();
1906        assert_eq!(
1907            request.body.requested_assurance,
1908            OfflineAssurance::HardwareBacked
1909        );
1910        assert_eq!(
1911            credential.body.assurance,
1912            OfflineAssurance::Software,
1913            "the issuer, not the request, certifies credential assurance"
1914        );
1915    }
1916
1917    #[test]
1918    fn trust_bundle_rejects_rollback_fork_and_unapproved_issuer_recovery() {
1919        let (issuer, _, _, credential) = fixture();
1920        let first = OfflineTrustBundle::issue(
1921            &issuer,
1922            "field-a",
1923            1,
1924            None,
1925            None,
1926            vec![credential.clone()],
1927            vec![],
1928        )
1929        .unwrap();
1930        let mut state = OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
1931        let first_water = state.apply(first, 2_000).unwrap();
1932
1933        let fork = OfflineTrustBundle::issue(
1934            &issuer,
1935            "field-a",
1936            1,
1937            None,
1938            None,
1939            vec![credential.clone()],
1940            vec![],
1941        )
1942        .unwrap();
1943        assert!(state.apply(fork, 2_000).is_err());
1944        assert_eq!(state.high_water().unwrap(), Some(first_water.clone()));
1945
1946        let attacker = SoftwareOfflineSigner::from_seed([7; 32]);
1947        let recovery = OfflineTrustBundle::issue(
1948            &attacker,
1949            "field-a",
1950            2,
1951            Some(first_water.digest.clone()),
1952            Some(first_water.issuer_key_id.clone()),
1953            vec![],
1954            vec![credential.body.serial],
1955        )
1956        .unwrap();
1957        assert!(state.apply(recovery, 2_000).is_err());
1958        assert_eq!(state.high_water().unwrap(), Some(first_water));
1959    }
1960
1961    #[test]
1962    fn proof_binds_both_peers_nonces_channel_and_rejects_replay() {
1963        let (_, device, _, credential) = fixture();
1964        let transcript = OfflineProofTranscript {
1965            protocol: OFFLINE_PROOF_PROTOCOL.into(),
1966            trust_domain: "field-a".into(),
1967            credential_serial: "serial-a".into(),
1968            presenter_device_id: "device-a".into(),
1969            presenter_endpoint_id: "endpoint-a".into(),
1970            verifier_device_id: "device-b".into(),
1971            verifier_endpoint_id: "endpoint-b".into(),
1972            presenter_nonce: "presenter-nonce".into(),
1973            verifier_nonce: "verifier-nonce".into(),
1974            transport_stable_id: 7,
1975            channel_binding: "quic-exporter-current-generation".into(),
1976        };
1977        let proof = OfflineConnectionProof::create(&device, transcript.clone()).unwrap();
1978        let mut replay = OfflineReplayCache::new(8);
1979        let (issuer, _, _, _) = fixture();
1980        let bundle =
1981            OfflineTrustBundle::issue(&issuer, "field-a", 1, None, None, vec![credential], vec![])
1982                .unwrap();
1983        let mut state = OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
1984        state.apply(bundle, 2_000).unwrap();
1985        state
1986            .verify_connection_proof(&proof, &transcript, &mut replay, 2_000)
1987            .unwrap();
1988        assert!(state
1989            .verify_connection_proof(&proof, &transcript, &mut replay, 2_000)
1990            .is_err());
1991
1992        let mut wrong_generation = transcript;
1993        wrong_generation.transport_stable_id = 8;
1994        wrong_generation.channel_binding = "retired-generation".into();
1995        assert!(state
1996            .verify_connection_proof(
1997                &proof,
1998                &wrong_generation,
1999                &mut OfflineReplayCache::new(8),
2000                2_000,
2001            )
2002            .is_err());
2003    }
2004
2005    #[cfg(not(target_arch = "wasm32"))]
2006    #[test]
2007    fn durable_trust_restart_rejects_rollback_fork_and_preserves_revocation() {
2008        let (issuer, _, _, credential) = fixture();
2009        let state_dir = std::env::temp_dir().join(format!(
2010            "openrtc-offline-trust-{}",
2011            crate::session_token::generate_nonce()
2012        ));
2013        std::fs::create_dir_all(&state_dir).unwrap();
2014        let state_path = state_dir.join("trust.json");
2015        let first = OfflineTrustBundle::issue(
2016            &issuer,
2017            "field-a",
2018            1,
2019            None,
2020            None,
2021            vec![credential.clone()],
2022            vec![],
2023        )
2024        .unwrap();
2025        let mut durable = DurableOfflineTrustState::open(
2026            &state_path,
2027            "field-a",
2028            issuer.verifying_key().unwrap(),
2029            [],
2030            2_000,
2031        )
2032        .unwrap();
2033        let first_water = durable.apply(first.clone(), 2_000).unwrap();
2034        let first_journal = std::fs::read(&state_path).unwrap();
2035        let second = OfflineTrustBundle::issue(
2036            &issuer,
2037            "field-a",
2038            2,
2039            Some(first_water.digest.clone()),
2040            None,
2041            vec![],
2042            vec![credential.body.serial.clone()],
2043        )
2044        .unwrap();
2045        let second_water = durable.apply(second.clone(), 2_000).unwrap();
2046        let second_journal = std::fs::read(&state_path).unwrap();
2047        let invalid_fork = OfflineTrustBundle::issue(
2048            &issuer,
2049            "field-a",
2050            2,
2051            Some(first_water.digest.clone()),
2052            None,
2053            vec![],
2054            vec![],
2055        )
2056        .unwrap();
2057        assert!(durable.apply(invalid_fork, 2_000).is_err());
2058        assert_eq!(
2059            durable.trust().high_water().unwrap(),
2060            Some(second_water.clone())
2061        );
2062        assert_eq!(std::fs::read(&state_path).unwrap(), second_journal);
2063        drop(durable);
2064
2065        let restarted = DurableOfflineTrustState::open(
2066            &state_path,
2067            "field-a",
2068            issuer.verifying_key().unwrap(),
2069            [],
2070            2_000,
2071        )
2072        .unwrap();
2073        assert_eq!(
2074            restarted.trust().high_water().unwrap(),
2075            Some(second_water.clone())
2076        );
2077        assert!(restarted.trust().credential("device-a", 2_000).is_err());
2078        drop(restarted);
2079
2080        std::fs::write(&state_path, &first_journal).unwrap();
2081        let rollback_error = DurableOfflineTrustState::open(
2082            &state_path,
2083            "field-a",
2084            issuer.verifying_key().unwrap(),
2085            [],
2086            2_000,
2087        )
2088        .err()
2089        .expect("rollback must be rejected")
2090        .to_string();
2091        assert!(rollback_error.contains("rollback or fork"));
2092
2093        let fork = OfflineTrustBundle::issue(
2094            &issuer,
2095            "field-a",
2096            2,
2097            Some(first_water.digest),
2098            None,
2099            vec![credential],
2100            vec![],
2101        )
2102        .unwrap();
2103        let mut fork_state =
2104            OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
2105        fork_state.apply(first.clone(), 2_000).unwrap();
2106        let fork_water = fork_state.apply(fork.clone(), 2_000).unwrap();
2107        write_private_json(
2108            &state_path,
2109            &OfflineTrustJournal {
2110                protocol: OFFLINE_TRUST_JOURNAL_PROTOCOL.to_string(),
2111                history: vec![first, fork],
2112                high_water: fork_water,
2113                replay_ids: Vec::new(),
2114            },
2115        )
2116        .unwrap();
2117        let fork_error = DurableOfflineTrustState::open(
2118            &state_path,
2119            "field-a",
2120            issuer.verifying_key().unwrap(),
2121            [],
2122            2_000,
2123        )
2124        .err()
2125        .expect("fork must be rejected")
2126        .to_string();
2127        assert!(fork_error.contains("rollback or fork"));
2128
2129        std::fs::write(&state_path, second_journal).unwrap();
2130        let restored = DurableOfflineTrustState::open(
2131            &state_path,
2132            "field-a",
2133            issuer.verifying_key().unwrap(),
2134            [],
2135            20_000,
2136        )
2137        .unwrap();
2138        assert_eq!(restored.trust().high_water().unwrap(), Some(second_water));
2139        assert!(restored.trust().credential("device-a", 20_000).is_err());
2140        std::fs::remove_dir_all(&state_dir).unwrap();
2141    }
2142
2143    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
2144    #[test]
2145    fn durable_trust_restart_rejects_an_already_accepted_proof() {
2146        let issuer = SoftwareOfflineSigner::from_seed([11; 32]);
2147        let device = SoftwareOfflineSigner::from_seed([12; 32]);
2148        let local_endpoint = iroh::SecretKey::generate().public();
2149        let remote_endpoint = iroh::SecretKey::generate().public();
2150        let request = OfflineEnrollmentRequest::create(
2151            &device,
2152            "field-replay",
2153            "device-a",
2154            &remote_endpoint.to_string(),
2155            "enroll-replay",
2156            vec!["sensor".into()],
2157            OfflineAssurance::Software,
2158            1_000,
2159        )
2160        .unwrap();
2161        let credential = OfflineDeviceCredential::issue(
2162            &issuer,
2163            &request,
2164            "serial-replay",
2165            1,
2166            vec!["sensor".into()],
2167            OfflineAssurance::Software,
2168            1_000,
2169            10_000,
2170        )
2171        .unwrap();
2172        let bundle = OfflineTrustBundle::issue(
2173            &issuer,
2174            "field-replay",
2175            1,
2176            None,
2177            None,
2178            vec![credential],
2179            vec![],
2180        )
2181        .unwrap();
2182        let state_dir = std::env::temp_dir().join(format!(
2183            "openrtc-offline-replay-{}",
2184            crate::session_token::generate_nonce()
2185        ));
2186        std::fs::create_dir_all(&state_dir).unwrap();
2187        let state_path = state_dir.join("trust.json");
2188        let mut durable = DurableOfflineTrustState::open(
2189            &state_path,
2190            "field-replay",
2191            issuer.verifying_key().unwrap(),
2192            [],
2193            2_000,
2194        )
2195        .unwrap();
2196        durable.apply(bundle, 2_000).unwrap();
2197        let endpoint_addr = iroh::EndpointAddr::new(remote_endpoint)
2198            .with_ip_addr("127.0.0.1:4433".parse().unwrap());
2199        let candidate = durable
2200            .trust()
2201            .authorize_local_candidate("device-a", endpoint_addr, 2_000)
2202            .unwrap();
2203        let transcript = OfflineProofTranscript {
2204            protocol: OFFLINE_PROOF_PROTOCOL.into(),
2205            trust_domain: "field-replay".into(),
2206            credential_serial: "serial-replay".into(),
2207            presenter_device_id: "device-a".into(),
2208            presenter_endpoint_id: remote_endpoint.to_string(),
2209            verifier_device_id: "device-b".into(),
2210            verifier_endpoint_id: local_endpoint.to_string(),
2211            presenter_nonce: "presenter-restart".into(),
2212            verifier_nonce: "verifier-restart".into(),
2213            transport_stable_id: 9,
2214            channel_binding: "quic-exporter-generation-9".into(),
2215        };
2216        let proof = OfflineConnectionProof::create(&device, transcript.clone()).unwrap();
2217        let binding = OfflineTransportBinding {
2218            local_endpoint_id: local_endpoint.to_string(),
2219            remote_endpoint_id: remote_endpoint.to_string(),
2220            transport_stable_id: 9,
2221        };
2222        durable
2223            .verify_transport_proof(&candidate, &proof, &transcript, binding.clone(), 2_000)
2224            .unwrap();
2225        drop(durable);
2226
2227        let mut restarted = DurableOfflineTrustState::open(
2228            &state_path,
2229            "field-replay",
2230            issuer.verifying_key().unwrap(),
2231            [],
2232            2_000,
2233        )
2234        .unwrap();
2235        let error = restarted
2236            .verify_transport_proof(&candidate, &proof, &transcript, binding, 2_000)
2237            .expect_err("accepted proof must remain consumed after restart")
2238            .to_string();
2239        assert!(error.contains("replay rejected"));
2240        std::fs::remove_dir_all(state_dir).unwrap();
2241    }
2242
2243    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
2244    #[test]
2245    fn local_candidate_proof_handoff_is_generation_and_replay_bound() {
2246        let issuer = SoftwareOfflineSigner::from_seed([1; 32]);
2247        let device = SoftwareOfflineSigner::from_seed([2; 32]);
2248        let local_endpoint = iroh::SecretKey::generate().public();
2249        let remote_endpoint = iroh::SecretKey::generate().public();
2250        let request = OfflineEnrollmentRequest::create(
2251            &device,
2252            "field-a",
2253            "device-a",
2254            &remote_endpoint.to_string(),
2255            "enroll-a",
2256            vec!["sensor".into()],
2257            OfflineAssurance::Software,
2258            1_000,
2259        )
2260        .unwrap();
2261        let credential = OfflineDeviceCredential::issue(
2262            &issuer,
2263            &request,
2264            "serial-a",
2265            1,
2266            vec!["sensor".into()],
2267            OfflineAssurance::Software,
2268            1_000,
2269            10_000,
2270        )
2271        .unwrap();
2272        let bundle =
2273            OfflineTrustBundle::issue(&issuer, "field-a", 1, None, None, vec![credential], vec![])
2274                .unwrap();
2275        let mut state = OfflineTrustState::new("field-a", issuer.verifying_key().unwrap()).unwrap();
2276        state.apply(bundle, 2_000).unwrap();
2277        let candidate = state
2278            .authorize_local_candidate(
2279                "device-a",
2280                iroh::EndpointAddr::new(remote_endpoint)
2281                    .with_ip_addr("127.0.0.1:4433".parse().unwrap()),
2282                2_000,
2283            )
2284            .unwrap();
2285        let transcript = OfflineProofTranscript {
2286            protocol: OFFLINE_PROOF_PROTOCOL.into(),
2287            trust_domain: "field-a".into(),
2288            credential_serial: "serial-a".into(),
2289            presenter_device_id: "device-a".into(),
2290            presenter_endpoint_id: remote_endpoint.to_string(),
2291            verifier_device_id: "device-b".into(),
2292            verifier_endpoint_id: local_endpoint.to_string(),
2293            presenter_nonce: "presenter-nonce".into(),
2294            verifier_nonce: "verifier-nonce".into(),
2295            transport_stable_id: 7,
2296            channel_binding: "quic-exporter-generation-7".into(),
2297        };
2298        let proof = OfflineConnectionProof::create(&device, transcript.clone()).unwrap();
2299        let binding = OfflineTransportBinding {
2300            local_endpoint_id: local_endpoint.to_string(),
2301            remote_endpoint_id: remote_endpoint.to_string(),
2302            transport_stable_id: 7,
2303        };
2304        let mut replay = OfflineReplayCache::new(8);
2305        let handoff = state
2306            .verify_transport_proof(
2307                &candidate,
2308                &proof,
2309                &transcript,
2310                binding.clone(),
2311                &mut replay,
2312                2_000,
2313            )
2314            .unwrap();
2315        assert_eq!(handoff.transport_stable_id(), 7);
2316        assert_eq!(handoff.device_id(), "device-a");
2317        assert!(state
2318            .verify_transport_proof(&candidate, &proof, &transcript, binding, &mut replay, 2_000,)
2319            .is_err());
2320        let stale_binding = OfflineTransportBinding {
2321            local_endpoint_id: local_endpoint.to_string(),
2322            remote_endpoint_id: remote_endpoint.to_string(),
2323            transport_stable_id: 8,
2324        };
2325        assert!(state
2326            .verify_transport_proof(
2327                &candidate,
2328                &proof,
2329                &transcript,
2330                stale_binding,
2331                &mut OfflineReplayCache::new(8),
2332                2_000,
2333            )
2334            .is_err());
2335    }
2336
2337    #[test]
2338    fn bounded_swarm_never_projects_quadratic_degree() {
2339        let members = (0..100)
2340            .map(|index| format!("device-{index:03}"))
2341            .collect::<Vec<_>>();
2342        for local in &members {
2343            let neighbors = bounded_swarm_neighbors(local, members.clone(), 99).unwrap();
2344            assert_eq!(neighbors.len(), MAX_OFFLINE_SWARM_DEGREE);
2345            assert!(!neighbors.contains(local));
2346        }
2347    }
2348}