No description
Find a file
Nicolas Perraut 79530a8dfe
All checks were successful
Test / audit (push) Successful in 23s
Test / pint (push) Successful in 26s
Test / rector (push) Successful in 30s
Test / types (push) Successful in 31s
Test / type-coverage (push) Successful in 41s
Test / unit (push) Successful in 40s
docs: rewrite the README for a stranger
The old one opened with an endpoint table, which assumes the reader
already knows what a relay is for. It now opens with the problem, and the
second section states the ceiling up front: the social graph is only
lightly obscured and there is no forward secrecy. Burying that until §12
of a linked spec is the wrong call for a project whose pitch is privacy.

Adds an Interoperating section, which an open protocol needs and this
lacked entirely: start at the §9.4.7 vector, and know that a wrong
derivation yields a well-formed stream id that 404s exactly like an empty
mailbox. That failure mode cost days to diagnose once already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01He3B8sC3DyNJ4R83udw7re
2026-08-12 18:05:03 +02:00
.forgejo/workflows refactor: align the relay with the house Laravel rules 2026-07-30 00:16:22 +02:00
app chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
bootstrap feat(security): sign writes with a per-stream key 2026-08-04 19:56:40 +02:00
config chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
database chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
public chore!: strip starter kit down to a relay host 2026-07-29 11:06:39 +02:00
routes fix(security): give writes their own tight rate budget 2026-07-30 18:12:16 +02:00
storage chore: default 2025-03-15 19:51:25 +00:00
tests chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
.editorconfig chore: syncs with skeleton 2026-03-21 12:58:31 +00:00
.env.example chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
.gitattributes chore: adjusts atttributes 2025-09-18 23:20:48 +01:00
.gitignore chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
AGENTS.md chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
artisan chore: default 2025-03-15 19:51:25 +00:00
composer.json chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
composer.lock chore: cut what the relay never used 2026-08-12 18:05:03 +02:00
glong-protocol.md docs(spec): consolidate the protocol into one source of truth 2026-08-12 18:05:02 +02:00
phpstan.neon chore!: strip starter kit down to a relay host 2026-07-29 11:06:39 +02:00
phpunit.xml refactor: drop the session service provider 2026-07-30 00:04:41 +02:00
pint.json fix(pint): add 'tmp' to notPath to streamline configuration 2025-09-30 23:28:45 +05:30
README.md docs: rewrite the README for a stranger 2026-08-12 18:05:03 +02:00
rector.php chore: cut what the relay never used 2026-08-12 18:05:03 +02:00

Glong Relay

A zero-knowledge mailbox for end-to-end-encrypted shift schedules.

Bus drivers who want to ride along on each other's shifts need to know who is out. Answering that normally means a server holding everybody's roster. This one holds ciphertext under an opaque id and nothing else — it cannot tell you whose shift it is storing, who is reading it, or what it says.

The relay is one role in the Glong protocol. A driver's own instance scrapes their roster, projects it down to the level granted per friend, encrypts and signs it, and publishes here. Friends poll, decrypt locally, and verify against a key they checked in person. The relay never holds a key, never decrypts, and knows no identities.

Its entire state is one row per stream: the latest envelope, plus the public key allowed to replace it.

Why bother

The relay is deliberately the least trusted part of the system, so the interesting question is what it can betray you with. The answers are in §9.5 and §12, and they are honest about the ceiling:

  • Content is safe. Envelopes are XChaCha20-Poly1305 under a key sealed to the recipient. The author and signature live inside the ciphertext, so reading the database tells you nothing about who wrote what.
  • The social graph is only lightly obscured. "Whoever writes stream X" and "whoever reads stream X" are a linkable pair. Client IPs would let an operator attribute it, which is why the relay strips them. Rebuilding the graph with real effort is an accepted risk, not a solved problem.
  • There is no forward secrecy. A recipient key that leaks later decrypts every envelope it ever received.

