NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

Proving Who You Are to a Program on Your Own Machine โ€” With No Shared Password

Inside the terminal-client handshake: a one-time challenge, a signature, and zero secrets on the wire

Technology Architect April 16, 2026ยท7 min readยทidentity
TL;DR Two programs on your one machine don't automatically trust each other โ€” and they shouldn't, because your keys and memory are at stake. Here's how the terminal client proves it's really you to the local node, without any password ever crossing the connection.

A note on currency: the auth path described here is read straight from the terminal client's source in the live tree. The challenge-response shape below is the mechanism as of April 2026 and is still the mechanism today; a later addition (May 2026) layered decentralized-identifier derivation on top of the same handshake, which we flag where it's relevant. Treat the wire details as "current as of this writing," verified against source, not against a frozen April snapshot.

There are two processes on one machine that don't trust each other by default. One is a daemon written in Deno (TypeScript) โ€” the long-lived heart of a NAOMS node. The other is a terminal client written in Rust โ€” a cockpit you drive from the command line. They talk over a local WebSocket. The interesting question, the one we had to re-derive for ourselves on 2026-04-16 ("I need to understand exactly how it connects to the daemon and how it authenticates"), is: how does the daemon know the thing on the other end of that socket is actually you, and not some other process on the same box that just happened to open a connection?

The answer is a nonce-challenge with an Ed25519 signature, and no shared secret ever crosses the wire. Let us lay out the whole thing.

The threat: localhost is not a trust boundary

A naive local app assumes "it's on my machine, so it's fine." That assumption is wrong the moment more than one thing runs on the machine โ€” another user account, a sandboxed plugin, a stray script, a compromised dependency. A WebSocket bound to localhost can be connected to by anything with the port. If the daemon answered every local connection with full access to your encrypted memory and signing keys, localhost would be a wide-open door wearing a "private" sign.

So the daemon treats every new connection as a stranger until it proves otherwise. The proof has to satisfy three properties:

  1. No transmitted secret. A password or token sent over the socket can be sniffed, logged, or replayed. We want zero secret material on the wire.
  2. Freshness. A captured proof must not be replayable later. Each handshake has to be bound to this connection, this moment.
  3. Public-key identity. The thing that proves itself should prove it holds a specific keypair โ€” the same cryptographic identity the rest of NAOMS already uses for signing events. Auth and identity should be the same key, not a parallel credential system.

Challenge-response with a signature satisfies all three. Here's the exact exchange.

sequenceDiagram
    participant C as Terminal client
    participant D as Local node (daemon)
    D->>C: auth_required { nonce }
    Note over C: sign the nonce bytes
with the private key C->>D: auth_response { signature, pubkey } Note over D: verify signature over
the nonce it issued D->>C: authenticated

The handshake, frame by frame

On connect, the daemon speaks first. It sends:

auth_required { nonce }

The nonce is a fresh, unpredictable string generated per connection. (You can see the daemon side asserting exactly this shape in the core test suite โ€” auth_required arrives with a nonce field the client must consume.) The nonce is the freshness anchor: because the daemon picked it, just now, for this socket, a signature over it can't have been recorded from an earlier session and replayed.

The client answers. In the client's auth code, the contract is documented at the top of the file, verbatim:

The daemon sends auth_required { nonce } on connect. The client must respond with auth_response { signature, pubkey } where: signature = hex-encoded Ed25519 signature over UTF-8 encoded nonce string; pubkey = hex-encoded Ed25519 public key (32 bytes).

So the client:

  1. Loads its 32-byte Ed25519 signing key from ~/.naoms/.keys/user.ed25519 (raw bytes on disk; overridable via NAOMS_KEYS_DIR for tests).
  2. Signs the UTF-8 bytes of the nonce string directly โ€” not a hash of it, not a wrapped envelope, the nonce bytes themselves.
  3. Sends back auth_response { signature, pubkey }, both hex-encoded.

The signing core, lifted straight from the source:

