JavaScript client for HiveMind, up to protocol v3 (Noise with argon2id PSK derivation). Runs in the browser and in Node.js 18+.
Uses the native Web Crypto API (crypto.subtle) for X25519, SHA-256, HMAC and AES-GCM, plus @noble/ciphers and @noble/hashes (pure-JS, audited, no WASM) for the two primitives Web Crypto lacks: ChaCha20-Poly1305 (the default protocol-v3 Noise AEAD) and argon2id (the default PSK derivation). This gives HiveMind-js full cipher parity with hivemind-core, every registered Noise suite and PSK derivation. Node.js 18+; protocol v3 needs Node.js 20+ (Web Crypto X25519).
npm install hivemind-jsIn Node.js the @noble dependencies are resolved automatically. For the browser see Browser build below, hivemind.js itself is a plain script, and it expects the two @noble primitives to be exposed on globalThis.HiveMindNoble (a five-line bundle step). Without them the client still runs, but degrades to the Web-Crypto-only AES-GCM + PBKDF2 subset (it cannot negotiate the default ChaChaPoly suite or derive an argon2id PSK).
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>HiveMind JS Demo</title>
<!-- Web Crypto is built in; expose @noble on globalThis.HiveMindNoble
for the default ChaChaPoly suite + argon2id PSK, see "Browser build" -->
<script src="static/js/hivemind.js"></script>
</head>
<body>
<script>
const hivemind = new JarbasHiveMind();
// Override event hooks before connecting.
// onHiveConnected fires only after the full handshake completes, not on
// socket open. sendUtterance and sendMessage are safe to call from it.
hivemind.onHiveConnected = async function () {
await hivemind.sendUtterance("tell me a joke");
};
hivemind.onMycroftSpeak = function (mycroft_message) {
window.alert(mycroft_message.data.utterance);
};
hivemind.onHiveDisconnected = function () {
window.alert("HiveMind connection lost.");
};
// connect(host, port, username, accessKey, password)
// The 5th argument is the shared password of this client on the hub.
hivemind.connect("127.0.0.1", 5678, "HivemindWebChat", "ivf1NQSkQNogWYyr", "mypassword");
</script>
</body>
</html>A page can also import the ESM entry point directly:
<script type="module">
import { JarbasHiveMind } from './static/js/hivemind.mjs';
const hivemind = new JarbasHiveMind();
</script>The browser then loads hivemind.js as a module. The file puts its exports on
globalThis.HiveMindJS, and hivemind.mjs reads them from there.
In Node.js, supply a WebSocket implementation on globalThis (the client uses the
browser WebSocket global). Any standard implementation such as ws works:
// CommonJS
const { JarbasHiveMind } = require('hivemind-js');
globalThis.WebSocket = require('ws');
// or ESM
// import { JarbasHiveMind } from 'hivemind-js';
// import WebSocket from 'ws';
// globalThis.WebSocket = WebSocket;
const hivemind = new JarbasHiveMind();
hivemind.onHiveConnected = async () => {
console.log('connected');
await hivemind.sendUtterance('tell me a joke');
};
hivemind.onMycroftSpeak = (msg) => console.log('speak:', msg.data.utterance);
// fires for the spec topic 'ovos.utterance.speak' and the legacy 'speak'
// connect(host, port, username, accessKey, password)
hivemind.connect('127.0.0.1', 5678, 'HivemindNode', 'ivf1NQSkQNogWYyr', 'mypassword');Node needs ws only as a dev dependency (Node lacks a built-in WebSocket
global). The runtime crypto dependencies are @noble/ciphers and
@noble/hashes, small, audited, pure-JS, used only for ChaCha20-Poly1305 and
argon2id; everything else uses native Web Crypto.
Opens a WebSocket connection and runs the handshake automatically. The Noise
handshake is mandatory (HIVEMIND-CRYPTO-1 §3): the client never downgrades to
the legacy Protocol V1 handshake on its own. When a hub cannot complete the
Noise handshake, the connection is refused unless options.legacyHub is set.
| Argument | Type | Description |
|---|---|---|
host |
string | Server hostname or IP. May carry its own scheme (wss://hive.example.org); a bare host uses options.ssl |
port |
number | Server port (default HiveMind port: 5678) |
username |
string | Client name / user-agent string |
accessKey |
string | Access key issued to the client |
password |
string | Shared password for PBKDF2 session-key derivation |
options |
object | Optional, protocol v3 (Noise) settings, see below |
options fields (all optional):
| Field | Type | Description |
|---|---|---|
psk |
Uint8Array or hex string |
Optional pre-provisioned 32-byte Noise PSK. Normally unnecessary, a password is stretched with argon2id on-device to the same value; provide psk only to skip derivation or when no password is configured |
serverNoiseKey |
hex string | Pinned server static X25519 public key; enables KKpsk0 and aborts on mismatch (TOFU pinning) |
noiseStaticKey |
Uint8Array or hex string |
This node's static X25519 private key. Optional — when omitted, the client generates one and remembers it for you, keyed by host/port/accessKey: localStorage in the browser, an in-memory process-lifetime cache in Node.js (Node has no localStorage, and this library will not silently write key material to a file in your home directory — pass this option yourself if you need a Node process identity to survive a restart). An explicit value here always wins over anything stored, and is itself persisted for later connects. This matters because the server pins a client's static key on first use: regenerating it every connection gets the client locked out |
maxProtocolVersion |
number | Cap the negotiated protocol version (default 3) |
legacyHub |
boolean | Default false. Allows the pre-v3 legacy password handshake against a hub that cannot complete the mandatory Noise handshake. The choice never depends on what the hub advertises — only this switch. Setting it logs one warning per connection naming the forward-secrecy and mutual-authentication properties given up, and the version the legacy handshake is removed in (computed from this client's own version, next major) |
ssl |
boolean | Connect with wss:// instead of ws:// for a bare host (default false). A browser on an HTTPS page cannot open a ws:// socket at all, so any hub reached from a hosted page needs this or a wss:// host |
Returns the raw WebSocket instance.
Sends a recognizer_loop:utterance bus message. Must be called after onHiveConnected.
Sends an arbitrary pre-built HiveMessage (plain object). Encrypts it with the session key. Throws if called before onHiveConnected.
Use _wrap(msg_type, payload) to build a correctly-shaped HiveMessage envelope.
Override these on your instance before calling connect():
| Hook | When it fires |
|---|---|
onHiveConnected() |
Handshake complete, safe to call sendMessage |
onHiveDisconnected() |
WebSocket closed |
onHiveError(err) |
The hub refused or aborted the connection, e.g. the WebSocket closed before the handshake reached READY. A close with code 1008 means the hub rejected the credentials — treated as fatal. Fires before onHiveDisconnected() |
onMycroftMessage(msg) |
Any bus message received |
onMycroftSpeak(msg) |
bus message with type ovos.utterance.speak, or the legacy type speak |
onHiveBroadcast(msg) |
broadcast message received |
onHivePropagate(msg) |
propagate message received |
onHiveIntercom(msg) |
intercom message received |
onHivePing(msg) |
ping message received |
When the server advertises binarize: true in its HANDSHAKE, the client opts in and both sides switch to binary WebSocket frames. Every frame is an AES-GCM-encrypted payload using the bitstring wire format.
// Sending a raw audio frame
const metadata = { sample_rate: 16000, sample_width: 2 };
const frame = encodeBitstring('bin', wavBytes, metadata, BIN_TYPES.RAW_AUDIO);
await hivemind._sendEncryptedBinary(frame);
// Handling incoming binary frames (e.g. TTS audio)
const orig = hivemind._handleBinaryWsMessage.bind(hivemind);
hivemind._handleBinaryWsMessage = async function(buffer) {
const decrypted = await decryptAesGcmBin(this._sessionKey, new Uint8Array(buffer));
const decoded = await decodeBitstring(decrypted);
if (decoded.binType === BIN_TYPES.TTS_AUDIO) {
// play audio...
return;
}
await orig(buffer);
};Available globals (after loading hivemind.js): encodeBitstring, decodeBitstring, encryptAesGcmBin, decryptAesGcmBin, BIN_TYPES.
See docs/binary.md for the full frame layout, bitstring format, and API reference.
When the server advertises max_protocol_version >= 3 together with its Noise
patterns/suites, the client runs an authenticated key exchange built on the
Noise Protocol Framework (revision 34)
instead of the legacy handshake, see HIVEMIND-CRYPTO-1 §3.4. It provides
mutual static-key authentication, forward secrecy, password authentication
without an offline-attackable artifact on the wire, and transcript binding
(any tampering with the negotiation aborts the handshake).
This client supports both registered cipher suites (HIVEMIND-CRYPTO-1 §3.4.1) across both patterns, full parity with hivemind-core:
Noise_XXpsk2_25519_ChaChaPoly_SHA256, default, general case (static keys exchanged in the handshake, TOFU-then-pin)Noise_KKpsk0_25519_ChaChaPoly_SHA256, default, pre-provisioned static keys (passserverNoiseKey)Noise_XXpsk2_25519_AESGCM_SHA256/Noise_KKpsk0_25519_AESGCM_SHA256, Web-Crypto-native AES-GCM variants
ChaCha20-Poly1305 (via @noble/ciphers) is preferred, matching the Python
client's preference order; the suite is negotiated from the server's advertised
list, with AES-GCM chosen only when the server offers AES-GCM but not
ChaChaPoly. When no mutual suite exists, or the hub cannot otherwise complete
the Noise handshake, the connection is refused unless options.legacyHub is
set (see the API reference above).
After the handshake, all session traffic travels as Noise transport messages (binary WebSocket frames) under per-direction cipher states with strictly sequential 64-bit counter nonces, replayed, reordered or tampered messages fail authentication and terminate the session.
The shared site password enters the handshake as a 32-byte Noise PSK. The
server derives it as argon2id(password, SHA-256(node_id)) by default, and this
client derives the same value on-device with @noble/hashes argon2id (same
parameters: t=3, m=64 MiB, p=1, len=32, id, v0x13). So a password-configured
client just works against a stock hivemind-core, with no server-side
configuration and no provisioning step (HIVEMIND-CRYPTO-1 §3.4.4). The derivation
is byte-verified against poorman_handshake.noise.derive_psk in the test suite.
Alternative PSK inputs, in priority order:
-
Provisioned PSK: pass
options.psk(32-byteUint8Arrayor hex) to skip derivation. Compute it on any capable host, e.g. Python:from poorman_handshake.noise import derive_psk psk = derive_psk("site password", node_id="<server node_id>") print(psk.hex()) # -> options.psk
-
Password via PBKDF2: if the server explicitly advertises
PBKDF2as its PSK KDF in the handshake parameters, the client derivesPBKDF2-HMAC-SHA256(password, SHA-256(node_id), >=100000, 32)instead.
Only in a minimal browser deployment that ships hivemind.js without the
@noble bundle (no ChaChaPoly, no argon2id) does the client become a
constrained peer: it then needs a provisioned options.psk or a PBKDF2-
advertising server, and logs a clear operator error otherwise.
Requires X25519 support in Web Crypto: all modern browsers, Node.js 20+.
static/js/hivemind.js is a plain script with no build step of its own, but the
default ChaChaPoly suite and argon2id PSK need the two @noble primitives
present as globalThis.HiveMindNoble. Expose them with a tiny ESM shim (both
libraries are ESM, browser-friendly, and need no bundler):
<script src="static/js/hivemind.js"></script>
<script type="module">
import { chacha20poly1305 } from 'https://esm.sh/@noble/ciphers@2/chacha.js';
import { argon2id } from 'https://esm.sh/@noble/hashes@2/argon2.js';
globalThis.HiveMindNoble = { chacha20poly1305, argon2id };
// connect() only after this line has run
</script>The client reads globalThis.HiveMindNoble when it connects, not when
hivemind.js loads. The script order does not matter. A module script always
runs after a classic script, so the global must be set before you call
connect(), not before the script loads. If you connect from another script,
wait for the import to finish first.
Or bundle the same three lines with your app (esbuild/rollup/vite) and drop the
CDN import. If globalThis.HiveMindNoble is absent when connect() runs, the
client runs the Web-Crypto-only subset.
Connection flow:
WebSocket open → Server HELLO → Server HANDSHAKE (request)
→ Client HANDSHAKE (envelope) → Server HANDSHAKE (response + envelope)
→ (both sides derive session key via PBKDF2)
→ Client HELLO (encrypted) → onHiveConnected() → normal encrypted traffic
Key points:
onHiveConnectedfires after the full handshake, not on WebSocketopen- All regular messages are encrypted with AES-GCM (JSON-HEX encoding by default)
- The session key is derived fresh per-connection, the password is never transmitted
See docs/handshake.md, docs/encryption.md, docs/protocol.md, and docs/binary.md for details.
Requires Node.js 18+ (Node.js 20+ for the protocol v3 tests). Run npm install
first: the tests load @noble/ciphers, @noble/hashes and ws.
cd HiveMind-js
npm install
node --test test/*.test.js
# or via package.json script:
npm testTest files:
| File | What it covers |
|---|---|
test/crypto.test.js |
PasswordHandShake: hSub creation, IV extraction, PBKDF2 key derivation, cross-compat with Python vectors |
test/encryption.test.js |
AES-GCM encrypt/decrypt, wire format, Python-vector round-trip |
test/handshake.test.js |
Full connection state machine with a MockWebSocket |
test/binary.test.js |
Bitstring codec, binary encryption, binarize handshake negotiation, binary send/receive |
test/noise-persistence.test.js |
Noise static key persistence across connect() calls, and the pre-READY close that reports onHiveError |
test/esm-native.test.js |
Importing hivemind.mjs as native ESM, with no module and no require, as a browser does |
test/real_hub.test.js |
Reaching a real hub: wss:// and options.ssl URL selection, and the spoken-response topic names |
test/noise.test.js |
Protocol v3 Noise handshake: byte-level interop against Python poorman_handshake/noiseprotocol responder fixtures for both suites (ChaChaPoly + AES-GCM) across both patterns (XXpsk2 + KKpsk0), wrong-PSK/tampered-prologue failure, transport replay rejection, argon2id + PBKDF2 PSK derivation (byte-verified vs derive_psk), client negotiation (ChaChaPoly preferred) + v0-v2 fallback |
To regenerate the cross-language test vectors, run test/generate_vectors.py in a
Python environment that has hivemind-bus-client and poorman_handshake installed.
The vectors prove the JS crypto (hSub, PBKDF2 key derivation, AES-GCM, bitstring
encoding) is byte-for-byte identical to the Python reference implementation:
python3 test/generate_vectors.pyThe protocol v3 (Noise) vectors are generated separately by
test/generate_noise_vectors.py, which drives the Python reference stack
(poorman_handshake.noise + the noiseprotocol engine it wraps) as the
server-role responder with fixed keys, for both cipher suites, and records
every handshake and transport byte plus the argon2id/PBKDF2 PSK values:
python3 test/generate_noise_vectors.py # needs: pip install poorman-handshake noiseprotocolThis repo ships a self-contained, hermetic interop test under
test/e2e/. It boots a real Python hivemind-core hub on an
in-process loopback transport, launches the Node driver
(test/e2e/js_e2e_driver.mjs) which loads the actual
static/js/hivemind.js, connects over a real WebSocket, performs the full V1
handshake, and sends an encrypted utterance. The Python side then asserts the hub
received that exact utterance with a real session id, so the wire behaviour, not
just the vectors, is verified end to end. No external network or fixed ports.
# one-time: install the loopback-hub deps (test-only Python packages) and ws
pip install -r test/e2e/requirements.txt
npm install
# run it
npm run test:e2e # == python3 test/e2e/loopback_hub.pyThe same scenario also runs in the
HiveMind test harness
(tests/test_js_e2e.py), which drives this client as part of the cross-client
conformance suite.
HiveMind-js/
├── static/js/
│ ├── hivemind.js # Main client (CommonJS and browser script)
│ └── hivemind.mjs # ESM entry point, re-exports hivemind.js
├── test/
│ ├── crypto.test.js # PasswordHandShake unit tests
│ ├── encryption.test.js # AES-GCM unit tests
│ ├── handshake.test.js # State machine integration tests
│ ├── noise-persistence.test.js # Noise static key persistence, pre-READY close
│ ├── real_hub.test.js # wss:// selection and spoken-response topics
│ ├── binary.test.js # Bitstring codec + binarize mode tests
│ ├── generate_vectors.py # Python script, regenerates vectors.json
│ ├── noise.test.js # protocol v3 Noise interop tests
│ ├── noise_vectors.json # Noise interop vectors (from Python noiseprotocol)
│ ├── generate_noise_vectors.py # regenerates noise_vectors.json
│ ├── vectors.json # Cross-compat test vectors (Python ↔ JS)
│ └── e2e/
│ ├── loopback_hub.py # Boots a real hivemind-core loopback hub + asserts
│ ├── js_e2e_driver.mjs # Node driver, connects the real client to the hub
│ └── requirements.txt # Test-only Python deps for the hub
├── docs/
│ ├── protocol.md # HiveMessage wire format reference
│ ├── handshake.md # Handshake flow and PasswordHandShake spec
│ ├── encryption.md # Encryption wire format and Web Crypto notes
│ ├── binary.md # Binary/binarize mode: bitstring format, BIN_TYPES, JS API
│ └── e2e.md # End-to-end interop test: how the hub is provided
└── package.json
