Skip to main content

Trust and verification

In a network you do not fully control, "did this result really come from that node?" has to be answerable. Gnarl answers it with signatures, and keeps the question separate from "should this node be allowed to ask me anything?", which is policy.

Identity​

Every node is an ed25519 keypair. The node ID is derived from the public key, so an identity cannot be claimed — only proven.

gnarl identity show
▸ node id 4f2c9a7e1b83d05c6ea41f9027bd3e58c1740a9f6b2d85e3c09a7f41d62be8b7
▸ algorithm ed25519
▸ home /Users/you/.lucenia

A node ID is the 32-byte hash of the public key, printed as 64 hex characters — the same form --admit-peer takes.

What gets signed​

ArtifactSigned byProtects against
ManifestPublishing nodeForged claims about what a node holds
QueryOriginating nodeUnattributable or replayed queries
Result batchAnswering nodeTampering by a relay or coordinator

Results carry the signature of the node that produced them, not the node that merged them. A coordinator that alters a hit invalidates the signature it was handed, and the client sees it.

{
"hits": [
{
"score": 12.4,
"node": "4f2c9a7e…d62be8b7",
"doc": {"title": "Puget Sound, 2024-06-11", "sensor": "sentinel-2"},
"signature": "ed25519:41ab…77c9"
}
],
"verified": true
}

Set "verify": false to skip verification when you already trust the transport and want the throughput back.

Policy​

Signatures establish who. Policy decides whether.

Today policy has three levers, all set when the node starts:

# who may join at all — an invite is pinned to one node ID
gnarl mesh invite --mesh-name my-mesh --admit-peer <node-id>

# capability tokens on the data plane (private mesh only; on by default there,
# and refused on a public mesh, which stays open by design)
gnarl rbac issue --user <name> --role <reader|writer|admin>

# how hard anyone may ask
gnarl start --http-rate-limit 600 --http-rate-limit-burst 60

Per-index and per-field rules, and redaction, are not implemented yet.

Policy is evaluated on the node that holds the data, every time, before the index is touched. There is no central policy service to bypass and no cached decision to go stale.

Trust models in practice​

Closed network. A pre-shared key gates membership; every member is operated by one organization. Signatures are still checked, mostly to catch bugs and misconfiguration.

Federated. Several organizations, each running their own nodes, each publishing a node ID out of band. Peers pin the IDs they intend to trust.

gnarl mesh invite --mesh-name my-mesh \
--admit-peer 9b71e0c4a5d38f26ab04c9715de6820f3a4cb18d72e5f093ac61d80b4e27f5a6

Admission is granted when you invite, not afterwards: an invite token is pinned to one node ID, so a mesh name alone never admits anyone.

Open. Anyone may join and answer. Results are ranked with node reputation as an input, and clients can require that hits come only from pinned identities.

There is no per-query trust mode yet. Restriction happens at the mesh boundary instead: you admit the node IDs you intend to trust when you invite them, and a peer you never admitted is never asked.

Key rotation​

Not yet implemented. A node's identity is fixed for its lifetime: the keypair is generated on first run and lives in the data directory, and gnarl reset regenerates it rather than rotating it — peers that trusted the old node ID will not follow it to the new one.

Planned behaviour is a rotation record signed by the old key, so peers can follow an identity forward without re-establishing trust out of band. Until that ships, back up your data directory.

Threat model, briefly​

Gnarl assumes peers may be slow, absent, buggy, or actively lying about their own content. It does not assume a peer can be forced to answer honestly — a node that holds a document may always choose to withhold it. What the protocol guarantees is attribution: whatever you do receive is provably from who it claims to be, unaltered, and everything else is visibly missing.

Authentication is not authorization​

Every /internal request is signed, and the node id is derived from the public key rather than trusted from a header. That establishes who is asking.

On the public fabric that alone settles nothing, because every peer has a key and can therefore authenticate. Whether they may have the data is decided separately, by the index's own placement — and by the same policy that decides who may hold a replica, so the two cannot drift apart. A peer refused a replica cannot obtain the same bytes by asking for them instead.

That covers every peer-to-peer path content can leave by: documents, term statistics (which for a memory index are the content words themselves), the write-ahead log, graph traversal, manifests, raw segment bytes addressed by content hash, whole-node fanout queries, and storage-proof challenges — a proof hands back a slice of the segment, so it is a read in a different shape and passes the same gate.

Two things it deliberately does not cover. The user-facing /v1 API is not authenticated on a public-scope node, by design: bind it to localhost, or put your own authentication in front of it, if the machine is reachable. And the claim-routing plane still gossips claim and manifest identifiers without a placement filter, so a peer can learn that an index exists, and roughly how big, without being able to read any of it. Closing that is open work.

What a mesh name proves​

Nothing, on its own. A private mesh is partitioned by name, and the node says so in its own status output: "--mesh-name is NOT a trust boundary. Same-name strangers can still join discovery."

The admission allowlist is the trust boundary. A mesh name keeps strangers from colliding with you by accident; it does not keep out anyone who knows it. Private scope reaches across the internet via bootstrap and relay, so "it is only on my network" is not a property you have unless your scope is lan or narrower.