marshmallows 1.0: rebuilding a 2019 IoT mesh on the Noise Protocol
In 2019 I submitted marshmallows to the INCIBE National Cybersecurity Competition: a secure mesh for IoT devices, where each device dials out to a small "mini cloud", proves who it is with a Noise handshake, and gets a browser terminal without ever exposing a port. The idea held up. The code did not: a Rails application for authentication, a Create React App frontend, and two Go programs built inside a GOPATH against noisesocket.v0. Seven years later every one of those dependencies was end of life.
Last month I rebuilt it and tagged version 1.0. This is what stayed, what changed, and what I would still change.
What stayed
The architecture is the same three boxes. An agent runs on the device. A broker runs in the mini cloud and is the only thing with a public address. An auth service decides who may enroll a device and who may open a shell on it. The browser talks to the broker, the broker talks to the agent, and the agent talks to nobody else.
The property that mattered in 2019 still matters: the device has no inbound port. It opens one outbound WebSocket to the broker and keeps it alive. Everything else, including the shells, travels inside that one connection. You can put the device behind NAT, behind a carrier, behind a firewall that only allows outbound 443, and it still works. There is nothing to scan.
What changed
- One Go module. The old tree had three GOPATH projects with copy-pasted packages. Now there is one module,
github.com/trustsentinel/marshmallows, withcmd/mm-broker,cmd/mm-agent,cmd/mm-authand aninternal/tree. The 2019 code is still in the repository under_legacy/, and the0.1.xbranch preserves the baseline. I like being able to diff against the past. - Authentication without Rails. The Rails app needed MySQL and Redis to do two things: register a user with a second factor, and answer the broker's question "is this enrollment token valid?". The replacement is a small Go service with WebAuthn (passkeys) and TOTP, a JSON file as its store, and single-use tokens for both user invites and device enrollment. The contract the broker depends on did not change:
POST /agent_registration/checkwith a token, and the answer is{"status":"ok"}exactly once. - Noise IK instead of noisesocket.v0. The transport is now
flynn/noisewith the IK pattern: Curve25519, ChaCha20-Poly1305, BLAKE2b. IK means the initiator already knows the responder's static key. The agent's configuration carries the broker's public key, so an agent only ever completes a handshake with the broker it was told about. The broker learns the agent's static key during the handshake and ties it to the device registration. A stolen configuration file is still a problem, but a spoofed broker is not. - Shells multiplexed inside the encrypted channel. This was the embarrassing part of the 2019 code: the control channel was encrypted, but each shell opened a separate plaintext connection. Now
open,in,out,resizeandclosemessages travel inside the one Noise session, and the agent keeps a map of PTY sessions keyed by id. One connection, one key, everything inside it. - A frontend that is just a frontend. Create React App became Vite with React 18 and xterm.js. No component library, no state framework, light background, one violet accent. The dashboard lists devices, opens a terminal, and lets you enroll a device or a user. That is all it needs to do.
- A demo that is also a test.
docker compose upstarts auth, broker, agent and web. The browser athttp://localhost:8080gets a real shell on the agent container through the brokered Noise channel. The same compose stack runs in CI as the end-to-end test, next togo test, govulncheck, gosec and CodeQL. If the demo breaks, the build is red.
What I would still change
Honest caveat: the browser-to-broker leg is TLS to the broker, not end to end. The broker terminates the browser session and re-encrypts toward the agent, so for terminal traffic the broker is a trusted relay. The threat model says so. The fix is a WebAssembly client that runs the Noise handshake in the browser (stk already has the transport layer for it), after which the broker becomes a pipe that cannot read what it forwards.
Two more items on the list: federation between mini clouds, so a device enrolled in one can be visible from another under policy, and hardware-backed device identity with a TPM or secure element feeding the device's identity document.
Lessons
- Keep the old tree.
_legacy/cost nothing and answered a dozen "why did I do this?" questions. - Port the crypto, do not rewrite it. The Noise transport came from stk verbatim, including its tests.
- Fresh dependencies have a price. The module requires Go 1.26 because the WebAuthn library does.
- A demo nobody runs is documentation. A demo that runs in CI is a test.
The code is at https://github.com/trustsentinel/marshmallows and there is a short whitepaper in docs/.