picomesh
v0.1.0
Published
Layered Bluetooth piconet framework for Node.js: encrypted sessions, logical addressing, HTTP-style request/response routing, and host-blind end-to-end encryption over BLE (or TCP / in-memory transports).
Downloads
12
Maintainers
Readme
picomesh
A layered Bluetooth piconet framework for Node.js, with host-blind end-to-end encryption. One device is the Host (BLE central: router, DHCP, and DNS in one), every other device is a Client (BLE peripheral). On top of raw BLE characteristics you get:
- Encrypted sessions — X25519 ECDH handshake signed by long-term Ed25519 identities, AES-256-GCM per direction, all via Node's built-in
crypto. No dependence on BLE pairing. - Reliable messaging — fragmentation/reassembly for MTU-sized radio packets, ACKs, retransmits, dedup, replay protection.
- Logical addressing — the host assigns short addresses (host is always
1), keeps a registry, resolves human-readable names, and evicts dead devices via heartbeats. - HTTP-style requests —
client.request('sensor-kitchen', { method: 'POST', path: '/data/sync', body })routed client→host→client, with Express-style handlers on every node. - End-to-end encryption —
client.requestSecure(...)runs a pairwise X25519 handshake with the target device through the host; the host relays the traffic but can only see routing metadata, never the method, path, headers, or body. - Swappable transports — the same stack runs over BLE, TCP, or fully in-memory (for dev and tests). Zero runtime dependencies; BLE libs are optional peers.
┌─────────────────────────────────────────────┐
│ E2E session pairwise AES-GCM, host-blind │ e2e.js (optional, requestSecure)
├─────────────────────────────────────────────┤
│ Application REQ/RES JSON envelopes │ host.js / client.js / handlers.js
├─────────────────────────────────────────────┤
│ Registration addresses, names, heartbeats │ registry.js
├─────────────────────────────────────────────┤
│ Session ECDH handshake, AES-256-GCM │ session.js
├─────────────────────────────────────────────┤
│ Reliability ACK, retransmit, dedup │ link.js
├─────────────────────────────────────────────┤
│ Framing fragment / reassemble to MTU │ framing.js
├─────────────────────────────────────────────┤
│ Transport BLE · TCP · in-memory │ src/transport/*
└─────────────────────────────────────────────┘Install
npm install picomesh
# only where you actually use BLE:
npm install @abandonware/noble # host machine (central)
npm install @abandonware/bleno # client devices (peripheral)Quickstart (no radios needed)
const { Host, Client, MemoryNetwork } = require('picomesh');
const net = new MemoryNetwork({ mtu: 185 });
// The host: router + name server
const host = new Host({ name: 'hub', transport: net.host() });
host.handle('GET', '/status', (req, res) => res.send({ ok: true }));
await host.listen();
// A device that serves a path
const sensor = new Client({ name: 'sensor-kitchen', transport: net.client() });
sensor.handle('/data/sync', (req, res) => {
res.send({ accepted: req.body.records.length });
});
await sensor.connect();
// Another device talks to it BY NAME — routed through the host,
// encrypted per hop, fragmented to the MTU, acked and retransmitted.
const phone = new Client({ name: 'phone', transport: net.client() });
await phone.connect();
const res = await phone.request('sensor-kitchen', {
method: 'POST',
path: '/data/sync',
body: { records: [{ t: 21.5 }], since: 1720800000 },
});
// res -> { status: 200, headers: {}, body: { accepted: 1 } }
// Same thing, but the host cannot read it — end-to-end encrypted,
// with the sensor's identity pinned:
const sealed = await phone.requestSecure('sensor-kitchen', {
path: '/data/sync',
body: { records: [{ t: 21.5 }] },
peer: sensor.pubKeyHash, // exchange this out of band in real deployments
});
// sealed -> { status, headers, body, peer: { pubKeyHash } }Swap MemoryNetwork for TcpHostTransport/TcpClientTransport (LAN) or BleCentralTransport/BlePeripheralTransport (real Bluetooth) and nothing else changes. Runnable versions live in examples/.
API
new Host(options)
| option | default | |
|---|---|---|
| transport | (required) | a host transport (net.host(), new TcpHostTransport(), new BleCentralTransport()) |
| name | 'host' | human-readable name |
| identity | generated | long-term Ed25519 keypair (persist it with identityToJSON) |
| authorize | accept all | ({ pubKeyHash }) => boolean — vet devices during the handshake |
| heartbeatTimeout | 30000 | evict devices silent for this many ms |
| requestTimeout | 10000 | default timeout for host.request() |
host.handle([method,] path, (req, res) => …)— register a handler.await host.listen()/await host.close()host.devices()— registry snapshot.await host.request(addressOrName, { method, path, headers, body, timeout })await host.broadcast(opts)— same request to every device;Map<address, Response|Error>.- Events:
'join' (device),'leave' (device, reason),'error'.
new Client(options)
| option | default | |
|---|---|---|
| name | (required) | registered with the host; how other devices address you |
| transport | (required) | a client transport |
| identity | generated | persist for a stable device identity (and sticky address) |
| trustedHosts | trust on first use | array of host pubKeyHash values to pin |
| authorizePeer | accept all | ({ address, pubKeyHash }) => boolean — vet incoming end-to-end sessions |
| heartbeatInterval | 10000 | ms between heartbeats |
| autoReconnect | true | reconnect with exponential backoff after a drop |
client.handle([method,] path, handler)— serve a path (register beforeconnect()so it's advertised).await client.connect()→{ address, host }await client.request(target, opts)— target is'host', a name, or an address.await client.requestSecure(target, opts)— same, but end-to-end encrypted (see below);opts.peerpins the expected devicepubKeyHash.await client.resolve(name)/await client.devices()- Events:
'connect','disconnect','reconnect','request','error'.
Handlers
node.handle('/echo', (req, res) => res.send(req.body)); // any method
node.handle('GET', '/status', () => ({ ok: true })); // returned value -> 200 body
node.handle('/fail', (req, res) => res.status(503).send({ error: 'busy' }));
// req: { method, path, headers, body, src, srcName? }Thrown errors become a 500 response; unknown paths a 404.
Identities
const { generateIdentity, identityToJSON, identityFromJSON, publicKeyHash } = require('picomesh');
const id = generateIdentity();
fs.writeFileSync('identity.json', JSON.stringify(identityToJSON(id)));
// later:
const restored = identityFromJSON(JSON.parse(fs.readFileSync('identity.json')));A device is known by publicKeyHash(identity.publicKey) (SHA-256 of the public key). Reconnecting with the same identity gets the same logical address back.
Security model
- The handshake is TLS-like: the host signs its ephemeral X25519 key with its Ed25519 identity (its "certificate"); the client signs both ephemerals back (replay-proof). HKDF-SHA256 derives independent AES-256-GCM keys per direction; counters give replay protection within a session.
- Pin the host on clients with
trustedHosts: [hash]; vet clients on the host withauthorize(). Without pinning you get trust-on-first-use. client.request()traffic is encrypted per hop: the host decrypts and re-encrypts what it relays — exactly like a router doing TLS termination.client.requestSecure()adds true end-to-end encryption between two devices. The devices run their own X25519 handshake (relayed by the host as opaque bodies, with distinct signature contexts so host-handshake messages can't be replayed into it), then seal the entire inner request/response — method, path, headers, body, status — with pairwise AES-256-GCM keys. The host still sees routing metadata (who talks to whom, when, and how much), because it has to route. The envelope id is bound inside the ciphertext so the host cannot splice responses between requests, and a sliding replay window rejects duplicated messages while tolerating out-of-order delivery.- End-to-end trust needs an out-of-band anchor: pass
peer: '<pubKeyHash>'torequestSecure()(and/or useauthorizePeeron the receiving side). Without pinning, the first e2e handshake trusts whatever identity answers — a malicious host could substitute its own. DevicepubKeyHashvalues are visible indevices(), but for secrecy from the host you must exchange them out of band. - Note that advertised service paths (
services, defaulting to your handler paths) are registered with the host by design. Passservices: []if even path names are sensitive. - Envelope
srcaddresses are stamped by the host on relay, so devices cannot spoof each other.
Windows BLE — the Rust bridge
Node has no working BLE peripheral bindings on Windows, but the OS itself supports the role (WinRT GattServiceProvider). This package ships a small Rust bridge (bridge/) that talks to the Windows Bluetooth stack directly — peripheral role via WinRT, central role via btleplug — and pipes datagrams to Node over a loopback socket. No dongles, no drivers, no extra hardware: it uses the built-in adapter.
npm run build:bridge # requires Rust (rustup.rs); builds bridge/target/release/picomesh-ble-bridge.exe// Machine 1 — host
const { Host, WinBleHostTransport } = require('picomesh');
const host = new Host({ name: 'hub', transport: new WinBleHostTransport() });
// Machine 2 — client
const { Client, WinBleClientTransport } = require('picomesh');
const client = new Client({ name: 'laptop-b', transport: new WinBleClientTransport() });Runnable versions: examples/winble-host.js / examples/winble-client.js.
Requirements: Windows 10 1703+ and an adapter with IsPeripheralRoleSupported (check with the snippet below), Bluetooth toggled on. Most modern laptop adapters qualify.
# Does this machine support peripheral mode?
Add-Type -AssemblyName System.Runtime.WindowsRuntime
[Windows.Devices.Bluetooth.BluetoothAdapter,Windows.Devices.Bluetooth,ContentType=WindowsRuntime]::GetDefaultAsync().GetResults() |
Select IsCentralRoleSupported, IsPeripheralRoleSupportedPlatform reality check (BLE)
- Windows ↔ Windows: use the Rust bridge transports above (
WinBleHostTransport/WinBleClientTransport). - Linux (Raspberry Pi, embedded): use the noble/bleno transports (
BleCentralTransport/BlePeripheralTransport) — no Rust needed there. - The two interoperate: they speak the same GATT service, so a Windows bridge host can register Linux bleno clients and vice versa.
- A BLE central realistically holds 5–10 simultaneous connections. This is a small-cluster design; if you outgrow it, swap the transport for TCP/Wi-Fi — the layers above don't change (that's the point of the layering, and the TCP transport in this package is the proof).
MemoryNetworksimulates MTU, latency, and packet loss for radio-free development anywhere.
Tests
npm test41 tests cover framing at tiny MTUs, handshake tampering/replay, routing, spoof protection, name conflicts, heartbeat eviction, auto-reconnect with sticky addresses, a 25%-frame-loss soak, end-to-end encryption (host blindness, replay rejection, peer pinning, out-of-order responses, transparent re-handshake), the full stack over real TCP sockets, and the Windows bridge protocol against a simulated radio.
