hraness

identity without a central authority

keys are the account

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

A centralized system gets identity for free. The operator runs a database, the database assigns an account, and “who is this” reduces to “which row.” Remove the operator and the question becomes the system’s hardest problem: if no service vouches for anyone, what is an identity?

Valhalla’s answer is the one peer-to-peer systems keep rediscovering: the account is a keypair. There is no registry to call, no username to claim, no row to own. An Ed25519 signing key is the owner: control of the private key is the entire claim. Everything the system knows about you is signed by that key, and everything the system grants you is checked against it.

That sentence hides most of the engineering. The interesting part is not that keys are identity; it is what the workspace had to build so that a key could be identity without becoming authority.

the account is a key

At the bottom, vhalla-crypto defines the primitive. peer_id_from_key derives a routing handle from a VerifyingKey: SHA-256 over the key bytes, take the first 16 bytes, wrap in PeerId. That handle is what an Envelope carries as its author, a 128-bit value that fits the fixed-width header.

But the workspace is careful about what a handle is. PeerId is a routing convenience, not an identity proof. The VerificationContext (the struct a ReplayWindow checks every signed envelope against) binds the full 32-byte key, not the handle. And VerifyError::AuthorMismatch exists precisely because a claimed PeerId must equal the peer_id_from_key of the key that actually signed. Two different keys can collide on 16 bytes; the protocol does not pretend otherwise, so nothing downstream of authentication ever sees only the handle.

the handle is not the key

This distinction (handle versus key) is where most peer-to-peer identity systems get sloppy. The handle is what you route by, index by, display. The key is what you verify against. Valhalla keeps them separate all the way down:

  • vhalla-identity::Identity::public_key() returns [u8; 32], the full key. Its doc comment is explicit: “Routing handles must not replace it in pairing or membership policy.”
  • Pairing in vhalla-session pins both peers’ full application keys and their full transport keys (four [u8; 32] values), not handles.
  • LocalPolicy::read_memory in vhalla-policy grants to requester: [u8; 32], the complete signing key, not a PeerId.
  • InvitationClaims names the owner and invitee by full key.

The reason is simple: a handle can be forged, collided, or guessed. A 32-byte Ed25519 verifying key cannot, and checking it is cheap. Anywhere the system grants anything (admission, membership, an effect), it does so against the key, and the handle is treated as what it is: a hint about which key to ask for.

custody is the hard part

If the key is the account, then losing the key is losing the account, and leaking it is worse. vhalla-identity is the Unix custody adapter, and its job is to make the private half of the account as hard to misuse as a file can be.

The record is 72 bytes: an 8-byte VHID0001 magic, the 32-byte seed, a 32-byte SHA-256 digest over the first 40. The directory is 0o700, the files 0o600. Identity::create_new builds the seed with getrandom, writes identity.tmp, fsyncs it, hard-links it to identity (so the file either exists completely or not at all), fsyncs the directory, fsyncs the parent directory, and only then returns. Identity::open refuses a directory with the wrong mode, a record with the wrong length, any unexpected file, a symlink, or a second hardlink. Both hold an exclusive try_lock for the life of the handle.

The discipline is in the error type. IdentityError::Io wraps the OS error, but the doc comment is blunt about the rule: “A failed create may have left a directory or record; reconcile by explicit open, never regenerate automatically.” If creation’s result is uncertain (did the rename land? did the fsync?), the answer is not “delete and retry,” because the bytes on disk may be the only copy of the key. Uncertain creation is reconciled by looking, not by overwriting.

And the Identity itself is sealed: no seed getter, no Clone, no Debug, no serializer. You can ask it to sign an envelope, issue an invitation, or run a session handshake. You cannot extract the key. The account can act; the account cannot be copied.

The menu of operations is itself the design. Identity exposes exactly five verbs: issue_invitation, initiate_session, respond_session, confirm_session, sign_social/countersign_social. There is deliberately no generic sign(&self, bytes) oracle: a signing oracle is how “the key never leaves the process” still ends up certifying things the designer never imagined. Every verb signs a fixed-width, domain-separated structure the workspace defined, so the set of statements a key can make is enumerable from the API surface.

delegation without a server

A key-as-account system still needs to answer “how do I let someone in” without a server to vouch for them. vhalla-session’s Invitation is the mechanism: an owner-signed, fixed-width token (VHIN magic, version 1, INVITATION_BYTES canonical length) that carries InvitationClaims: the owner’s full key, the invitee’s full key, realm, room, epoch, an expiry, and a 32-byte nonce, all under the owner’s signature.

Two properties matter. The invitation is addressed: it names the invitee’s key, so it cannot be forwarded to a third party. And it is not admission: the module doc says “decoding or verifying an invitation never admits a peer by itself.” The invitee still has to run the handshake, bind observed transport keys, and construct a Pairing. The invitation is evidence the owner signed; the session is the proof both parties are live.

The nonce exists for single-use: SpentInvitationNonces and the VSN1 SpentFile in vhalla-native keep a bounded, atomically republished set of consumed nonces (1,024 entries, 32,772 bytes on disk) so a presented invitation cannot be replayed. The account delegated an introduction; it did not delegate the account.

The social layer adds the second level of the same idea: identity that survives key rotation. vhalla-social separates OwnerId (an immutable identifier derived from the owner’s genesis record) from the controller keys that currently act for it. A planned rotation is signed by both the old and new controllers on the same record, so the account continues while the keys change. Two valid but conflicting controller histories freeze the owner’s live authority rather than electing a winner, and compromised-controller recovery is, by explicit design, unavailable in v1 (Error::RecoveryDisabled). The fork is preserved as evidence; it is not resolved by fiat.

what identity does not mean

The workspace’s most consistent rule is that authentication stops at authentication. A valid signature proves control of a key over a specific envelope in a specific context: audience, realm, room, epoch, session, expiry. It does not grant membership, tools, or host authority. The design plan states it as a contract: “A valid peer signature proves control of an identity key for a specific envelope. It does not grant host tools or change local trust policy.”

The workspace enforces the rule with types, not conventions. vhalla-steel-thread carries three compile_fail doctests proving that a VerifiedRecord (a signed social record), a discovery Hit, and a Notification each cannot be converted into a RemoteRequest: RemoteRequest::from_verified exists only for replay-checked VerifiedEnvelopes. An authenticated statement, a search result, and a notification are all evidence; only one type of evidence can even be offered to the policy layer.

That is what makes the model survivable. If keys were authority, a leaked key would be a leaked everything. Because keys are only evidence, the layers above (policy, session, pairing) decide what the evidence is worth. The account is a key. What the key may do is still a local decision.

sources

  • vhalla
  • crates/vhalla-crypto/src/lib.rs: peer_id_from_key, VerificationContext, VerifyError::AuthorMismatch
  • crates/vhalla-identity/src/unix.rs: the custody record, sealed Identity, and the reconcile-don’t-regenerate rule
  • crates/vhalla-session/src/invitation.rs: VHIN invitations and InvitationClaims
  • crates/vhalla-native/src/spent.rs: the VSN1 spent-nonce file
  • crates/vhalla-policy/src/lib.rs: grants bound to full keys