If those trade-offs are wrong for you, the protocol is open and the relay is swappable — that is the point of it being a commodity.

Endpoints

Method Path Notes
PUT /streams/{streamId} Publish or replace the latest envelope. 204.
GET /streams/{streamId} The stored envelope, verbatim, as application/vnd.glong.envelope+json.
GET /streams/{streamId}/head {"envelopeId", "createdAt"} — change poll without the ciphertext.
DELETE /streams/{streamId} Retract. Best-effort: already-fetched copies cannot be recalled.
GET /up Liveness probe.

Stream ids are Crockford base32, 2664 characters; anything else is a 404 before it reaches the database. Full definitions in §9.2.

Reads are anonymous. Readers hold no key the relay could check, and requiring one would hand it a roster of who reads what.

Not implemented, on purpose: the handle directory (§9.1) and push notifications (§9.3). A handle → id resolver would give the relay a list of participants and a log of who looked up whom, for no confidentiality gain — pairing is out-of-band. Readers poll /head instead of subscribing. Both are specified, so another relay may implement them.

Writer authentication

Addressing an envelope by stream id alone makes the id both the address and the write capability: everyone who knows a stream — including its reader — could overwrite or delete it. Confidentiality and authenticity survive that, since the inner signature is checked by the reader. Availability does not.

So writes carry a per-stream Ed25519 signature (§9.4):

PUT /streams/{streamId}
Glong-Writer-Key: <base64url ed25519 public key, unpadded>
Glong-Signature:  <base64url detached signature, unpadded>

signed message = "glong-write-v1\n" || streamId || "\n" || <raw request body>
  • The writer key is not the publisher's Glong ID. It is derived per stream from the pair secret (HKDF-SHA256(s_AB, info="glong-writer-v1")), so it is unlinkable across streams and pinning it does not undo the sealed sender. The publisher re-derives it and stores nothing.
  • The signed message covers the body as received. Re-serialising the JSON changes the bytes and the signature stops verifying. Binding the stream id stops a valid envelope being replayed into a different stream.
  • The relay pins the key on first publish (TOFU). 401 means the credentials are missing, malformed or do not verify; 403 means they verify but the stream belongs to another key.
  • DELETE signs "glong-retract-v1\n" || streamId || "\n" || envelopeId, so a captured retraction stops working as soon as the stream moves on.
  • createdAt must be strictly ahead of the stored envelope, and no more than GLONG_CLOCK_SKEW_SECONDS ahead of the relay's clock. The first rule stops a rollback to a stale shift; the second stops one publish dated years ahead from freezing the stream for good.

Ceiling: TOFU cannot protect the first publish, and a retracted or pruned stream is claimable by whoever publishes next. Both are the same first-use race the protocol already accepts for pairing. When a stream ends up pinned to the wrong key, php artisan glong:unpin <streamId> releases it. Publishers should treat a 403 as an alert, not a retry.

Interoperating

If you are writing a publisher, the thing to get right first is the §9.4.7 test vector — a fixed pair secret, its derived writer key, a sample body and the expected signatures. Ed25519 is deterministic, so agreeing on the derivation and the signed bytes reproduces them byte for byte.

tests/Feature/Glong/WriterSignatureVectorTest.php asserts every literal in that section, so the spec and this implementation cannot drift apart quietly. If it fails, one of the two is wrong — never the vector.

Getting a derivation wrong is expensive to diagnose, because a wrong stream id is still a well-formed stream id. It 404s exactly like a correct id pointing at an empty mailbox. There is no error to surface. Check the vector before you check anything else.

What the relay refuses to trust

  • envelopeId is recomputed from the decoded ciphertext (blake2b(ciphertext, 16)) and must match what was sent — otherwise /head polling could be made to lie about change.
  • createdAt is monotonic per stream: an envelope older than the stored one is 409, so a captured publish cannot roll a reader back to a stale shift.
  • Envelope shape is validated (version, cipher, 24-byte nonce, 48-byte wrap, non-empty ciphertext). Nothing beyond shape — the relay has no key with which to judge the contents.

