Offline edge devices

Provision and verify local devices without a cloud connection

OpenRTC can create a signed enrollment request for a native edge device without sending its private key to JavaScript or a cloud service. The device creates and keeps its own proof key. Your fleet authority signs the public request and decides which roles the device receives.

Discovery and trust are separate:

LAN / Bluetooth / QR observation
             ↓
untrusted local candidate
             ↓
signed credential + fresh device proof
             ↓
Rust admission and application encryption
             ↓
application traffic

A nearby advertisement is never permission. An Iroh ticket is dialing information, not a device credential, and OpenRTC never broadcasts a bearer ticket or shared fleet key.

Check support

The same TypeScript call is safe in browser and native applications:

const support = await rtc.offline.support();

if (!support.localMesh) {
  showProvisioningInstructions(support.reason);
}

A browser-only client reports localMesh: false. It does not silently start a cloud connection. A Tauri client asks the native Rust host whether the binary was compiled with LAN support.

Create a target enrollment request

import { OpenRTC } from 'openrtc/native';

const rtc = OpenRTC({ apiKey }, tauriBridge);
const request = await rtc.offline.createEnrollmentRequest({
  trustDomain: 'field-team-a',
  requestedRoles: ['sensor'],
});

displayQrCode(JSON.stringify(request));

Rust starts or reuses the one native Iroh endpoint, checks that the request is bound to that endpoint, and asks the host's private signer to sign it. Only the public request returns over IPC.

The WebView cannot choose its assurance. The native host reports the signer as software by default and may report stronger custody only when its trusted signer implementation can prove that claim. The fleet issuer independently chooses the assurance it certifies in the final device credential.

Rust without TypeScript

The openrtc crate exposes the same protocol directly:

use openrtc::offline::{
    OfflineAssurance,
    OfflineEnrollmentOptions,
};

let request = client.offline()
    .create_enrollment_request(
        app_private_signer.as_ref(),
        OfflineEnrollmentOptions {
            trust_domain: "field-team-a".into(),
            device_id: "sensor-17".into(),
            enrollment_nonce: fresh_nonce(),
            requested_roles: vec!["sensor".into()],
            requested_assurance: OfflineAssurance::Software,
            created_at_ms: now_ms(),
        },
    )
    .await?;

The crate also defines issuer-signed device credentials, monotonic signed trust bundles, recovery lineage, fresh proof transcripts, bounded replay protection, and deterministic bounded-neighbor intent. The native Rust client works without the npm package.

Keep the same identity after restart

For native Unix hosts, OpenRTC provides a private-file signer with no keychain prompt. At process startup, configure the client with NetworkPolicy::LocalOnly, then load its two separate keys before creating an enrollment request:

use std::sync::Arc;
use openrtc::native::{FileSigner, load_or_create_endpoint_key};
use openrtc::offline::OfflineSigner;

let app_private_signer: Arc<dyn OfflineSigner> = Arc::new(
    FileSigner::load_or_create(app_data.join("offline/proof.key"))?
);
let endpoint_key = load_or_create_endpoint_key(
    app_data.join("offline/iroh.key")
)?;
client.init_iroh(Some(endpoint_key.to_bytes().to_vec()), Vec::new()).await?;

app_data must be an absolute host-owned path. The helper creates a private directory when needed and rejects unsafe permissions, symlinks and damaged keys. Reopening an existing file never silently replaces its identity. Other platforms can provide their own OfflineSigner; this file helper currently requires Unix permissions and fails explicitly elsewhere.

The proof key proves enrollment; the endpoint key identifies the Iroh device. Keep both local. Software storage cannot stop someone who obtains copies of both private keys from impersonating the device. Hardware-backed custody is a separate profile, not a property of this file helper.

Provision using public files

  1. The device exports its signed OfflineEnrollmentRequest as JSON. No private key goes in the file.
  2. The fleet owner verifies it, chooses roles, and calls OfflineDeviceCredential::issue and OfflineTrustBundle::issue using the owner's signer.
  3. Transfer the signed bundle to the device by your chosen channel. Pin the trusted issuer's public key independently; do not trust a key simply because it arrived inside a bundle. Bound file size before parsing JSON.
  4. Apply the bundle once, then reopen DurableOfflineTrustState on restart. Applying a bundle is a forward-only policy update, not a startup heartbeat. A replacement bundle needs a higher generation and the required parent digest.

After the fleet authority signs a trust bundle, the native host installs the durable trust owner. The client must already use NetworkPolicy::LocalOnly and have its one Iroh endpoint running:

use std::sync::{Arc, Mutex};
use openrtc::offline::{DurableOfflineTrustState, OfflineRuntimeConfig};

let mut trust = DurableOfflineTrustState::open(
    app_data.join("offline-trust.json"),
    "field-team-a",
    pinned_issuer_key,
    recovery_issuer_keys,
    now_ms(),
)?;
trust.apply(signed_bundle, now_ms())?;

client.offline().install_runtime(OfflineRuntimeConfig {
    local_device_id: "sensor-17".into(),
    signer: app_private_signer,
    trust: Arc::new(Mutex::new(trust)),
}).await?;

From that point, native mDNS observations are still only candidates. OpenRTC matches them to signed trust, sends the eligible set to its existing peer actor, and admits a route only after both devices complete a fresh proof bound to the current encrypted connection. Applying a newer signed bundle immediately retires a removed, revoked, or rotated identity. The replacement endpoint must be observed and prove its new key before traffic resumes.

Current implementation boundary

The public enrollment, credential, trust-bundle, proof, local-only endpoint, and support-reporting contracts are implemented. Native LAN candidates now flow through the existing Rust desired-peer and admission owners, including durable anti-replay and live signed revocation. Physical QR/USB/NFC/Bluetooth provisioning and the required five-minute physical smoke are still tracked work. Private-file identity persistence and signed-file provisioning have separate native-process proof; that is not proof of LAN reconnection after process restart or physical radio behavior. Do not treat an observed LAN device as connected or authorized until Rust reports the current route ready.

Usage credits

Local request signing, credential verification, mDNS observation, bilateral proof, and direct private/link-local traffic consume zero OpenRTC usage credits. They do not contact the managed gateway or relay. Optional future online inventory, backup, or trust-bundle synchronization is a separate hosted operation and will use its published credit value.