Skip to content

ensemble: sign handshake claims alongside identity - #70

Open
adiled wants to merge 7 commits into
mainfrom
socket/admission-integrity
Open

adiled wants to merge 7 commits into
mainfrom
socket/admission-integrity

Conversation

@adiled

@adiled adiled commented Oct 16, 2026

Copy link
Copy Markdown
Owner

First slice of #66 — the cheapest of the four, and the one that was already a live gap.

The hole

The handshake signature covers identity only:

format!("{}:{}:{}", HANDSHAKE_DOMAIN, humd_id, signed_at_ms)

nests, hosts, can_relay and free_slots are all parsed after verifying_key.verify(...) returns. Nothing checks them against the signature. So any peer that completes a valid signed handshake can claim any capacity, any relay rights, and any hostname — and those feed real decisions.

Same class of defect as the model claim in #67, one layer down. #67 stopped prompts routing on an unverified claim; this stops the peer handshake carrying unverified claims.

What changes

A second signature over the claims, under a separate domain (hum-ensemble-caps-v1).

The identity signature is untouched. Changing handshake_message would break every existing peer's verification — that's a wire break, not a patch. An old peer verifying signature sees exactly the message it always did.

Backward compatible. A hello with no capsSignature is admitted and marked caps_signed: false. An old peer sends none and keeps working.

A bad signature is a hard reject, not a degrade. A peer that signs the wrong claims is either broken or lying. Either way the claims can't be trusted, so the hello fails rather than falling back to "unsigned but admitted".

What is and isn't signed

Signed: nests, hosts, can_relay. These are authority claims.

Not signed: free_slots. It's capacity telemetry that changes after the handshake — binding it would make a valid signature stale the moment a slot is used. Worth a decision if anyone wants it covered; I left it out deliberately rather than quietly.

Proof

Six tests in ensemble/src/handshake.rs. Rule 0 — verification removed → 4 of 6 fail:

test result: FAILED. 2 passed; 4 failed

Restored: 6/6. They cover the cases that matter:

  • tampering can_relay after signing
  • widening hosts after signing
  • a forged signature from another key
  • a signature replayed from another peer
  • unsigned hello still admits as unsigned

Full workspace: 55 binaries, 0 failures. ensemble --lib 108 → 114.

Pre-existing, not touched

clippy -D warnings is red on hum-nest, thehum and hum-identity — 8 + 5 errors on clean main, unchanged by this branch. CI only lints -p ensemble -p hum-paths, so none of it blocks. Flagging because it's a release-readiness item, not because it belongs here.

Refs #66

The handshake signature covered identity only. nests, hosts, can_relay
and free_slots are parsed after verification and were never covered by
it, so any peer completing a signed handshake could over-claim relay
rights or capacity, and both feed real decisions.

Adds a second signature over the claims under a separate domain. The
identity signature is untouched, so a peer verifying the old message
still verifies it and existing peers keep working. A hello with no
capsSignature is admitted and marked unsigned — backward compatible. A
hello with one that fails verification is rejected outright rather than
degraded, since a peer that signs the wrong thing is broken or lying and
either way the claims cannot be trusted.

free_slots is not signed: it is capacity telemetry that changes after
the handshake, so binding it would make a valid signature stale
immediately. can_relay, nests and hosts are the authority claims and
are the ones now bound.
A socket hello declares `hid` as a bare string and humd parses it. There
is no proof it belongs to the bee claiming it. A bee could send another
bee's hid and inherit its reconnect-dedup slot, and the registry keyed by
identity has no way to tell a real hid from a typed one. Same shape as
the peer handshake gap fixed earlier, one transport down.

Bees already hold an Ed25519 key — BeeKey, whose pubkey hashes to the hid
they already send. It was just never used to sign anything on this path.

BeeKeys can now sign a socket hello. humd verifies when the fields are
present, under its own domain so a peer-handshake signature cannot be
replayed onto a socket hello. Checks: hid parses, carries a bee prefix,
is the sha256 of the signing pubkey, timestamp is inside the skew window,
signature verifies.

Unsigned stays admitted. Most hives send no signature today and this
would break every one of them if it required the fields; marked
hid_verified: false instead so callers routing on identity can tell
proven from self-declared. A signature that is present and wrong is a
hard reject — a bee that signs the wrong thing is broken or lying.

Wire naming stays socket-flavoured (`hid`, `pubkey`, `signedAt`,
`signature`) rather than borrowing the peer handshake's. Different
transport, and renaming would churn every hive for nothing.

Rule 0: removing the signature check fails 2 tests; removing the
hid/pubkey match first failed none, which is how the test for it got
written — a valid signature over a hid the signing key does not own was
previously uncaught. Both now fail when their gate is removed.