Zero-knowledge posture

  • Rate limiting keys clients by HMAC(ip, app.key), held in the cache only. No address is stored.
  • Errors render as {"message": ...} and nothing else, whatever APP_DEBUG says. Debug mode left on in a deployment must not be what decides whether anonymous callers see stack traces and server paths.
  • Envelopes are served with Cache-Control: no-store.
  • Request-recording tooling has no place here. Profilers and audit trails record bodies, addresses and headers, which is exactly the set §9.5 forbids retaining.
  • Access logging is a web-server concern: disable it, and strip X-Forwarded-For unless you set TRUSTED_PROXIES.

Setup

Requires PHP 8.5 with ext-sodium and ext-pdo, and Redis for the rate-limiter counters. SQLite by default; any database Laravel supports works.

git clone https://git.comotic.io/glong/relay.git && cd relay
composer setup     # install, .env, key, migrate
composer dev       # serve
composer test      # type coverage, tests, lint, static analysis

Configuration

Env Default Meaning
GLONG_MAX_ENVELOPE_BYTES 65536 Publish bodies above this get 413.
GLONG_RETENTION_DAYS 30 Streams not republished within the window are pruned.
GLONG_CLOCK_SKEW_SECONDS 300 How far ahead of the relay's clock a createdAt may sit.
GLONG_RATE_LIMIT_PER_MINUTE 120 Per-client read budget (readers poll /head).
GLONG_WRITE_STREAM_LIMIT_PER_MINUTE 5 Writes per minute to one stream.
GLONG_WRITE_SOURCE_LIMIT_PER_MINUTE 60 Per-client write burst, wide enough for a fan-out.
GLONG_WRITE_SOURCE_LIMIT_PER_DAY 500 Per-client write volume — this is what bounds storage.
TRUSTED_PROXIES (empty) Comma-separated IPs/CIDRs, or *.

Retention (§9.6) runs from the scheduler (model:prune, daily), so php artisan schedule:work — or a cron entry for it — must be running in production. Without it nothing reclaims storage at all. Pruning releases the writer-key pin along with the row, so a publisher quiet for longer than the window re-claims its own stream on the next PUT — as does anyone else who gets there first.

Deployment requirements

These are not optional; each one is a way to take the relay down.

  • TRUSTED_PROXIES must name your proxy when anything sits in front of PHP. Left empty, every request reports the proxy's address, so all clients share one rate-limit bucket and a single sender at the limit returns 429 to everybody. Never set it to * — that lets clients forge X-Forwarded-For and evade the limit entirely.
  • Cap request bodies at the proxy (client_max_body_size 64k in nginx). The relay rejects an oversized Content-Length before reading the body, but the proxy should stop it first.
  • APP_DEBUG=false in production. Errors render as JSON unconditionally, so debug mode would serve stack traces and paths to anyone.
  • Storage is bounded by the write budget, not by a quota. Writes are anonymous, so GLONG_WRITE_SOURCE_LIMIT_PER_DAY is what stops one client filling the disk: 500 writes at the 64 KiB envelope cap is about 32 MB/day per source address. A publisher republishes only when a shift changes — roughly ten times a day per friend — so the default leaves plenty of headroom. Distinct source addresses each get their own budget, so monitor storage regardless.

The rest of the system

Role Repository
Relay (this repo) glong/relay
Instance — scrapes and publishes glong/scrapper
Reader — the discovery app glong/reader
Driver's own roster app glong/driver

Contributing

The protocol spec is versioned with this repo and is the contract every implementation builds against. A change to the wire format is a change to glong-protocol.md first — including the version in its header — and to the code second. Sections marked as deliberate omissions are decisions, not gaps; reopening one is a conversation, not a patch.

composer test runs type coverage, the suite, lint and static analysis. It must be green.

License

MIT.