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 byhost:port. 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. - Only
HELLOandHANDSHAKEframes travel unencrypted (they precede session-key establishment). On acrypto_requiredserver, any other cleartext frame is rejected and the client disconnected.
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.
Legacy handshake (protocol v1 / v2)¶
Not everything can run Noise, and HiveMind doesn't abandon those clients — it drops to an older but still sound handshake. It reaches the same destination (a shared key neither side transmitted); it just gets there by different math. The steps below are worth reading once even if you never implement them, because they show why watching the wire doesn't help an attacker. The password handshake proves the password with a salted hash and derives the key with PBKDF2-SHA256:
Server → Client HELLO (node_id, server public key — plaintext)
Server → Client HANDSHAKE (capabilities: binarize, crypto_required, etc. — plaintext)
Client → Server HANDSHAKE (binarize flag + PBKDF2 envelope — plaintext)
Server → Client HANDSHAKE (PBKDF2 envelope — plaintext)
Client → Server HELLO (session data, site_id, client public key — ENCRYPTED)
The handshake works as follows:
- Each side generates a random IV and computes a salted-hash subject
HSUB = IV ‖ SHA256(IV + password)(the IV concatenated with the hash, not a PBKDF2 value) - Both sides exchange their
HSUBandIV - Each side verifies the other's
HSUBby recomputing it locally - A common salt is derived:
salt = IV_client XOR IV_server - The session key is derived with PBKDF2:
key = PBKDF2-HMAC-SHA256(password, salt, 100_000 iterations)— 256 bits
Read step 5 again and the point clicks: the key is derived from the password on both
ends, but the password itself never crossed the wire — only the harmless IVs did. An
eavesdropper who caught every packet still doesn't have the password, and without it can't
compute the key. Once both sides hold that key, everything after the handshake is
encrypted with the negotiated cipher. The cipher 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. (AES-256-GCM is only the dataclass fallback used when a side offers no cipher list at all.)
An alternative v0 path skips the handshake and uses a pre-shared crypto_key directly (the legacy Encryption Key printed by add-client). This path is kept for backward compatibility only.
Refusing old clients — min_protocol_version¶
The server will only admit a client that can negotiate at least min_protocol_version (config key in ~/.config/hivemind-core/server.json, default 2). At the default, the oldest JSON-only / no-binary v0 and v1 clients are refused outright; a v2 client still connects with the legacy binary+handshake path, and a v3 client gets the Noise session. Raise the floor to 3 to require Noise from every client. The ProtocolVersion enum is ZERO/ONE (handshake) / TWO (binary) / THREE (Noise).
The floor is enforced twice. The server advertises it at connect time. It checks again when the handshake completes, and it judges the version the client actually performed, not the version the client said it supports. A client that can speak v3 but sends a v2 password handshake against a floor of 3 is refused. Earlier releases let that client in and ignored the raised floor. At the default floor of 2 the same rule refuses a legacy v1 pubkey handshake. Both rejections are log-only: they emit no bus event, so watch the logs for rejecting <peer>: lines (see Operations).
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 needs require_crypto=False. An INTERCOM payload that is not a signed, encrypted envelope carries no origin proof at all. A node with require_crypto set drops it. require_crypto is an attribute of HiveMindListenerProtocol and it is True by default, so a stock server drops plaintext INTERCOM. If you rely on that feature, construct the listener protocol with require_crypto=False.
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 |
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 roles, no "admin can do everything" shortcut. Permissions are per client:
each one carries its own allowed_types whitelist and its own skill/intent blacklists.
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. Two gates, always in this order:
-
allowed_typescheck — 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. -
Policy plugins — configured policies run in order. The default policy is
OVOSAgentPolicy, which readsClient.metadatato build per-client session blacklists (skills and intents).
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 --selfsigned 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_typespoorman_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_protocol_version(default 2),min_password_bits,runtime_password_strength_checkpoorman_handshake/symmetric/__init__.py— legacy password handshake, salt = IV XOR IV, PBKDF2-HMAC-SHA256 100k iterspoorman_handshake/asymmetric/utils.py— hybrid RSA + AES-256-GCM INTERCOM encryptionhivemind_bus_client/identity.py— identity file fields andtrusted_keys