Knit Spool is a blinded store-and-forward relay: it holds end-to-end-sealed frames for scope ids it
cannot map to anyone. Security reports are taken seriously, but note the project ships as-is with
no warranty or guaranteed response (see CONTRIBUTING.md).
Pre-1.0. Only main is supported — fixes land there, and there are no backports to earlier tags.
If you run a Spool, run the newest release; a fix that has not been released yet is on the edge
image, which every main push publishes once it has passed the same gates a release does.
Please do not open a public issue or pull request for security vulnerabilities. Public disclosure before a fix exists puts the people relying on running Spools at risk.
Report privately through GitHub's private vulnerability reporting — the "Report a vulnerability" button on the repository's Security tab — which opens a private draft advisory visible only to the maintainers. If you'd prefer email, write to jeff.mixon@gmail.com. Please include:
- a description of the issue and its impact,
- steps to reproduce (or a proof of concept), and
- the affected version / commit, plus the relevant
SPOOL_*configuration if it matters.
Please allow reasonable time for a fix before any public disclosure. Knit Spool is maintained by one person as their full-time job, on a best-effort basis, so there is no guaranteed acknowledgement or remediation timeline, but genuine reports will be reviewed.
Vulnerabilities in the sealed payload itself — the E2E crypto envelope, key handling, safety
numbers — belong to the Knit app, not to this repo: report those through
Knit's SECURITY.md. Anything about the
Spool wire protocol as specified (rather than as implemented here) is also a Knit-repo matter,
since the spec lives there.
A Spool is deliberately given as little as possible:
| Sees | Scope ids, blob ids, sealed ciphertext, sizes, timing, client IPs |
| Never sees | Node ids, plaintext, rosters, who read what, which other Spools a client uses |
Frames are sealed by the client before they are pushed, so a hostile or compromised Spool learns ciphertext and traffic patterns — never message content. No Spool talks to another; clients multi-home across several and union the results, so a Spool that vanishes, lies by omission, or is seized takes nothing with it that another member cannot re-push.
A Spool with the experimental casts switched on (SPOOL_RATE_CASTS) also relays a walkie-talkie's
chunks live and keeps none. That shows the operator a finer pattern than stored frames do: that a
conversation is in a walkie session, which side talks when and for how long, and both phones'
addresses. It still shows nothing of what was said, and none of it reaches the disk or the log. A
cast, or the scope it names, in this daemon's log is a reportable vulnerability.
Every Spool on 0.8 or later also relays signals live and keeps none, unless SPOOL_RATE_SIGS=0.
Today a signal is a typing cue. It shows the operator that someone in a conversation is typing,
when, and the sender's address, and nothing of what is typed or who is typing: the client seals and
signs it inside. No ticket gates a signal, so it ties no ticket pseudonym to that timing. None of it
reaches the disk or the log. A signal, or the scope it names, in this daemon's log is a reportable
vulnerability.
A Spool with a ticket key (SPOOL_TICKET_KEY, experimental) also learns each ticket holder's daily
pseudonym, and can link that holder's connections, and the scopes they hold, within a day. Where
files is gated it keeps an upload budget per pseudonym: how many attachment bytes that holder
stored today, in memory only and dropped once the ticket expires. None of it reaches the disk or
the log, and a restart forgets it. A ticket or a pseudonym in this daemon's log, or on its disk, is
a reportable vulnerability.
A Spool running a commons (SPOOL_COMMONS_ID, and up to seven more rooms as SPOOL_COMMONS_<n>_ID)
holds the same shape of secret it always did, once per room. Each room's invite is a 32-byte secret
that splits in two: the scope id, SHA-256("knit/spool/v1/commons" ‖ secret), which is what the
operator configures, and the content key, which members derive and the Spool is never given. This repo implements the first derivation and deliberately not the second, so
"the Spool cannot read its own commons" is a property of the code rather than a promise about it.
hello names each room by a tag, the first eight bytes of a hash of its scope id rather than of
the secret, so the Spool can compute it. It is one-way: it lets a member who derived the id find its
room's entry, and gives nobody else anything to subscribe with.
What a commons does move:
- The operator can count each room.
knit_spool_commons_subscribersis how many connections are subscribed to each room — an aggregate on an endpoint that is token-gated on private Spools and 404'd by every shipped proxy config, next to a connection count the operator already had. It is not a roster, and no such count is ever offered to clients: who is in a room stays a delivery fact the Spool does not deal in. With several rooms the operator also sees which room each connection subscribes to, as it does for every scope; with one room that was only whether a connection was in the room. hellois a directory of the rooms. Whoever connects learns how many rooms there are, each one's bounds and whether it takes attachments. Names are left out unlessSPOOL_TOKENis set, because on an open Spool anyone can connect; the invite link carries a room's name instead. A former member can tell whether an old room still runs by its tag, which the id they hold already let them check.- The scope id is a weaker secret than the invite. A member can hand out the id without the secret, and the holder could then subscribe and collect ciphertext they cannot read. That is traffic analysis, not disclosure, and it is the same exposure every scope id already carries.
Removing a member means rotating their room: mint a new invite, put the room's new id in
SPOOL_RELOAD_FILE, and send SIGHUP. The Spool closes every connection in the old room, the
removed member's included, so none of them keeps talking in the old scope on a connection that
outlives the rotation; nobody else is disconnected. The old scope is no longer pinned, so it ages
out on its TTL or under the watermark. Boot reads the same file, so a restart does not bring the
old room back. A removed member who still holds the old id can subscribe to it as an ordinary scope,
as anyone can name any scope; a conforming client stops subscribing to a room its Spool no longer
lists, so the members who stay are not there. The
operator cannot moderate individual messages — that would need the key they do not have. The one
lever is SPOOL_REQUIRE_MODERATION, and it is the members' clients that pull it (below).
Knit Spool is experimental. Several properties are intentional design trade-offs, not vulnerabilities — they are documented and out of scope for reports:
- Traffic metadata is visible to the operator by design. Scope ids, blob sizes, arrival times, subscriber counts, client IPs and, with casts on, the live timing of a walkie session are exactly what a relay must handle to do its job. Correlating them is expected; a report that a Spool operator can see them is not a finding.
- The listener speaks plain WebSocket. TLS terminates at a reverse proxy — see
deploy/for Caddy and nginx configurations. Exposing port 9470 to the internet directly is a deployment error, not a daemon vulnerability. - The bearer token rides in the query string (
?k=), because the spec puts it there. It gates access to a private Spool; it decrypts nothing. Keep it out of proxy access logs — the shipped proxy configs do, and any proxy of your own should. A token in this daemon's logs, or returned by/healthzor/metrics, is a finding. - PoW defaults to off (
SPOOL_POW_BITS=0). A public Spool run with the default will accept scope creation from anyone up to its configured rate limits and storage watermark. That is a configuration choice — the spec suggests 20 bits for public deployments. - Storage is not encrypted at rest. SQLite holds bytes that were already sealed end-to-end; the Spool has no key that would make encrypting them meaningful.
- Capacity is bounded, not fair. At the storage watermark the least-active scope is shed
wholesale, and within a scope the oldest frame by arrival is evicted. At
SPOOL_MAX_SCOPESa new scope can displace only an empty scope nobody is subscribed to, never one holding frames. A well-behaved client that shares a Spool with a noisy one can lose held frames — the client's answer is multi-homing and re-push, not a promise from the Spool. - A Spool can withhold, delay, or forget. Availability from any single Spool is explicitly not guaranteed; that is why no Spool is load-bearing.
SPOOL_REQUIRE_MODERATIONis a request, not an enforcement. The Spool holds ciphertext and cannot screen it; the flag asks conforming clients to run their on-device content screen before sending and to refuse what it flags (spec §7.5). A modified client can ignore it exactly as it can skip any sender-side check, and the Spool cannot tell — a report that one does is not a finding. Receive-side screening is unaffected and stays the recipient's own setting, which is the protection that survives a hostile sender.- A commons room is only as private as its invite, and every member shares one key. Anyone the secret reaches can read and write the room, and there is no per-member identity, ban list, or revocation short of rotating the invite. Whether a member can forge another's display name is a property of the sealed frame, which this daemon never opens — it belongs to the client and the spec, not here.
Novel issues beyond these documented trade-offs are in scope and welcome, in particular:
- CBOR record-parser bugs — over-reads, unbounded allocation from an attacker-chosen length,
crashes, or anything that gets past
SPOOL_MAX_RECORD/SPOOL_MAX_BLOB/SPOOL_MAX_A_CHUNK. - Cross-scope leakage — a frame, event, digest, or attachment chunk reaching a subscriber of a different scope.
- Authentication bypass on a private Spool, or
/metricsserved unauthenticated when a token is set. - PoW verification bypass, or a replay of the per-
(scope, day)cache that skips work it should not. - Rate-limit or quota bypass, per-IP accounting fooled by a forged
X-Forwarded-ForwhenSPOOL_TRUST_PROXYis off, or any path to unbounded memory, file-descriptor, or disk growth inside the configured limits. - SQL injection, path traversal out of
SPOOL_DATA_DIR, or corruption of the store that survives a restart. - Digest computation that lets a client poison another's anti-entropy state.
Not a substitute for the above, but the short list operators ask for: terminate TLS at a proxy, set
SPOOL_POW_BITS on a public Spool, set SPOOL_TOKEN on a private one and pass it with
--token-file rather than argv, keep /metrics off the public internet, leave
SPOOL_TRUST_PROXY=false unless a proxy you control appends X-Forwarded-For, and cap the process
(the shipped compose overlays do). See the README's Deploy and Operating sections.
Running the experimental TURN overlay adds two: keep SPOOL_TURN_SECRET out of anything but
deploy/.env (mode 0600) — anyone holding it can mint relay credentials — and keep the
denied-peer-ip list in deploy/coturn/entrypoint.sh intact. Without it a credential holder can
relay to the host's loopback, its private network or a cloud metadata endpoint. A credential, or
the secret, in this daemon's log is a reportable vulnerability like a token is. coturn itself logs
the username of a failed authentication, which carries the expiry and a random suffix and links to
no conversation.