No hive sends a signature yet. The path is inert until one does.
Five tests driving humd's hello arm with a real signature: a valid one
is admitted, a forged one is refused, one signed over a hid the key does
not own is refused, a stale one is refused, and an unsigned one is still
admitted.

The unsigned case is the one that protects every existing hive, and the
claiming-another's-hid case is the one the pubkey/hid binding exists for
— neither was reachable from the unit tests, since they assert on the
decision humd actually makes rather than on the parse.

Rule 0: neutering humd's reject branch fails exactly the three refusal
tests and leaves both admission tests passing.
thrum_broadcast sent to anyone owning a sigil, and to everyone owning
none when nobody matched. Neither looked at what the client said it
speaks. A forager that only advertises humfs_* still received every
prompt, chunk and tool-call the session produced — declared scope was
recorded in the manifest and then ignored for delivery.

Records the declared chis on the Reach at hello time and filters both
the sigil pass and the no-owner fallback on them.

A client that declares nothing is unfiltered, not starved. Every bee
predating this field sends an empty set, and an empty list means
"unspecified" rather than "wants nothing" — refusing it traffic would
be a wire break dressed as a security fix.

Re-declaring replaces the previous set rather than merging, so a
re-hello that narrows scope actually narrows it. Scoped thrum_all too:
it has no callers today, but leaving it unfiltered would make it a
bypass for anyone who reaches for it next.

Rule 0: neutering declares_chi fails the scoped-rejection test and
leaves both delivery tests passing. Workspace 381 -> 393, no failures.
A bee's hid is the hash of its public key. That is pseudonymity without
accountability — no authority behind a name, and no way to withdraw one
that behaves badly. Nothing was revocable.

The operator lists hids in $XDG_STATE_HOME/hum/revoked.json and humd
refuses them at hello: closed connection, error echo, not registered and
not announced. Not merely hidden from discovery, which leaves the socket
open to the bee it was meant to exclude.

Checked whether or not the bee proved the hid. The evasion this closes
is the obvious one: stop signing, claim the same string, and watch a
proof-only check pass by. The revoked string is refused however it is
presented.

A corrupt file revokes nothing rather than everything. Failing closed
would lock every bee out of the socket — a daemon that admits nobody is
a worse failure than one that admits a revoked bee, and the operator can
act on the parse warning.

The list is read once at boot, not per hello. Re-reading puts disk on
the admission path, which is the one path that must not stall; a
revocation takes effect on next start. Path is on DaemonConfig so tests
can point at a tempdir instead of the developer's real state.

What this does not do: bind across the mesh. A bee refused here can
still reach another daemon. That gap needs the on-chain registry to
carry revocations, which is a contract change. Worth noting the issue
describes HumdRegistry as "currently used for optional manifest
anchoring" — it has no callers anywhere in the tree, behind an `onchain`
feature flag. So there was nothing to hook into.

Rule 0: neutering the check fails the two refusal tests and leaves the
control passing. Workspace 393 -> 400, no failures.
A revocation list nobody can reach is not a feature. The list was
written by the test harness and by nothing an operator has.

humctl revoke <hid>..., humctl unrevoke <hid>..., humctl revocations.
Idempotent: revoking a listed hid says so rather than duplicating the
entry, and unrevoking an absent one is not an error.

The on-disk type moves to hum-paths, next to RuntimeInfo which is
already the shared serde state file both sides read. humd keeps a thin
wrapper for the admission check; humctl gets the edits without linking
the daemon's dependency tree.

Both verbs say restart humd afterwards, because the daemon reads the
list once at boot. That is a deliberate trade — re-reading per hello puts
disk on the admission path — but it is a footgun if left implicit.

Workspace 400 -> 404, no failures.
Removed 77 inline comment lines. The behaviour is unchanged; 404 tests
still pass and no gate moved.

Kept the doc comments. Those describe the contract rather than the code —
what `SocketIdentity::Unsigned` permits, why the two signature domains
must not cross, what a corrupt revocation file costs. `hives.rs` runs
105 doc-comment lines against 161 lines of code, so this is the house
style and removing it would leave the new security surface undocumented
at exactly the points a reader cannot infer.

Removed the rest: "the peer signed can_relay: false, then ships
can_relay: true" restates the next two lines. "a re-hello that narrows
scope must actually narrow" restates the test name above it. Where a
rationale was real but is now expressed by placement — revocation is
checked outside the identity match, so "checked whether or not the bee
proved the hid" is visible in the structure — the structure carries it.

Also collapsed a nested if in `Revocations::write_to` to match the
existing `RuntimeInfo::write` three lines above it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant