1use 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#[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 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 pub async fn install_runtime(&self, config: OfflineRuntimeConfig) -> Result<()> {
118 self.client.install_offline_runtime(config).await
119 }
120
121 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 pub async fn retire_device(&self, device_id: &str) -> Result<bool> {
135 self.client.retire_offline_device(device_id).await
136 }
137
138 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 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(¤t.signer, &config.signer)
231 && Arc::ptr_eq(¤t.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 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 #[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 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#[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
593pub trait OfflineSigner: Send + Sync {
595 fn verifying_key(&self) -> Result<VerifyingKey>;
596 fn sign(&self, message: &[u8]) -> Result<Signature>;
597}
598
599pub 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 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 #[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#[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#[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#[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#[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 #[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 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 #[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 #[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#[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 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 self.state = state;
1552 self.history = history;
1553 anchor_result?;
1554 Ok(high_water)
1555 }
1556
1557 #[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 pub transport_stable_id: u64,
1608 pub channel_binding: String,
1609}
1610
1611#[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#[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
1764fn 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
1808pub 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}