Identity & Trust
RuntimeCoordinate names each side of a connection; a crypto identity over durable storage proves it; trust-on-first-use pins the key. The one identity model both planes gate on.
Every secure connection has two ends, and each end has a name and a proof. The name is a
RuntimeCoordinate; the proof is a crypto identity backed by your storage; and trust-on-first-use
(TOFU) is what ties a name to a key so the second connection can tell it’s really the same peer. This
is the one identity model the whole stack rests on — action routing, realm avatars, and devtools
admission all read it.
Coordinates — the network label
Section titled “Coordinates — the network label”A RuntimeCoordinate labels a runtime environment and routes traffic to the right side. It’s exported
from @nice-code/wire (and re-exported by @nice-code/action), and it lives in your shared module so
both ends reference the exact same value:
RuntimeCoordinate.env("backend"); // a named environmentRuntimeCoordinate.env("backend").specify({ perId: "worker-1" }); // env + a per-instance idRuntimeCoordinate.env("backend").withPersistentId(id.toString()); // env + a persistent instance idRuntimeCoordinate.unknown; // unspecifiedThe identity you pass to createWireClient (or that an ActionRuntime is built from) is a
coordinate; the peer you dial is the other side’s coordinate. A realm defaults its avatar’s
persistentId / instanceId from the connection’s authenticated coordinate — so “who is this
avatar?” is answered by the same handshake that authenticated the socket.
The crypto identity lives in your storage
Section titled “The crypto identity lives in your storage”A secure connection authenticates with an Ed25519 keypair — a ClientCryptoKeyLink that
createWireClient builds over the storage you give it. That storage holds this runtime’s signing
keys and the verify keys of peers it has met. It’s why a secure connection needs storage and a
fully none connection doesn’t.
The handshake runs once, before any protocol frame: each side sends its coordinate and public key, then proves it holds the matching private key by signing a fresh, single-use challenge that binds both nonces, both identities, the wire-dictionary version, the security level, and every key exchanged. A replayed or tampered handshake produces a different challenge and fails. After that one exchange, every message rides inside the already-authenticated session — no per-message crypto. (Which level of protection that session gives — identity only, or identity + encrypted frames — is Security Levels.)
Trust-on-first-use (TOFU)
Section titled “Trust-on-first-use (TOFU)”The first time a peer sees a given identity — keyed by the coordinate’s envId + its persistent id
(falling back to the per-boot instance id when none is set) — it pins that identity’s verify key. A
later handshake presenting a different key for the same identity is rejected with
identity_pin_mismatch. That is what stops a third party who merely knows your id from impersonating
you: they can’t produce the pinned key’s signature.
withPersistentId is therefore a trust decision, not just a routing name — it tells the peer
“remember my key under this id, forever.”
Your storage must be durable — the mistake that bites
Section titled “Your storage must be durable — the mistake that bites”The verify key lives in storage. If that store is memory- or session-backed, the keypair regenerates
on every reload while the pinned id stays the same — so the first load works and every load after
it is rejected, permanently, for that identity. In a browser use createWebLocalStorageAdapter; on
React Native back it with AsyncStorage (see React Native / Expo);
memory adapters are for tests. createWireClient / connectChannel warn at construction when they see
a persistent id paired with a known-ephemeral store.
A per-session identity is perfectly fine — as long as the id is minted fresh too (a startup
crypto.randomUUID() as the coordinate’s perId, with memory storage). Declare it with
ephemeralIdentity: true to acknowledge the pairing and silence the warning.
Recovering from a pin mismatch
Section titled “Recovering from a pin mismatch”A pin mismatch is permanent for that identity+key pair — the keep-alive redial ladder parks on it rather than retrying a handshake that can never succeed. Two things that do not recover it, and two that do:
- Does not work: clearing only the client’s crypto storage. A fresh key under the same persistent id is exactly what the pin rejects.
- Works: connect under a new persistent id (a fresh identity), or remove the peer-side pin via the verify-key resolver’s storage.
A rejected handshake surfaces where connect() (or the first dispatch) rejects — don’t
fire-and-forget that promise. The mismatch is typed, so an app can branch on it:
import { err_wire_connect } from "@nice-code/wire";
try { await client.connect();} catch (e) { if (err_wire_connect.isExact(e) && e.hasId("identity_pin_mismatch")) { // Permanent for this identity+key pair — recover under a new persistent id, // or clear the peer-side pin. Retrying the same pair never succeeds. }}On Cloudflare: persist the pins
Section titled “On Cloudflare: persist the pins”A Durable Object host keeps its identity and its trusted-peer pins in DO storage (the default in
serveWireDurableObject). A pin that lived only in memory would re-TOFU on every hibernation wake and
defeat the protection — so on Cloudflare, persistence isn’t optional. See
Serving a Realm.
What rests on this
Section titled “What rests on this”TOFU is not certificate-authority PKI: it’s strong against an outsider who never held the key, but if
first contact happens over a hostile network, the pin is of whoever answered. That matters most for a
realm, whose entire authority model — every alter rule — rests on the avatar identity this pin
establishes. See Realm Security for the realm-layer consequences, and the
Error Reference for branching on identity_pin_mismatch and its neighbours.