Security¶
Think of adding a device to your hive like handing someone a key to the house. Before they get in, they have to prove they hold the right key — without ever sliding it under the door where someone could copy it. Once they're inside, they still can't touch everything: a new device arrives able to do nothing at all, and you decide, one permission at a time, what it's allowed to say. And everything spoken between the device and the server is scrambled the moment the door closes, whether or not you ever bother with TLS. Those three ideas — prove the key, grant nothing by default, encrypt everything — are the whole security model.
In a nutshell
- The
access_keyis a non-secret identifier sent in the clear; thepasswordis the only secret and is never transmitted — both sides derive the session key from it. - Protocol v3 runs a Noise handshake (
Noise_XXpsk2_25519_ChaChaPoly_SHA256) for an always-encrypted, forward-secret session; older clients fall back to a PBKDF2 password handshake. - Permissions are fail-closed: a new client can send nothing until you
allow-msgeach message type, and the admin flag never bypasses theallowed_typesACL. - TLS is optional defence-in-depth; HiveMind's own handshake already encrypts every payload.
Three layers protect a HiveMind
- Handshake + encryption — a connecting device proves it knows the password and the two sides agree on a session key without ever sending it over the wire. On protocol v3 this is a Noise handshake and the whole session is encrypted end to end.
- Permissions — hivemind-core checks every message against a per-device allowlist; an unknown message type is dropped.
- Transport (TLS) — an optional outer layer of standard WebSocket encryption. HiveMind's own handshake already encrypts every payload, so TLS is not required — it is defence-in-depth, useful mainly to hide metadata from a network observer.
Those three layers are the map. The rest of this page walks them in order — starting with the credentials a device carries, then the handshake that proves them, then the permissions that fence in what it can do once it's in.
Credentials: access key vs. password¶
When you run add-client, the device walks away with two things — and the difference
between them is the whole game. One is a name it can shout across the room; the other is a
secret it must never say out loud:
access_key— a non-secret identifier, like a username. It is sent in the clear as anauthorizationURL query parameter (base64 ofuseragent:access_key) so hivemind-core knows which client is connecting. Leaking it does not compromise the hive: it names a client, it does not authenticate one.password— the only secret, and it is never transmitted. Both sides derive the session key from it; a connecting peer that does not know the password simply fails the handshake and is disconnected. There is no password-recovery path over the wire — knowledge of the password is proven, never revealed.
Because the password is the sole secret, its strength is the security of the whole hive (see Weak-password refusal).
Handshake and encryption¶
So the password is the one secret, and it's never sent. That raises the obvious question: how do two machines agree on an encryption key from a password without one of them transmitting it? That's the handshake's whole job — a short back-and-forth at the start of every connection that leaves both sides holding the same key, while an eavesdropper who watched every byte learns nothing.
There are two ways it can go, depending on how modern the client is. The good one first.
Protocol v3 — the Noise handshake (current default)¶
Any reasonably capable client — a laptop, a phone, a browser — gets the strong path. Protocol v3 replaces the legacy handshake with the Noise Protocol Framework, giving a session that's encrypted from the first real message and stays secret even if the password later leaks (forward secrecy). Here's what a v3-capable client (Noise primitive available, password configured) actually negotiates:
- Default suite:
Noise_XXpsk2_25519_ChaChaPoly_SHA256— mutual static-key authentication over X25519, per-handshake ephemeral Diffie-Hellman (forward secrecy), ChaCha20-Poly1305 AEAD, SHA-256. This is the suite browser and JavaScript clients (HiveMind-js) negotiate too: they pair the native Web Crypto API with the pure-JS@noble/ciphers+@noble/hashesfor the two primitives Web Crypto lacks (ChaCha20-Poly1305 and argon2id), giving full cipher parity with hivemind-core — no server-side configuration required. ANoise_XXpsk2_25519_AESGCM_SHA256suite is also registered as an AES-GCM alternative for minimal Web-Crypto-only peers (see the browser caveat below). - PSK derivation: the shared secret mixed into the handshake is
PSK = argon2id(password, salt = SHA-256(node_id)). Salting with the server'snode_idmakes the PSK server-specific, so the same password on two servers yields two different PSKs. A full browser client derives this in-browser with@noble/hashesargon2id (same parameters as the server), so a password alone is enough — no provisioning and no server-side KDF change. A pre-provisioned 32-bytepskremains an option, and PBKDF2 remains an explicit fallback when a server advertises it. - Pinned-key case: once a client's static key has been pinned by a prior
XXpsk2handshake, both ends may useNoise_KKpsk0_25519_ChaChaPoly_SHA256(both static keys known in advance). - The client pins the server too. On the first completed
XXpsk2handshake the client stores the server's Noise static key. It goes in the client identity file, underpinned_noise_keys, keyed by the server's node_id. Every later handshake compares against that pin, and a changed key aborts the session with a man-in-the-middle warning. If you regenerate a server identity or restore a node from backup, delete that entry from~/.config/hivemind/_identity.jsonon each satellite. Until you do, every satellite refuses to connect. - There is no cleartext v3 session. Only the initial
HANDSHAKE/HELLOexchange, sent before the Noise session is established, travels as plaintext JSON — inherent to Noise's own handshake messages, not a permanent exemption. Oncenoise_transportis set, every later message, including any subsequentHELLO, is Noise-encrypted like everything else. Any cleartext frame outside that pre-handshake window is rejected and the connection closed with code1008.
Constrained devices use a provisioned PSK
Microcontrollers (ESP32, MicroPython) cannot run argon2id on-device. Instead you pre-compute the 32-byte PSK on the server and flash it onto the device:
This prints the hex PSK, which equals argon2id(password, SHA-256(node_id)) — identical to what a capable peer derives at connect time, so a provisioned device and a password-deriving device interoperate with no server-side distinction. The device never sees the password itself.
Browser caveat
Browsers are not constrained: with @noble loaded (a five-line ESM shim exposing chacha20poly1305 + argon2id on globalThis.HiveMindNoble) a HiveMind-js client negotiates the default ChaChaPoly suite and derives the argon2id PSK on-device, exactly like a Python client. Only a minimal browser bundle shipped without @noble degrades to the AES-GCM + PBKDF2 subset, and then needs either a provisioned psk or a PBKDF2-advertising server.
There is no legacy path¶
There is no crypto-key, and there is no plaintext or legacy v1/v2 handshake to fall back to.
A client either completes the v3 Noise handshake with an access key and a password, or it is
refused. The server closes the connection with code 1008 and the reason this node requires
protocol v3 (the Noise handshake). The ProtocolVersion enum on the wire still enumerates
ZERO/ONE/TWO/THREE for historical reasons, but only THREE (Noise) is ever accepted.
The earlier rungs of that ladder, and the min_protocol_version floor that used to gate them,
no longer exist. max_protocol_version still appears in the HANDSHAKE payload; it is exactly
what a v3 client reads to select the Noise handshake, but it no longer advertises a negotiable
range down to older versions.
The cipher used inside the Noise session is negotiated, not fixed: the client offers an
ordered list of ciphers it supports, the server filters that list against its own config
allowed_ciphers (default ["CHACHA20-POLY1305", "AES-GCM"], with ChaCha20-Poly1305 listed
first), and the server picks the client's most-preferred surviving choice. Both
ChaCha20-Poly1305 and AES-GCM are supported. Each message carries a unique nonce and an
authentication tag.
Weak-password refusal¶
Because the password is the only secret and its verifier is offline-crackable, poorman_handshake refuses guessable passwords (WeakPasswordError). It estimates guess-resistance with zxcvbn and rejects anything below min_password_bits (default 40 bits — enough to reject Password123!, correct_password, or the xkcd Tr0ub4dour&3, while real passphrases pass).
The check runs in two places:
- At
add-client(ingestion) — a weak--passwordis rejected before the credential is ever stored. Override for a known high-entropy machine-generated secret with--allow-weak-password. - At handshake time (runtime backstop) — re-checked when a client connects, in case the credential database was edited by hand. Disable this backstop with config
runtime_password_strength_check: falseor the env varHIVEMIND_DISABLE_PASSWORD_STRENGTH_CHECK=1.
Advanced: exact handshake parameters
- The handshake IV is 64-bit (8 bytes), generated with
os.urandom(generate_iv). - The common salt is
salt = IV_client XOR IV_server. - The session key is
PBKDF2-HMAC-SHA256(password, salt, 100_000 iterations), producing a 256-bit key.
These are defined in poorman_handshake/symmetric/__init__.py and symmetric/utils.py.
RSA identity (asymmetric)¶
Each node maintains an RSA 2048-bit key pair, PEM-encoded and stored at ~/.config/hivemind/HiveMindComs.pem. Encryption uses PKCS#1 OAEP; signatures use PSS with SHA-256. The public key is exchanged in the encrypted HELLO after the handshake. The RSA keys are used for:
- INTERCOM — encrypting point-to-point messages so intermediate nodes cannot read them
- Node authentication — verifying sender identity on INTERCOM messages via signature
Reset the RSA key at any time with hivemind-client reset-pgp (the command name is retained; it recreates the RSA key pair).
Advanced: how INTERCOM actually encrypts arbitrary-length payloads
Bare RSA-OAEP can only encrypt a few hundred bytes, so INTERCOM uses a hybrid RSA + AES-256-GCM scheme (hybrid_encrypt_RSA in poorman_handshake/asymmetric/utils.py): a fresh random 256-bit AES key encrypts the payload with AES-GCM, and only that 32-byte AES key is wrapped with the recipient's RSA public key (PKCS#1 OAEP). This removes the RSA size limit while keeping end-to-end confidentiality.
Separately, the RSA handshake path (an alternative to the password handshake) derives the session secret by XOR-ing both sides' 32-byte secrets together, so neither side alone determines the key.
Bootstrapping satellite-to-satellite trust¶
For INTERCOM (and the signed trust checks on PROPAGATE / CASCADE), a node only accepts messages from peers whose public key it holds. There are two ways a key gets there.
Out of band, on the client. Each node keeps a trusted_keys mapping (alias → public-key string) in its identity file. Add a peer's key with NodeIdentity.add_trusted_key(alias, pubkey) (and trusted_keys / remove_trusted_key to read or revoke). After PING discovery has mapped the network, HiveMapper.mark_trusted_nodes(trusted_keys) flips each discovered node's trusted flag based on that mapping, so later source-trust checks resolve quickly. Exchange these keys through a channel you already trust.
Trust on first use, on the server. hivemind-core reads the public key a client sends in its HELLO. It pins that key against the client's access key, in trusted_pubkeys (hivemind_core/protocol.py). INTERCOM signature checks use the pin. A later HELLO presenting a different key does not move an existing pin, and the server logs the mismatch.
A HELLO only asserts a public key. It does not prove the sender holds the matching private key. So the first party to connect with an access credential owns its pin. Treat the credential as the thing that must stay secret, and pin keys out of band when you cannot control who connects first.
Verification is fail-closed. The target node checks the origin signature on an INTERCOM before it does anything with the content. It rejects the message when the signature does not verify, when the payload carries no signature, and when no pinned key exists for the originator. It no longer processes an unverifiable INTERCOM for the sake of confidentiality alone. A rejected message stops at the node that rejected it. That node does not fan it out to peers and does not escalate it upstream.
Plaintext INTERCOM is always dropped. An INTERCOM payload that is not a signed, encrypted envelope carries no origin proof at all. Every session on the node is encrypted, so an unsigned or unencrypted INTERCOM message is unconditionally dropped, not relayed and not escalated. There is no opt-out.
Identity file¶
A satellite shouldn't have to be handed its password every time it starts up, so it keeps
everything it needs to reconnect in one small file on disk: ~/.config/hivemind/_identity.json.
This is the file hivemind-client set-identity writes, and it's why a device can reboot
and rejoin the hive on its own. Here's what lives in it:
| Field | Description |
|---|---|
access_key |
Non-secret client identifier assigned by hivemind-core (sent in the clear, like a username) |
password |
The only secret; used to derive the session key (v3 Noise PSK, or legacy PBKDF2). Never transmitted |
default_master |
Server host address |
default_port |
Server port (default 5678 for WebSocket) |
site_id |
Physical location identifier injected into OVOS context |
public_key |
RSA public key string |
secret_key |
Path to the RSA private key (PEM) file |
noise_key |
Path to the protocol v3 Noise handshake's X25519 static private key |
pinned_noise_keys |
TOFU-pinned Noise static keys for known peers, keyed by node_id |
trusted_keys |
Alias → public-key mapping for INTERCOM origin verification |
Write the identity file:
hivemind-client set-identity \
--key <access_key> \
--password <password> \
--host <hub_host> \
--port 5678 \
--siteid living-room
Permissions¶
The handshake proved who a device is. Now comes the second question: what is it allowed to do? This is the layer people underestimate, and it's the one that makes leaked credentials far less scary than they sound.
HiveMind has no role hierarchy beyond a single is_admin flag. That flag is real: an
admin client is exempt from the reserved-"default"-session guard, and BROADCAST
requires it. Everything else — the allowed_types whitelist, skill/intent blacklists —
is per client regardless of admin status; admin does not exempt a client from those. And
the default posture is refusal — a brand-new device can send nothing until you say
otherwise.
How the policy chain works¶
Every message a satellite sends runs a little gauntlet before it reaches the agent. Three gates, always in this order:
-
allowed_typescheck (MessageTypeACLPolicy) — the client's per-client whitelist is checked first. If the OVOS message type is not in the allowed list, the message is dropped immediately. The same gate covers binary payloads: a client with an empty whitelist can send neither messages nor audio. This is fail-closed, so a new client can send nothing until you grant it a type. -
Reserved-session guard (
DefaultSessionPolicy) — denies withSESSION_ID_DEFAULT_FORBIDDENif a non-admin client declares the reserved"default"session. -
Policy plugins — configured policies run in order. The default policy is
OVOSAgentPolicy, which readsClient.metadatato build per-client session blacklists (skills and intents).
MessageTypeACLPolicy and DefaultSessionPolicy are both always force-prepended to the
chain and cannot be removed by configuration — see CLI Reference,
whose policy list output shows both first, always.
Admin flag (make-admin) does not bypass the allowed_types check. It signals to policy plugins that extra-privileged operations are permitted, but the hard ACL is always enforced first.
Writing a custom policy¶
Admission control beyond the allowed_types ACL is an extension point. A custom policy is a plugin that subclasses PolicyPlugin (from hivemind_plugin_manager.policy) and is registered under the hivemind.policy entry-point group. hivemind-core loads it into an ordered policy chain and calls it for every inbound message.
The contract. Override any of three hooks:
review(message, client)— inspect a MycroftMessagebefore it is emitted onto the agent bus. Return aVerdict.review_binary(payload, client)— same, for binary payloads (e.g. raw audio). The built-in ACL policy implements this too: a client whoseallowed_typeswhitelist is empty is denied binary payloads, exactly as it is denied bus messages. Your policy adds to that gate rather than replacing it.observe(message, client)— fire-and-forget hook called after a message was successfully emitted. Use for counters, audit logs, telemetry. Must not raise.
A Verdict is either an allow or a deny:
Verdict.allow(*mutations)— let the message proceed. Optionally carries one or moreMutationobjects describing changes to apply before the next policy runs. (Concrete mutation types are agent-specific and ship with the agent plugin, e.g. the OVOS bridge's skill/intent blacklist mutations — they are not part of the base primitives.)Verdict.deny(code, reason="", **data)— drop the message and short-circuit the chain.codeis a stable machine-readable string; theDenyCodesenum provides the built-in codes (POLICY_ERROR,POLICY_CHAIN_UNAVAILABLE,ACL_DISALLOWED_TYPE,SESSION_ID_DEFAULT_FORBIDDEN), but custom policies may emit their own string codes.
The chain is fail-closed: an exception raised inside review / review_binary (or a mutation's apply) is converted to Verdict.deny("policy_error", ...). There is no operator knob to make it lenient. The same rule covers the client database. If the ACL cannot read a client's allowed_types row, it denies with POLICY_ERROR rather than trust the permissions snapshot taken at connect time. A database outage therefore denies every client. That is deliberate: a revocation issued while the database was down would otherwise never take effect. Client.is_admin is informational only — the chain runner never skips a policy based on it; a policy that wants to treat admins specially checks client.is_admin itself.
When a policy denies a message, the originating client is notified on the bus with a hive.policy.denied message carrying the denied_type, code, reason, and data from the verdict.
The chain. Operators configure the chain as an ordered list under policy.chain in ~/.config/hivemind-core/server.json:
{
"policy": {
"chain": [
{"module": "hivemind-intent-quota-policy", "config": {"limit": 100}},
{"module": "my-custom-policy", "config": {}, "optional": true}
]
}
}
Each entry names a module (the entry-point name), an optional config dict passed to the plugin, and an optional optional flag. An optional: true policy that raises is logged and skipped (the chain continues); a mandatory policy that raises fails the chain closed. The built-in MessageTypeACLPolicy (the allowed_types ACL) is always force-prepended to the chain and is mandatory — it cannot be removed, made optional, or reordered, even if you list it explicitly. If the chain fails to build at startup, hivemind-core installs a DenyAllPolicy fallback that rejects everything until the config is fixed.
Minimal skeleton. A policy plugin and its entry point:
# my_policy/__init__.py
from hivemind_plugin_manager.policy import PolicyPlugin, Verdict
class MyPolicy(PolicyPlugin):
def review(self, message, client):
if message.msg_type == "some.forbidden.type":
return Verdict.deny("forbidden_type",
"this type is never allowed here")
return Verdict.allow()
See Writing Plugins — Policy plugins for the full plugin-authoring walkthrough.
Managing permissions via CLI¶
In practice you'll do all of this from the command line, one grant at a time. The pattern
is always the same — a verb, what you're granting, and which client — so once you've seen
a few they all read the same way. (The node ID is a positional argument, not a
--node-id option; leave it off and the command lets you pick a client from a list.)
# Allow a message type
hivemind-core allow-msg "speak" 2
# Remove an allowed message type
hivemind-core blacklist-msg "speak" 2
# Allow/deny ESCALATE from a client
hivemind-core allow-escalate 2
hivemind-core blacklist-escalate 2
# Allow/deny PROPAGATE from a client
hivemind-core allow-propagate 2
hivemind-core blacklist-propagate 2
# Blacklist a skill (OVOS-policy)
hivemind-core blacklist-skill "skill-homeassistant.openvoiceos" 2
# Un-blacklist a skill
hivemind-core allow-skill "skill-homeassistant.openvoiceos" 2
# Blacklist an intent (OVOS-policy)
hivemind-core blacklist-intent "HomeAssistant.DeviceControllerIntent" 2
# Un-blacklist an intent
hivemind-core allow-intent "HomeAssistant.DeviceControllerIntent" 2
# Grant admin flag
hivemind-core make-admin 2
# Revoke admin flag
hivemind-core revoke-admin 2
# Set arbitrary metadata (read by policy plugins)
hivemind-core set-metadata 2 --key role --value guest
Per-client defaults¶
When a client is added via add-client, its allowed_types whitelist is empty — it is denied on every message until you explicitly allow-msg each message type it needs. This applies to admin clients too: the admin flag does not exempt a client from the allowed_types ACL (see How the policy chain works above). This deny-all-by-default posture ensures that compromised credentials give an attacker no capability until access is granted.
Transport security (TLS)¶
Here's the thing that surprises people coming from the web world: you do not need
https:// for a HiveMind to be secure. TLS is optional. HiveMind's own handshake already
encrypts and authenticates every payload (Noise on v3, negotiated AEAD on v1/v2), so a
hive is fully private over plain WebSocket. Add TLS only as defence-in-depth — chiefly to hide connection metadata from a passive network observer, or to satisfy an external requirement. hivemind-core listen takes no flags — TLS is configured in ~/.config/hivemind-core/server.json under network_protocol.hivemind-websocket-plugin:
{
"network_protocol": {
"hivemind-websocket-plugin": {
"host": "0.0.0.0",
"port": 5678,
"ssl": true,
"cert_dir": "/path/to/certs",
"cert_name": "mycert"
}
}
}
For local networks: a self-signed certificate is sufficient. Pass --self-signed on satellite commands to accept it.
For internet-facing deployments: use a reverse proxy (nginx, Caddy, Traefik) with valid certificates from Let's Encrypt. Keep HiveMind on an internal port and expose only the proxy externally. Do not expose port 5678 directly to the internet.
Security checklist¶
Enough theory — here's what actually matters when you set one up. The list splits by where your hive lives, because a homelab on your own LAN and a server reachable from the open internet call for very different care.
Local/private networks:
- Use a strong, randomly generated password (12+ chars, mixed case and symbols)
- Store the password in an environment variable, not in plain config files
- Firewall port 5678 to trusted subnets only
- Monitor logs for repeated failed handshakes
Internet-facing deployments:
- Deploy a reverse proxy (nginx proxy manager, Caddy, Traefik)
- Obtain valid TLS certificates (Let's Encrypt)
- Enable automatic certificate renewal
- Keep port 5678 unexposed to the public internet (behind the proxy only)
- Apply rate limiting and firewall rules at the proxy
Limitations¶
- Security is proportional to password entropy. Weak passwords are the primary attack surface — which is why
poorman_handshakerefuses guessable ones (see Weak-password refusal). - Without TLS, a network observer can see the encrypted ciphertext and connection metadata but not payload content. TLS adds metadata-hiding defence-in-depth; it is not required for confidentiality.
- INTERCOM authentication needs the receiver to hold the sender's public key, either exchanged through a trusted channel or pinned from the sender's first
HELLO. A message it cannot verify is dropped, not delivered. - A pin taken from a
HELLOis only as good as the access credential that carried it. The first party to connect with that credential owns the pin.
Next: Mesh Topology for how permissions compose across nested hives, or Writing Plugins to ship a custom admission policy.
Source¶
Validated against the HiveMind source:
hivemind_core/policy.py—MessageTypeACLPolicyand the fail-closed policy chain; admins are bound byallowed_typeshivemind_core/protocol.py—ProtocolVersionenum,max_protocol_versionin theHANDSHAKEpayload, theclose(1008, ...)refusal for a non-v3 client, and the unconditional plaintext-INTERCOM droppoorman_handshake/noise/__init__.py— v3 Noise suites (XXpsk2/KKpsk0, ChaChaPoly/AES-GCM),derive_psk = argon2id(password, SHA-256(node_id))poorman_handshake/symmetric/strength.py—WeakPasswordError, zxcvbn-based 40-bit floorhivemind_core/config.py—min_password_bits,runtime_password_strength_checkpoorman_handshake/asymmetric/utils.py— hybrid RSA + AES-256-GCM INTERCOM encryptionhivemind_bus_client/identity.py— identity file fields andtrusted_keys