trustsentinel.eu

marshmallows 1.0: rebuilding a 2019 IoT mesh on the Noise Protocol

2026-10-17 · 0xE2ED

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

  1. One Go module. The old tree had three GOPATH projects with copy-pasted packages. Now there is one module, github.com/trustsentinel/marshmallows, with cmd/mm-broker, cmd/mm-agent, cmd/mm-auth and an internal/ tree. The 2019 code is still in the repository under _legacy/, and the 0.1.x branch preserves the baseline. I like being able to diff against the past.
  2. 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/check with a token, and the answer is {"status":"ok"} exactly once.
  3. Noise IK instead of noisesocket.v0. The transport is now flynn/noise with 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.
  4. 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, resize and close messages 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.
  5. 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.
  6. A demo that is also a test. docker compose up starts auth, broker, agent and web. The browser at http://localhost:8080 gets 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 to go 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

The code is at https://github.com/trustsentinel/marshmallows and there is a short whitepaper in docs/.

← all posts