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.

For high-cardinality peers, call createStorageTofuVerifyKeyResolver(storage, { layout: "per_identity" }). The legacy/default "document" layout stores one pins map and rewrites it on each first contact; "per_identity" stores one domain-separated SHA-256-addressed record per identity, so unrelated pins do not contend or amplify writes. First contact for the same identity is serialized inside the resolver. The store must still be durable, and an application should authorize/cap which identities may reach TOFU when the peer population is attacker-controlled.

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.

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.