Identity & Trust
A RuntimeCoordinate names each side of a connection, a crypto identity in durable storage proves it, and trust-on-first-use pins the key. Actions and realms both rely on this one identity model.
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. Trust-on-first-use
(TOFU) ties a name to a key, so the second connection can tell it’s really the same peer. The whole
stack rests on this one identity model: 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").withPersistentId("worker-1"); // env + a persistent idRuntimeCoordinate.env("backend").withPersistentId(id.toString()); // env + a persistent idRuntimeCoordinate.unknown; // unspecifiedThe identity you pass to createWireClient (or that an ActionRuntime is built from) is a
coordinate, and 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 concise getters coordinate.persistentId and coordinate.instanceId expose those concepts in
application code. The existing perId and insId own fields remain the serialized wire shape for
compatibility.
The crypto identity lives in your storage
Section titled “The crypto identity lives in your storage”A secure connection authenticates with an Ed25519 keypair, held by 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 travels inside the already-authenticated session, with no per-message crypto. The level of protection that session gives, identity only or identity + encrypted frames, is covered in Security Levels.
Trust-on-first-use (TOFU)
Section titled “Trust-on-first-use (TOFU)”The first time a peer sees a given identity, it pins that identity’s verify key. The identity is
keyed by the coordinate’s envId + its persistent id, falling back to the per-boot instance id
when none is set. 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 as well as 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. 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 fine as long as the id is minted fresh too:
RuntimeCoordinate.env("web").withPersistentId(crypto.randomUUID()) at startup. 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 do not recover it, and two do:
- Does not work: retrying or redialing. The same identity and key get the same rejection every time, which is why the ladder parks.
- 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 shows up as a rejection from connect() (or the first dispatch), so 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 be lost on every hibernation wake. The next key
to show up would be trusted as a first contact, which defeats the protection, so on Cloudflare persistence isn’t optional. See
Serving a Realm.
The resolver has two storage layouts. The default, "document", keeps every pin in one pins map
and rewrites the whole map each time a new identity connects. That is fine for a handful of peers.
With many peers, pass { layout: "per_identity" }:
createStorageTofuVerifyKeyResolver(storage, { layout: "per_identity" });Each identity then gets its own record, keyed by a SHA-256 hash of the identity. A new peer writes one small record and never touches anyone else’s pin. Two first contacts for the same identity are run one after the other inside the resolver, so they cannot both win.
The store must still be durable. If anyone on the internet can connect, authorize or cap which identities may reach first contact, because each one costs you a stored pin.
Switching an existing host from "document" to "per_identity" is safe: on each identity’s next
contact its legacy pin is honored and migrated to a per-identity record, and the legacy pins
document is left in place so rolling back is also safe. The reverse switch is not migrated —
per-identity records are invisible to the "document" layout.
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 included, 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.