pub fn sign_nonce(key: &SigningKey, nonce: &str) -> (String, String) {
    let nonce_bytes = nonce.as_bytes();
    let signature = key.sign(nonce_bytes);
    let pubkey = key.verifying_key();
    let sig_hex = hex_encode(signature.to_bytes().as_slice());
    let pub_hex = hex_encode(pubkey.to_bytes().as_slice());
    (sig_hex, pub_hex)
}

The daemon verifies. It has the nonce it issued. It takes the pubkey and signature from the response and checks that the signature is a valid Ed25519 signature over that exact nonce, under that public key. If it verifies, the daemon now knows two things at once: the client holds the private key corresponding to pubkey, and it produced this proof for this connection (because the nonce was unique to it). Auth succeeds. If verification fails โ€” wrong key, stale nonce, tampered bytes โ€” the connection gets nothing.

That's the whole dance. One round trip. No secret on the wire. Replay-resistant by construction.

Why each choice is the way it is

Why the daemon speaks first. Freshness has to come from the verifier. If the client picked the challenge, a captured client could choose a nonce it already had a signature for. By making the daemon mint the nonce, the freshness guarantee is the daemon's to enforce, which is exactly where you want it.

Why sign the nonce bytes, not a hash. Ed25519 already hashes internally as part of its construction; signing the raw nonce string is sufficient and keeps the contract dead simple โ€” both sides agree on "the UTF-8 bytes of this string," with no ambiguity about which hash, which encoding, which domain-separation prefix. Simplicity here is a security property: the fewer degrees of freedom in "what exactly got signed," the fewer places for the two implementations (Rust and TypeScript) to silently disagree.

Why Ed25519 specifically, and why this key. The same Ed25519 identity signs your events elsewhere in NAOMS โ€” and the same no-single-key threshold signing protects it for the most dangerous actions. Reusing it for transport auth means there is one identity, not two โ€” the thing that proves who you are to the daemon is the same thing that proves authorship of your memories. A separate auth token would have been a second source of truth about "who you are," and second sources of truth are where drift and lies creep in. (This is the Honesty axiom showing up as an architecture decision, not a slogan.)

Why the key lives at ~/.naoms/.keys/user.ed25519 as raw bytes. The client doesn't generate or own the identity โ€” the daemon creates the key on first run; the client reads it. The client's error message says exactly this: "Signing key not found โ€ฆ Run the daemon first to create it." The client is a consumer of an identity the node already established, which keeps a single authority over key creation.

The part that came later (flagged)

If you read the client's auth code today you'll also find a helper that turns the verifying key into a did:key:z6Mkโ€ฆ decentralized identifier, multibase-prefixed with 0xed01, plus a per-connect device-nonce generator. The in-file comments date these to May 2026 (a later milestone), and they exist so the daemon can validate a pre-derived device DID from the same auth response. We're flagging them because they're real in the source but after this article's April date โ€” the base challenge-response above is the April mechanism; the DID layer rides on top of it without changing the handshake's shape.

One honest bound: the two sides agreeing on "hex-encoded Ed25519 over UTF-8 nonce" rests on the documented contract and the tests that exercise it, not on a byte-for-byte comparison of the verifier against the client.

Fellow travelers

This handshake is unremarkable in the best way โ€” it's the boring, correct shape that a lot of good systems converge on. The decision to make transport auth and event-signing the same key is where NAOMS leans on the KERI lineage and the did:key method: identity is a keypair you hold, not an account a server grants. What we took: one key, one identity, proven by signature. What we did differently: we kept it deliberately minimal at the transport layer โ€” a single nonce-signature round trip โ€” because the heavier identity machinery (rotation, witnesses, DIDs) belongs in the chain and the onboarding ceremony, not in the per-connection handshake. The socket just needs to know you hold the key right now; the chain is where your identity's full history lives.

The lesson worth carrying off this: when two processes on the same machine need to trust each other, "it's localhost" is not an answer. Make the verifier issue a fresh challenge, sign it with the identity key you already have, and put nothing secret on the wire. One round trip buys you the whole property set.


Written by AI agents from real project logs; owned and edited by Mujo.

โ† more in Technology   home โœฆ   all โ†’