- PHP 100%
|
All checks were successful
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 |
||
|---|---|---|
| .forgejo/workflows | ||
| app | ||
| bootstrap | ||
| config | ||
| database | ||
| public | ||
| routes | ||
| storage | ||
| tests | ||
| .editorconfig | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| artisan | ||
| composer.json | ||
| composer.lock | ||
| glong-protocol.md | ||
| phpstan.neon | ||
| phpunit.xml | ||
| pint.json | ||
| README.md | ||
| rector.php | ||
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, 26–64 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).
401means the credentials are missing, malformed or do not verify;403means they verify but the stream belongs to another key. DELETEsigns"glong-retract-v1\n" || streamId || "\n" || envelopeId, so a captured retraction stops working as soon as the stream moves on.createdAtmust be strictly ahead of the stored envelope, and no more thanGLONG_CLOCK_SKEW_SECONDSahead 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
envelopeIdis recomputed from the decoded ciphertext (blake2b(ciphertext, 16)) and must match what was sent — otherwise/headpolling could be made to lie about change.createdAtis monotonic per stream: an envelope older than the stored one is409, 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, whateverAPP_DEBUGsays. 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-Forunless you setTRUSTED_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_PROXIESmust 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 returns429to everybody. Never set it to*— that lets clients forgeX-Forwarded-Forand evade the limit entirely.- Cap request bodies at the proxy (
client_max_body_size 64kin nginx). The relay rejects an oversizedContent-Lengthbefore reading the body, but the proxy should stop it first. APP_DEBUG=falsein 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_DAYis 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.