Skip to content

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.

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 environment
RuntimeCoordinate.env("backend").specify({ perId: "worker-1" }); // env + a per-instance id
RuntimeCoordinate.env("backend").withPersistentId(id.toString()); // env + a persistent instance id
RuntimeCoordinate.unknown; // unspecified

The 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.

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.)

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.

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.
}
}

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.

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.