This document is the cross-repo contract between the server (this repo) and
any client (mobile app, or cmd/devclient — a reference implementation in
this repo) implementation. It covers: addressing, device
certificates/revocations, per-request signature authentication, the
identity/bootstrap REST surface, X3DH + Double Ratchet end-to-end
encryption, and the prekey/message REST surface that carries it.
Out of scope here (future milestones): broadcast, and the QR device-linking handshake itself (only its result — a signed device certificate — is consumed by this API). Federation (§9) and push notifications (§7) are both implemented. Groups (SRV-01) need no group-specific endpoint at all — a group is a client-side cryptographic object and its messages are ordinary envelopes — so what appears here is only the batch delivery their fan-out uses (§7, §9) and the client-to-client envelope versions they add (§6).
An account address is id*server (e.g. q2xjx*chat.example.org), where
server is the home server's address and id is a 21-character string
derived from the account's root Ed25519 public key. *local (or omitting
the *... part entirely) means "whatever server this is being resolved
against right now" — see §9 for when an explicit, different server is
required (a federated address) versus when it's just the caller's own.
- Compute
SHA-256(root_pubkey)(32 bytes). - Take the leading 9 bytes (72 bits) and convert to 5-bit groups MSB-first, keeping only the first 14 groups (70 bits) — i.e. discard the trailing 2 bits of the 9th byte.
- Prepend a 5-bit version marker group. The only currently defined version
is
0. This gives 15 payload groups (75 bits) total. - Compute a 6-group bech32m checksum (BIP-350: charset
qpzry9x8gf2tvdw0s3jn54khce6mua7l, XOR constant0x2bc830a3) over the 15 payload groups, using the fixed internal domain-separation tag"frz"— this tag is never part of the resulting string, it exists only so a Freizone ID can't collide with an unrelated bech32m string that happens to use a real human-readable prefix. - Map all 21 groups (15 payload + 6 checksum) through the bech32m charset.
The result is a plain 21-character string — no human-readable prefix, no
1 separator (unlike standard bech32/bech32m). For display, it may be
shown in 5-character dash-separated blocks (qk5x9-p2qan-7f3xy-zqeh8-m-style)
purely for readability; this is cosmetic and not part of the canonical form.
5 (not 4) is deliberate: the first character is always the version marker,
so a 5-character first group carries 4 real characters of entropy — see the
id-prefix uniqueness note below.
The ID is self-certifying: any party can recompute
hash(delivered_root_pubkey) == id themselves. No server can substitute a
different key without it being immediately detectable from the address
itself.
Normalization for comparison/lookup: lowercase, strip -/whitespace, verify
length (21) and bech32m checksum.
id-prefix uniqueness: a server also enforces that each account's first 5
characters (the version marker + 4 real characters of entropy, i.e.
32^4 = 1,048,576 possible values) are unique among its own accounts. This
doesn't change how ids are derived — an id is still always hash(root_pubkey)
— it just means POST /v1/accounts and POST /v1/bootstrap/claim reject a
freshly-derived id whose prefix collides with an existing account on that
server (409 id_prefix_taken), and the client is expected to generate a new
identity and retry. This makes the first displayed group usable as a short,
typeable, locally-unique lookup key for the full id, without weakening
self-certification or introducing a chosen/squattable handle.
Root Key (Ed25519, per account, generated once)
│ signs
▼
Device Certificate ──► Device Identity Key (Ed25519, per device)
│ signs (see §5)
▼
DH Identity Certificate ──► DH Identity Key (X25519, per device)
│ signs
▼
Signed Prekey Certificate ──► Signed Prekey (X25519, rotatable)
+ One-Time Prekeys (X25519, single-use, unsigned)
The root key never leaves the primary device and is never used to encrypt or to sign requests — only to sign device certificates and revocations. The device identity key is Ed25519 and is used for HTTP request signatures and for signing the device's own X3DH key material below — it is deliberately not reused as an X3DH Diffie-Hellman key (no XEdDSA-style conversion): a device holds a second, separate X25519 keypair for that, authenticated by its own certificate (§5).
Fields:
| Field | Type |
|---|---|
account_id |
string (the 21-char address id) |
device_id |
8 random bytes, hex-encoded (16 hex chars) |
device_pubkey |
Ed25519 public key, 32 bytes |
issued_at |
timestamp |
signature |
Ed25519 signature by the root private key |
Signing bytes (exact, deterministic binary encoding — not JSON, since JSON key ordering/whitespace is not a safe cross-implementation contract):
uint16BE(len(account_id)) || account_id (UTF-8 bytes)
|| device_id (8 raw bytes, decoded from hex)
|| device_pubkey (32 raw bytes)
|| uint16BE(len(issued_at_str)) || issued_at_str (UTF-8 bytes)
where issued_at_str is issued_at formatted as UTC RFC 3339
(2006-01-02T15:04:05Z07:00).
signature = Ed25519.Sign(root_private_key, signing_bytes).
Same pattern, over (account_id, device_id, revoked_at):
uint16BE(len(account_id)) || account_id
|| device_id (8 raw bytes)
|| uint16BE(len(revoked_at_str)) || revoked_at_str
Signed with the root private key.
Every authenticated API request is signed with the calling device's Ed25519 identity key — no passwords, sessions, or cookies. This is a simplified, custom canonicalization (not literal RFC 9421 compliance).
Headers:
| Header | Value |
|---|---|
Signature-Key-Id |
the device id (16 hex chars) |
Signature-Timestamp |
Unix seconds, decimal string |
Signature-Nonce |
client-random string, unique per (device, request) |
Signature |
base64 (standard encoding) of the 64-byte Ed25519 signature |
Canonical string (newline-joined, no trailing newline):
{METHOD}\n{url_path}\n{raw_query}\n{Signature-Timestamp}\n{Signature-Nonce}\n{Signature-Key-Id}\n{lowercase_hex(sha256(body))}
METHODis the uppercase HTTP method (POST,GET, ...).url_pathis the request path only (no scheme/host/query), e.g./v1/devices.raw_queryis the raw query string with no leading?(empty string if none).bodyis the exact bytes of the request body (empty-body requests hash the empty byte string).
signature = base64(Ed25519.Sign(device_private_key, canonical_string_utf8_bytes)).
Server-side verification: look up the device by Signature-Key-Id (must be
active), check |now - Signature-Timestamp| <= 5 minutes, verify the
signature, and reject replayed (device_id, nonce) pairs. Every failure
mode returns the same generic 401 — unknown device, bad signature,
expired timestamp, replayed nonce, or revoked device are not distinguished
in the response, to avoid giving an attacker an oracle.
A few endpoints authenticate a caller that has no local device row to look
up: a cross-server federation sender (§9), or an account being recovered from
its seed phrase when no device survives (POST /v1/accounts/{id}/recover, §4).
These reuse the exact same canonical string and headers, with two
differences:
Signature-Key-Idis the base64-encoded public key that signed the request, not a device id. The endpoint verifies the signature against that very key and then, separately, establishes that the key is authorized (a device cert chained to the sender's root key for federation; equality with the account's storedroot_pubkeyfor recovery — i.e. the request is signed by the account's root key, its ultimate authority, which already signs every device cert and revocation, see §2).- The nonce is recorded in the persistent
used_noncesstore rather than the device-auth in-memory cache, but the 5-minute skew window and generic-401 rule are identical.
The canonical string ends in sha256(body), which normally means the server
must buffer the whole request before it can authenticate it. That is fine for
a chat message and unacceptable for a multi-megabyte blob upload (§10), so
those routes — and only those — take the body hash from a header instead:
| Header | Value |
|---|---|
Blob-Digest |
sha256=<lowercase hex> (a bare hex digest is also accepted) |
The client signs exactly the same canonical string, with that digest as its last field. The server then:
- verifies the signature from the headers alone, before reading any body — so a forged or unauthorized upload costs no disk at all;
- streams the body to storage while hashing it, and rejects it with
400 digest_mismatch(discarding what it wrote) if the bytes do not match the signed digest.
This keeps the body cryptographically bound to the signature: a caller can
only store bytes it actually signed for. The variant applies to the two blob
upload routes and nowhere else: POST /v1/blobs has it enabled per route in
the authentication middleware, and POST /v1/federation/blobs — which is not
behind that middleware, since a federated sender has no device row to look up
(§9) — performs the equivalent check inline in its handler. On every other
endpoint a Blob-Digest header is ignored and the body itself is hashed, so
it can never be used to substitute an unsigned body elsewhere.
All paths are under /v1/. All bodies/responses are JSON. Byte fields
(root_pubkey, device_pubkey, signature, etc.) are base64 (standard
encoding). Error responses: {"error":{"code":"...","message":"..."}}.
No auth. 200 {"status":"ok"}, or 503 if the database is unavailable.
No auth (gated by the one-time setup token printed to the server's log on first boot). Claims the first admin account.
The setup token is 8 symbols from Crockford's Base32 alphabet
(0123456789ABCDEFGHJKMNPQRSTVWXYZ -- excludes I, L, O, U to avoid
transcription errors), 40 bits, printed dash-grouped (ABCD-1234) for
readability. Dashes and case are cosmetic: the server strips
separators/whitespace and uppercases before comparing, so abcd-1234 and
ABCD1234 are equivalent. Deliberately short enough to type by hand into a
phone without needing a QR code.
Unlike per-request signatures, this endpoint has no other rate limiting, so
the token's safety against online guessing comes from a lockout, not raw
entropy: after 10 failed claim attempts the token is permanently rejected
(even a subsequently-correct guess), and the operator must restart the
server with --reset-setup-token to generate a fresh one.
Request:
{
"setup_token": "...",
"root_pubkey": "base64...",
"device_id": "16hexchars",
"device_pubkey": "base64...",
"device_cert_issued_at": "2026-07-17T12:00:00Z",
"device_cert_signature": "base64..."
}201 with an account response (see below) on success.
401 invalid, already-used, or locked-out token · 400 invalid certificate ·
409 id_prefix_taken (see §1's id-prefix uniqueness note) -- retry with a
freshly generated identity. There is no "an admin already exists" check —
claiming with a valid, unused setup token always succeeds regardless of how
many admins already exist; minting a fresh token (--reset-setup-token /
--reset-admin) is the intended way to add an additional or replacement
admin.
No auth (registration policy-gated: open / invite / closed). Same
certificate-bearing shape as bootstrap, plus an optional invite_code:
{
"root_pubkey": "base64...",
"device_id": "16hexchars",
"device_pubkey": "base64...",
"device_cert_issued_at": "2026-07-17T12:00:00Z",
"device_cert_signature": "base64...",
"invite_code": "optional, required under the invite policy"
}201 account response · 403 registration closed / invite code required ·
404 unknown invite code · 410 invite code expired or already used ·
409 account or device id collision, or id_prefix_taken (see §1's
id-prefix uniqueness note) -- retry with a freshly generated identity.
Recover an existing account after total device loss (SRV-06; client
companion APP-01). Neither of the normal paths works here: POST /v1/devices
needs an already-active device to sign the request, and POST /v1/accounts
rejects an existing account (409 account_exists). Since
account_id == hash(root_pubkey), restoring the root key from the seed phrase
restores the same account, and this endpoint lets that restored key mint a new
device without any surviving device.
Authenticated inline by a root-key per-request signature (§3's
self-describing-key variant): Signature-Key-Id is base64(root_pubkey) and
must equal the target account's stored root_pubkey; the signature is verified
against it. The body carries a new device certificate signed by that same root
key (identical fields to POST /v1/accounts, minus root_pubkey/invite_code
— the account is named by the path {id}):
{
"device_id": "16hexchars",
"device_pubkey": "base64...",
"device_cert_issued_at": "2026-07-26T12:00:00Z",
"device_cert_signature": "base64..."
}On success the new device is created active and every other device on the
account is revoked in the same step (total loss is the premise; old devices
are assumed gone or compromised). 201 account response (with the new device
active and the rest revoked) · 400 malformed body or invalid device
certificate · 401 root signature invalid / stale / replayed (generic, per
§3) · 403 account not active · 404 unknown account · 409 device id
collision (a repeat recovery must use a fresh device id). Chat history is not
restored — the server keeps none; peers re-establish ratchet sessions against
the new device (see §5 / SRV-03).
No auth — lets a client decide which setup path applies before it has any
identity: bootstrap (no admin claimed yet), self-register (open policy),
invite-code registration, or "closed" -- registration fully blocked, not
even with an invite code (the invite-code check in POST /v1/accounts is
never reached while the policy is closed).
200:
{
"claimed": true,
"registration_policy": "open",
"federation_enabled": true,
"blobs_enabled": true,
"max_blob_bytes": 8388608,
"max_blob_recipients": 100,
"batch_messages": true,
"max_batch_messages": 100,
"attestation": "opaque-token..."
}claimed is whether the one-time setup token has already been used
(i.e. an admin exists) — not sensitive, same trust level as the
registration policy itself, which has to be knowable before someone can
register at all. federation_enabled reflects whether this server accepts
inbound federation (§9); it is public because clients rely on it to decide
whether their own users may reach other servers at all — with it off, a
peer's replies would be blocked inbound, so an honest client won't start or
send into such a cross-server conversation (older servers omit the field;
clients treat its absence as true).
blobs_enabled and max_blob_bytes describe this server's attachment
transport (§10), so a sender can size an attachment to what the recipient's
server will actually take instead of discovering the limit as a 413
mid-upload. Note the absence rule is the opposite of federation_enabled's:
a server that omits blobs_enabled predates §10 and has no blob endpoints at
all, so clients must treat its absence as off, not on. max_blob_bytes
absent or 0 means "no limit stated" — an oversized upload then still fails
server-side, as it would have anyway.
max_blob_recipients is how many recipients one upload may name (§10), and
its absence means 1, not "unlimited": an older server reads only the first
recipient_device_id, stores the blob for that one device and still answers
201, so a sender that assumed otherwise would silently deliver a group
picture to a single member. A sender reading 1 — stated or absent — falls
back to one upload per recipient on that server. Discovered per server, for
the same reason batch_messages is.
batch_messages and max_batch_messages describe batch delivery (§7), used
by group fan-out. The absence rule matches blobs_enabled's: a server that
omits it predates the endpoints and has none, so absence means off and the
sender falls back to one POST /v1/messages per recipient. Because a
federated group's members sit on servers that will not upgrade together, this
is discovered per server, not once — a sender may legitimately batch to one
member's server and post individually to another's in the same fan-out.
attestation (SRV-19), when present, is an opaque token this server was
configured with and serves back verbatim without interpreting it itself —
absence means no attestation, which is the ordinary case for the overwhelming
majority of servers and never implies anything is wrong. A client decodes and
verifies it against a small set of issuer public keys it was built with; none
of that verification touches this endpoint or any other network request, so
an older client that does not recognize the field simply ignores it, and a
client that does recognize it needs nothing further from this server to
confirm what the token claims.
No auth — a public key directory, analogous to a keyserver. 200:
{
"id": "k5x9p2qan7f3xyzqeh8m1",
"root_pubkey": "base64...",
"devices": [
{
"device_id": "16hexchars",
"device_pubkey": "base64...",
"issued_at": "2026-07-17T12:00:00Z",
"signature": "base64...",
"status": "active",
"revoked_at": null
}
]
}signature is the device certificate's signature (§2) — include it so a
client can verify the full self-certifying chain itself
(hash(root_pubkey) == id, then Ed25519.Verify(root_pubkey, device signing bytes, signature)) instead of trusting the server's word for which
devices belong to an account. Both active and revoked devices are listed
(with their status). {id} also accepts the shorter, unchecksummed
PrefixLength (5-character) form as an alias for the full id (see §1's
id-prefix uniqueness note) -- either way, the response's own "id" is
always the true full id, which is what a caller must actually verify
against root_pubkey, never the shorthand it looked up with. 404 if
{id} is unknown or fails address normalization (full form) / charset
validation (prefix form).
Permanently deletes the caller's own account, cascading (via FK) through
its devices to their prekeys and queued messages, and through invite
codes it issued (deleted) or used (used_by_account_id cleared) — the
same cascade as the admin delete (§4's server admin endpoints), just
self-service rather than requiring admin privileges. {id} is checked
against the signing device's own account as defense in depth, but the
actual target is always the identity the request's signature already
established, never {id} taken at face value — a request signed by
account A can never delete account B, no matter what {id} names.
Irreversible: a deleted account can never be recreated with the same id
on this or any other server, since a new registration under the same
root key would collide with (or rather, no longer exist to collide with,
but the private key itself isn't gone anywhere) nothing — there's simply
no path back to the same identity.
A chat partner who writes to a deleted account afterward gets an
immediate 404 unknown_recipient (their client has no device to deliver to,
since the cascade already removed it) — not a silently growing queue.
Messages already sent to others before deletion, if not yet fetched,
are unaffected: they remain deliverable, same as already-sent mail isn't
recalled by deleting the sender's mailbox.
200 {"status":"ok"} · 403 {id} does not match the signing device's
own account · 404 unknown account · 409 last_admin deleting the
server's only remaining admin (same guard as the admin delete).
No auth — this server's VAPID public key (RFC 8292), not secret. Clients
pass this to their UnifiedPush distributor at registration time (some
distributors reject registration without one); it identifies which
application server may push to that subscription. 200:
{ "key": "base64url-encoded P-256 public key" }Adds a device to an account. Must be signed by a device already active on that account. Body carries a new device certificate pre-signed by the account's root key:
{
"account_id": "k5x9p2qan7f3xyzqeh8m1",
"device_id": "16hexchars",
"device_pubkey": "base64...",
"issued_at": "2026-07-17T12:00:00Z",
"signature": "base64..."
}201 device response · 403 the signing device's account doesn't match
account_id · 400 invalid certificate · 404 unknown account · 409
device id collision.
Revokes a device. Must be signed by a device already active on the account. Body carries a root-key-signed revocation record:
{
"account_id": "k5x9p2qan7f3xyzqeh8m1",
"device_id": "16hexchars",
"revoked_at": "2026-07-17T12:00:00Z",
"signature": "base64..."
}{device_id} in the path must match the body. 200 {"status":"revoked"} · 400 path/body mismatch or invalid revocation
signature · 403 account mismatch · 404 unknown account/device or
already revoked.
Registers, or (with an empty body) clears, this device's push subscription
— see §7's note on push under POST /v1/messages. This is a standard Web
Push subscription (the same shape browsers hand you from
PushManager.subscribe()): p256dh/auth are the device's own ECDH
public key and auth secret, which the server uses to RFC 8291-encrypt the
(content-free) wake payload it sends to endpoint.
{
"endpoint": "https://distributor.example/wake/abc123",
"p256dh": "base64url-encoded uncompressed P-256 point",
"auth": "base64url-encoded 16-byte secret"
}All three fields must be given together, or all omitted/null to
unregister. endpoint must be an https:// URL. 200 {"status":"ok"} ·
400 missing/partial/non-https fields · 403 path device_id isn't the
signing device · 404 unknown device.
Registers, or (with an empty body) clears, this device's FCM/APNs push
target — the counterpart to push-endpoint above for devices delivered
through a freizone-gateway
instance instead of UnifiedPush/Web Push. platform is "fcm" or
"apns"; token is that platform's own addressing token (an FCM
registration token, or an APNs device token).
{
"platform": "fcm",
"token": "the platform's own registration/device token"
}Both fields must be given together, or both omitted/null to unregister.
Registering a push target clears any existing push subscription (and
vice versa) — a device uses exactly one wake mechanism at a time. 200 {"status":"ok"} · 400 missing/partial fields or unknown platform ·
403 path device_id isn't the signing device · 404 unknown device.
Issues a single-use invite code (for the invite registration policy) —
typically rendered by the app as a QR code (see §8) for the caller to
hand out, but short enough to read aloud or write down.
{ "expires_at": "optional RFC3339; omitted means the server's default expiry" }201:
{ "code": "ABCD-EFGH-JKMN", "expires_at": "optional" }403 if the caller is neither admin nor moderator.
Code format. 12 symbols of Crockford Base32 — the alphabet
0123456789ABCDEFGHJKMNPQRSTVWXYZ, which omits I, L, O and U —
returned grouped in fours for legibility. That is 60 bits of entropy.
A redeemed code is normalized before comparison, so a client may send whatever the user actually typed and need not clean it up first:
- case is ignored;
-,_, spaces, tabs and newlines are stripped, so the grouped display form and the compact form a QR carries are the same code;IandLare read as1, andOas0— unambiguous precisely because the alphabet cannot produce those letters, so encountering one can only mean a misread digit.Uis left alone: nothing it plausibly stands for, so it simply fails to match rather than being rewritten.
The server stores only a SHA-256 hash of the normalized code, as it does for the setup token (§3) — a leaked database yields no working invites. Nothing needs the plaintext after this response, since there is deliberately no endpoint that lists codes; the flip side is that a lost code cannot be shown again and must be reissued.
What is retained. A code that expires without being redeemed is deleted
by a periodic sweep: it can never be accepted again, so the row is dead
weight. A redeemed code's row is kept, and with it created_by and
used_by — i.e. which account issued the invite and which account joined
with it. That pairing is retained deliberately, as the one piece of
moderation history this server keeps ("who let that account in?"); it is
never exposed through any endpoint, so only the operator can see it. Deleting
either account clears its side (the issuer's invites cascade away, a joiner's
reference is set to null).
Why 12 symbols and not the setup token's 8. The token gets away with 40
bits because it is a singleton protected by a lockout counter
(MaxSetupTokenAttempts). Neither applies here: many invite codes are
outstanding at once and any unused one grants registration, so a guesser
need not target a particular code, and a failed guess identifies no code to
lock out. The length therefore has to do the work the token's lockout does.
Codes also carry a default expiry (FREIZONE_INVITE_EXPIRY_DAYS, 14 days),
because an unbounded window is what would make guessing worth attempting at
all.
Moderators get read-only visibility plus invite creation: the account list,
the current registration policy, the federation switch (all GET), and
POST /v1/admin/invites (documented above). They may additionally
block/unblock a regular member — the one account-changing action they
get, so moderating a server does not require handing out admin. Everything
else that changes an account or the server — role changes, deleting, setting
the registration policy, toggling federation — stays admin-only, so
privilege escalation and account removal can never come from a moderator.
A moderator's block is confined to accounts whose role is user; targeting
another moderator or an admin is 403. Blocking is removal by another name
(a disabled account cannot make a single authenticated request), so without
that limit a moderator would hold over staff exactly the power this model
reserves for admins — and the last_admin guard below is no substitute,
since it only refuses the final admin.
Every one of these (except GET) that targets an admin account rejects
the change with 409 last_admin if it would leave the server with zero
active admins.
-
GET /v1/admin/accounts(signed, admin or moderator) — the full account list, each entry carrying the activity signals that distinguish an account in use from an abandoned one.200:[{ "id": "...", "role": "user|moderator|admin", "status": "active|disabled", "created_at": "...", "pending_messages": 3, "oldest_pending_at": "...", "blob_count": 2, "blob_bytes": 3355443, "blob_bytes_limit": 268435456, "device_count": 2, "invited_by": "..." }]The counts are summed across the account's devices.
oldest_pending_atis omitted when the queue is empty (there is no such timestamp); every other field is always present, zero rather than absent, so a client can tell "nothing queued" from an older server that doesn't report this at all.blob_bytes_limitis the per-device quota (FREIZONE_MAX_BLOB_BYTES_PER_DEVICE) timesdevice_count, because that is where the limit is actually enforced — it is therefore 0 for an account with no devices, which means "no meaningful limit", not "a limit of nothing", and it moves as devices are added or revoked. Revoked devices count: a revoked device keeps its blobs until its row is deleted, so excluding it could put usage above its own limit.These are aggregates only — how much is waiting and how much is stored, never who an account talks to or what it sends. Moderators see them along with the rest of the list; the whole point is being able to clean up a server without being an admin.
invited_byis the exception: the account that issued the invite this one joined with, and sent to admins only — a moderator's response omits the field entirely, and the server doesn't even look it up for them. It is the one account-to-account link this server holds, which is a different kind of thing from a queue length. Omitted whenever there is nothing to say, and a client must read that as "not known here" rather than "registered openly": the field is equally absent for an account that needed no invite and for one whose inviter has since been deleted, since the invite row cascades with its creator. -
POST /v1/admin/accounts/{id}/role(signed, admin only) —{"role": "user|moderator|admin"}.200 {"status":"ok"}·400invalid role ·404unknown account ·409 last_admindemoting the server's only remaining admin. -
POST /v1/admin/accounts/{id}/block/.../unblock(signed, admin, or moderator targeting auser) — no body. Blocking sets the accountdisabled, whichinternal/auth's middleware then rejects on every subsequent request from that account, from any of its devices.200 {"status":"ok"}·403a moderator targeting a moderator or an admin ·404unknown account ·409 last_adminblocking the server's only remaining admin. -
DELETE /v1/admin/accounts/{id}(signed, admin only) — permanently removes the account, cascading through its devices to their prekeys/queued messages and any invite codes it issued.200 {"status":"ok"}·404unknown account ·409 last_admindeleting the server's only remaining admin. -
GET /v1/admin/registration-policy(signed, admin or moderator) /PUT /v1/admin/registration-policy(signed, admin only) —{"policy": "open|invite|closed"}in both directions. This is the runtime policy (see README.md's config reference):FREIZONE_REGISTRATION_POLICYonly seeds it on first boot, this endpoint is what actually governs it afterwards, and the change persists across restarts.200 {"policy": "..."}·400invalid policy value. -
GET /v1/admin/federation(signed, admin or moderator) /PUT /v1/admin/federation(signed, admin only) —{"enabled": true|false}in both directions. Turns inbound federation (§9) on/off at runtime, mirroring the registration-policy endpoint:FREIZONE_FEDERATION_ENABLEDonly seeds it on first boot, this endpoint governs it afterwards, and the change persists across restarts and is reflected inGET /v1/server-status'sfederation_enabled.200 {"enabled": ...}. -
GET /v1/admin/license(signed, admin only) — this server's active account count against its own attestation's advisoryseatsceiling (SRV-19,pkg/attest'sSeats). Deliberately not onGET /v1/server-statusor the landing page: how many accounts a server has is exactly the kind of fact that turns "a server exists" into "a server worth attacking", and an admin, not a random visitor, is who needs to know it's time to renew.200:{ "attested": true, "tier": "commercial", "seats": 500, "expires_at": "2027-08-05T00:00:00Z", "active_accounts": 214, "over_limit": false }attestedisfalsewhenever there is nothing usable to report — no attestation configured, or one that fails to decode, verify, or falls outside its validity window — in which casetier,seats, andexpires_atare all omitted;active_accountsandover_limitare still meaningful on their own.seatsis omitted for0("unspecified or unlimited"), in which caseover_limitis alwaysfalse— there is no ceiling to compare against. Not a technical limit anywhere in this protocol: nothing rejects a registration or blocks an account because of it, the same way an expired attestation only warns rather than refusing to boot. -
GET /v1/admin/stats(signed, admin only) — this server's current size and load: accounts, devices, stored attachments and their disk usage, queued messages, federation status. Deliberately not onGET /v1/server-statusor the landing page, for the same reasonGET /v1/admin/licenseisn't — usage figures are attack-surface information, not something an unauthenticated caller needs.200:{ "captured_at": "2026-08-13T20:46:42Z", "account_count": 214, "active_account_count": 209, "device_count": 331, "blob_count": 1842, "blob_bytes": 512000000, "db_bytes": 41943040, "pending_message_count": 12, "disk_free_bytes": 21474836480, "disk_total_bytes": 107374182400, "federation_enabled": true, "federation_blocklist_count": 2 }disk_free_bytes/disk_total_bytesare both0when the server's host platform has no way to report them (internal/diskstatonly covers Linux/Darwin) — read that as "unknown", not "completely full".The response also carries a
forecastobject (omitted, rather than sent empty, by a server that does not compute one) saying how the stored attachments will drain and where they settle:{ "retention_days": 14, "inflow_window_days": 7, "inflow_bytes_per_day": 8400000, "equilibrium_bytes": 117600000, "drain": [{"at": "2026-08-14T20:46:42Z", "bytes": 512000000}, "..."], "with_inflow": [{"at": "2026-08-14T20:46:42Z", "bytes": 512000000}, "..."] }drainis arithmetic on facts rather than a prediction: every blob carries its ownexpires_at, fixed at upload and never extended (§10), so what is stored today has a known decay. It is an upper bound — a recipient releasing its claim after fetching makes the real curve fall faster — and it reaches0one day pastretention_days.with_inflowadds uploads continuing at the measuredinflow_bytes_per_day; those live one retention window too, so it flattens out onequilibrium_bytes. That figure is the useful answer to "will this server run out of room", because a fixed retention window means storage cannot grow without limit: it converges on inflow × window, whatever the current total happens to be.inflow_bytes_per_dayis measured, not guessed — the ciphertext actually stored overinflow_window_days, which is deliberately kept shorter than the retention period so nothing inside the window can have expired yet. Both series start at the same instant and value the enclosing response reports, so a chart can join its measured line to them without a step; that is also why the forecast rides in this response instead of a route of its own. -
GET /v1/admin/stats/history?days=N(signed, admin only) — everyGET /v1/admin/stats-shaped snapshot recorded in the lastNdays (default 90, capped at the 2-year retention window a background ticker prunes to), oldest first, for a growth chart (is storage climbing, are registrations climbing). A snapshot is taken four times a day, every six hours (plus one immediately on server start) — this is a history of point-in-time measurements, not a log of every change, and the current-account/blob tables it's computed from get rows deleted (blocked accounts, expired blobs) that this history alone still remembers.200: a JSON array of the same object shape asGET /v1/admin/stats.
Uploads/replaces a device's X3DH key material. dh_identity_cert is
required on the very first upload (to establish the device's long-term DH
identity key), optional afterwards (include it again only to rotate that
key). signed_prekey is always required and replaces the previous one.
one_time_prekeys appends to the pool (existing, unclaimed ones aren't
touched — this is how a device replenishes its supply).
{
"dh_identity_cert": {
"dh_pubkey": "base64 X25519, 32 bytes",
"issued_at": "2026-07-17T12:00:00Z",
"signature": "base64 Ed25519, by the device's own signing key"
},
"signed_prekey": {
"key_id": 1,
"dh_identity_pubkey": "base64, must match the device's dh identity key",
"pubkey": "base64 X25519, 32 bytes",
"issued_at": "2026-07-17T12:00:00Z",
"signature": "base64 Ed25519, by the device's own signing key"
},
"one_time_prekeys": [
{ "key_id": 101, "pubkey": "base64 X25519, 32 bytes" }
]
}200 {"status":"ok"} · 403 wrong device · 400 invalid/mismatched
certificate, or no dh_identity_cert on a device's first-ever upload ·
404 unknown device.
The bundle itself is public, like the account directory: no trust in the server is required, only in the certificate chain the caller verifies itself. The one-time prekey inside it is not public, because it is a consumable — see "Claimant authentication" below. Atomically removes one one-time prekey from the pool (if the claimant is entitled to one and any remain) and returns it — each one-time prekey is handed out at most once.
{
"device_id": "16hexchars",
"dh_identity_pubkey": "base64...",
"dh_identity_cert": { "dh_pubkey": "base64...", "issued_at": "...", "signature": "base64..." },
"signed_prekey": { "key_id": 1, "dh_identity_pubkey": "base64...", "pubkey": "base64...", "issued_at": "...", "signature": "base64..." },
"one_time_prekey": { "key_id": 101, "pubkey": "base64..." }
}one_time_prekey is omitted whenever none was handed out — X3DH proceeds
without it (§5), with reduced forward secrecy for that first message only.
one_time_prekey_omitted then says which reason applied, "pool_empty" or
"unauthenticated", and is itself absent when a key was handed out. It is
purely diagnostic: the two cases are otherwise indistinguishable, and a client
should be able to notice that its own credentials were not accepted rather than
mistake it for a drained peer.
Three conditions answer 404, under distinct codes so a caller can react
mechanically rather than parse prose: unknown_device (no such device id on
this server), no_prekey_bundle (the device exists but is revoked or has
never uploaded prekeys), and federation_disabled (the claim carried a
federated identity but this server has federation switched off — see
"Claimant authentication" below).
Stale-device rule. To a sender, unknown_device and no_prekey_bundle
both mean the same thing: the device id it holds for this peer is dead.
Cached device ids go stale legitimately — a device gets revoked (§2), or an
account is deleted and re-created from the same root key's seed, which
cascades every old device row away entirely — and nothing pushes that fact
to other servers (§9's known revocation gap). The specified reaction is:
discard the cached device id, re-fetch GET /v1/accounts/{id} on the
peer's home server, verify the chain and adopt the active device it names,
and retry once. A second failure is a real error to surface, not to
retry-loop. federation_disabled must not trigger this — the device may
be perfectly fine behind a switch the operator flipped, and discarding
working state over it would force a needless re-key when the switch comes
back. A message send answering unknown_recipient (§7) is the
queueing-time form of the same discovery and warrants the same reaction —
plus discarding the ratchet session, which is bound to the dead device.
If this claim leaves the pool below a low-water mark and the device has no live SSE stream open, the server fires a push wake (see "Push notifications" below) so a rarely-opened device gets a chance to replenish before the pool actually runs dry — which, since only an entitled claimant consumes a key, is no longer something an arbitrary caller can trigger on demand.
Three forms, in this order:
- A body carrying the claimant's own identity — for a claimant whose
account lives on another server, which has no local row to authenticate
against. Verified inline exactly as federated message delivery does (§9):
signed with the self-describing-key variant (§3):
{ "sender_account_id": "...", "sender_root_pub_key": "base64 Ed25519", "sender_device_cert": { "device_id": "...", "device_pub_key": "base64...", "issued_at": "...", "signature": "base64..." } }Signature-Key-Idis the base64 device public key named in the certificate. Subject to the same rules as a federated message:404 federation_disabledwhen this server has federation switched off,403for a sender on the federation blocklist,400for a certificate that does not verify under the claimed root key. - An ordinary §3 device signature, no body — for a claimant registered on
this server. Rejected with
401like any other signed route if it does not verify. - Neither — an anonymous claim. Still answered
200with the full bundle, but without a one-time prekey.
Credentials that are present but invalid are always refused, never quietly treated as anonymous: a downgrade there would turn a client bug or a skewed clock into a silent loss of forward secrecy that nothing reports.
Why the one-time prekey is gated. It is single-use, so a caller that can claim repeatedly can drain a device's whole pool — after which every new session with that device starts without one until it next tops up. Requiring an identity does not prevent that outright, but it makes it attributable and bounded by the cost of holding an account. Confidentiality never depended on this: the pool being empty is a normal state that X3DH is defined to handle.
This is a deliberately compatible way to close it. A client that predates the rule sends no credentials, gets the bundle without a one-time prekey, and proceeds down the same path an empty pool already produced — so it keeps working at exactly the forward-secrecy level it had before, while the pool it might have drained is now protected. Requiring authentication outright would instead have stopped every deployed client from starting new conversations.
Non-destructive counterpart to the claim endpoint above — reports the pool size without consuming a key, so a device can decide whether to top up.
{ "one_time_prekeys_remaining": 7 }200 · 403 wrong device · 401 unauthenticated.
Same deterministic length-prefixed binary signing pattern as the Device Certificate (§2), but signed with the device's own Ed25519 private key (not the root key) — a device already certified by the root is vouching for its own X3DH material.
DH Identity Certificate, over (account_id, device_id, dh_pubkey, issued_at):
uint16BE(len(account_id)) || account_id
|| device_id (8 raw bytes)
|| dh_pubkey (32 raw bytes, X25519)
|| uint16BE(len(issued_at_str)) || issued_at_str
Signed Prekey Certificate, over (account_id, device_id, key_id, dh_identity_pubkey, prekey_pubkey, issued_at)
— binding the prekey to a specific DH identity key is what stops the
signature being replayed against a substituted identity key:
uint16BE(len(account_id)) || account_id
|| device_id (8 raw bytes)
|| uint32BE(key_id)
|| dh_identity_pubkey (32 raw bytes)
|| prekey_pubkey (32 raw bytes)
|| uint16BE(len(issued_at_str)) || issued_at_str
One-time prekeys are not individually signed (matches the X3DH spec — their authenticity comes from being fetched as part of the same server-side bundle tied to an already-verified device).
Client-side only — the server never sees plaintext, key material beyond
public keys/certificates, or ratchet state. Implemented in
pkg/ratchet (public, so other Go modules — e.g. the mobile app's shared
core — can import it directly instead of re-implementing it), following
the X3DH spec and
the Double Ratchet spec
with these concrete choices:
- Curve: X25519 throughout (
crypto/ecdhin Go). - X3DH SK derivation: HKDF-SHA256,
IKM = 0xFF×32 || DH1 || DH2 || DH3 [|| DH4], salt = 32 zero bytes, info ="Freizone-X3DH-v1"→ 32-byte SK. - Session AD:
Encode(initiator's DH identity pubkey) || Encode(responder's), fixed for the life of the session by role (whoever sent the first "prekey" message is the initiator) — never swapped based on who's currently sending. - Double Ratchet KDF_RK: HKDF-SHA256(salt=current RK, ikm=DH output,
info=
"Freizone-DR-RK-v1") → 64 bytes → new RK (32) + new chain key (32). - Double Ratchet KDF_CK: HMAC-SHA256(key=chain key, msg=
0x01) → message key; HMAC-SHA256(key=chain key, msg=0x02) → next chain key. - Message encryption: AES-256-GCM. Per message,
HKDF-SHA256(ikm=message key, info=
"Freizone-DR-msg-v1", 44 bytes) → a 32-byte AES key + 12-byte nonce (safe here specifically because each message key is used exactly once). AEAD associated data = session AD || header bytes (dh_pub(32) || pn(uint32BE) || n(uint32BE)). - Bootstrap: the initiator generates a fresh X25519 keypair for its first ratchet step (never reuses its X3DH ephemeral key) and ratchets forward immediately using the responder's signed-prekey public key. The responder reuses its signed-prekey keypair as its initial ratchet keypair and only generates a fresh one once it processes the initiator's first message.
One-time prekey pool exhaustion (SRV-04, closed): a prekey-bundle claim
used to be entirely unauthenticated, so anyone could drain a device's
one-time-prekey pool. A claimant is now identified before it may consume one —
either as a locally registered device or, from another server, by the same
inline certificate chain federated delivery uses. See §4's prekey-bundle
entry for the three accepted forms and why an anonymous claim still succeeds
without a key.
Session recovery / re-key (client behavior): a prekey block is normally
only meaningful when the recipient has no existing session with that sender
(first contact). A client MAY also accept one when a session already
exists — e.g. the sender reset their local session after a ratchet
desync — but only as a proposal: it must attempt a fresh responder
establishment from it and adopt the resulting session only if it actually
decrypts the accompanying message, falling back to the existing session
otherwise (a stale/redelivered prekey block must not be able to disrupt a
still-healthy session). This is safe because responder establishment always
re-verifies the sender's full self-certifying cert chain (§2, §5) — accepting
a re-key is not a new trust decision, just a repeat of the same one. See
freizone-app's AppSession.resetSecureSession / processIncomingMessage for
a reference implementation.
Simultaneous establishment (client behavior). Two parties can claim each other's prekey bundle at the same moment, and then each holds its own initiator session for the pair while neither can read the other's. Between two people chatting this is rare, because somebody speaks first. In a group (§12) it is routine: a joining member establishes a session with every existing member at once, and those members reach back toward them in the same breath, so most new pairs in a group start out doubled.
A prekey block arriving over an existing session would otherwise be
ambiguous: it is either a peer who deliberately discarded their session (the
re-key above) or a peer who simply established one at the same moment we did.
The two need opposite handling, so the sender says which it is in the block
itself:
"prekey": { "sender_dh_identity_pub": "...", "sender_ephemeral_pub": "...",
"signed_prekey_id": 1, "rekey": false }rekey is a tri-state, and all three states mean different things:
| value | meaning |
|---|---|
true |
The sender discarded their session and re-established. Theirs is the only session they can read. |
false |
An ordinary establishment. If the receiver holds a session too, the two established simultaneously. |
| absent | The sender predates this field and says nothing. The receiver infers it from the decrypted content. |
A sender that knows the answer MUST state it, including false — "no" and "said
nothing" are different facts, and only distinguishing them lets the inference in
step 2b below ever be dropped. The field is not covered by any signature (there
is none over the prekey block); a tampered value can misdirect the handling of
one establishment, which the ratchet recovers from, and cannot make anything
decrypt that otherwise would not.
The receiver then:
- Builds the responder session and tries to decrypt the accompanying message with it. If it does not decrypt, this is a stale or redelivered block — keep the existing session and change nothing.
- Decides whether this is a deliberate re-key:
- 2a.
rekeypresent — take it at its word,falseincluded. - 2b.
rekeyabsent — infer, as clients did before the field existed: av: 3re-key signal in the plaintext means deliberate, anything else means simultaneous.
- 2a.
- If deliberate: adopt the new session unconditionally. The ordering rule below must not apply — the session it would tell us to keep is one the peer can no longer read.
- Otherwise, before reaching for the ordering rule: has this side actually
decrypted anything on the session it is currently holding for this peer? If
so, adopt the new one unconditionally too, regardless of
account_idordering. Arekey: falseblock only means "not deliberate from the sender's own point of view" — a sender that lost its session state (a reset that predates this field, a reinstall, a device migrated onto storage that never carried sessions across, e.g. SRV-23's shared-core cut) has no idea anything is wrong and says exactly the samefalsea genuine racer would. A session with confirmed inbound traffic settles which case this is: a real simultaneous establishment is two sides starting from nothing, so neither has decrypted anything on either of the sessions currently competing — that is what step 5 below is actually for. A session that has already had something decrypted on it is not that; it is a working, mutually-established conversation, and the peer just proved — by sending an envelope this side cannot read — that its copy of it is gone. Keeping this side's stale session because itsaccount_idhappens to sort lower strands every future message in both directions: the peer cannot read this side's old session, this side cannot read the peer's new one, and neither the desync accounting nor a manual reset by either party changes that, because the peer's next attempt hits the exact same wrongly-decided tie-break again. - Otherwise it is a genuine simultaneous establishment, and the tie-break is
the ordering rule re-keying already uses, derived from data both sides hold:
the lower
account_id's session wins and is the one both sides send on. If the peer's id is lower, adopt theirs. If ours is lower, keep ours for sending but retain the responder session for reading only — the peer is still sending on theirs until our next message reaches them, and without this those in-flight messages are stranded undecryptable forever.
The read-only retention in step 5 is easy to leave out and expensive to omit: the pair converges either way, but without it every message the loser sent before converging is lost. A client MAY discard a read-only session once the peer has demonstrably moved (a message arriving on the winning session).
Compatibility. Old and new clients interoperate in both directions without
any negotiation: an old sender omits the field and gets the old inference, a new
sender states it and is believed, and an old receiver ignores the extra key as
JSON parsers do. Which means the v: 3 re-key envelope stays worth sending
(§6) even now — it is what an old receiver reads.
Because a re-key must ride on some message, a client that has just discarded
its session SHOULD send the invisible rekey control envelope (§6) rather than
wait for the user to type something. What a desync breaks is receiving: the
peer keeps sending into a session this side can no longer read, and no action of
theirs will change that until a fresh prekey block from this side re-points
them at a working session.
Detecting a desync (client behavior). Session.Decrypt classifies its
failures (pkg/ratchet's FailureCode), and only some of them say anything
about the session:
| Code | Meaning | Evidence of desync |
|---|---|---|
duplicate_message |
Redelivery of an envelope already decrypted. Delivery is at-least-once (§7), so this is routine. | no |
authentication_failed |
The AEAD tag didn't verify — the message key derived here is not the one the sender used. | yes |
too_many_skipped |
The header's message number is too far ahead of this side's receiving chain to bridge. | yes |
no_receiving_chain |
A message for a chain that doesn't exist here yet. | yes |
| (none) | Anything else — a malformed header, corrupt persisted state. No diagnosis. | no |
A single such failure is not proof: an envelope can fail once because it
raced a session change. Repetition is, since decrypting the same ciphertext
against the same session is deterministic — so a client SHOULD count failures
per envelope, give up on one after a few attempts (dropping it from the queue,
§7), and only then count it as one unit of evidence against the session.
A separate case carries the same weight and produces no error at all: an
envelope with no prekey block from a sender there is no session for, which
is what a client whose own session state was lost or rolled back sees. Any
successful decrypt clears the evidence — it is the only proof the session works.
On enough evidence a client MAY discard its session and re-key as above. Two rules keep that from making things worse:
- Order it. If both sides re-key at once, each adopts a responder session
built from the other's
prekeyblock while discarding the initiator session the other just adopted — leaving both broken again, symmetrically, every round. Ordering is derived from data both sides already have: the loweraccount_idre-keys immediately, the higher one only if that hasn't fixed things within a grace period. - Space it. Each re-key consumes one of the peer's one-time prekeys, so a client MUST bound how often it will re-key with the same peer.
Recovery is lossy — anything still queued on the old chain becomes undecryptable — so it is a last resort, and a client SHOULD record it visibly in the conversation rather than silently.
A message's payload (§7) is an opaque JSON blob the server never parses —
defined here purely as a client-to-client contract, implemented in
pkg/wire:
{
"prekey": {
"sender_dh_identity_pub": "base64 X25519, 32 bytes",
"sender_ephemeral_pub": "base64 X25519, 32 bytes",
"signed_prekey_id": 1,
"one_time_prekey_id": 101,
"rekey": false
},
"header": {
"dh_pub": "base64 X25519, 32 bytes",
"pn": 0,
"n": 0
},
"ciphertext": "base64..."
}prekey is present only on the first message of a new session (Signal
calls this shape a "PreKeySignalMessage" vs. a plain "SignalMessage" for
everything after), or when re-keying an existing one (§5);
one_time_prekey_id is omitted if none was used, and rekey says whether this
block is a deliberate re-key or an ordinary establishment — a tri-state whose
absence means "this sender says nothing", see §5. header is the Double
Ratchet header (§5) and is always present.
What ciphertext decrypts to is a JSON object whose v selects the shape.
Nothing here ever reaches the server; it is documented so independent clients
interoperate, and implemented in freizone-app (lib/state/).
v |
Shape | Shown to the user |
|---|---|---|
| absent / not JSON | Legacy: the raw bytes are the message text. | yes |
1 |
Chat content — text, message id, reply reference, attachment references (§10). | yes |
2 |
Delivery/read receipt. | no |
3 |
Session re-key signal (below). | no |
4 |
Group chat content (below). | yes |
5 |
Group control — membership/role facts (below). | no |
v: 1 is frozen: ordinary chat messages never change shape, so every later
control envelope gets its own version. A client that meets a v it does not
know MUST NOT fail the message — it renders a neutral "newer feature"
placeholder, which is also what a client predating a control envelope will show
for one. That is the accepted cost of the scheme: an older peer displays a
placeholder for a message that should have been invisible, rather than silently
mishandling it.
Profile name (profile) — an optional field on v: 1, v: 2 and v: 4,
carrying the name the sender asserts about itself:
{
"profile": {
"name": "Anna",
"device_id": "16hexchars",
"issued_at": "2026-09-01T10:00:00Z",
"signature": "base64"
}
}A field, not a version, which is the one place the rule above is deliberately
inverted. An unknown v renders as the "newer feature" placeholder, so a control
envelope for a name would paint a visible ghost message into every older peer's
transcript each time somebody renames themselves — for a payload whose entire
purpose is to be invisible. An unknown field is ignored instead, exactly as an
older client ignores anything else added to v: 1 since (§10's attachments).
"v: 1 is frozen" is about its role, not about additive optional fields.
Riding on envelopes that already fly is also what makes it cheap: a rename costs no delivery of its own, and putting it on receipts too means a peer who only ever reads still tells a peer who only ever writes what they are called.
signature is Ed25519 by the device identity key named in device_id —
not the root key, which never leaves the primary device (§2) and must not be
required to correct a name from whichever device is in hand. The chain
root → device certificate → claim is fully verifiable by the recipient from
GET /v1/accounts/{id}, which it fetches anyway. Signing bytes:
uint16BE(len(account_id)) || account_id (UTF-8)
|| device_id (8 raw bytes, decoded from hex)
|| uint16BE(len(name)) || name (UTF-8)
|| uint16BE(len(issued_at_str)) || issued_at_str
where issued_at_str is UTC RFC 3339 to second precision — the claim on
the wire carries no finer value, or the signature would cover something other
than what was sent.
account_id is in the signing bytes but deliberately not on the wire: the
recipient already knows whose envelope this is, so sending it would pay for the
same string on every message, while covering it stops a claim being lifted out
of one account's message and replayed under another's. The server half of the
address is not covered at all, so a server move (SRV-24) leaves every claim in
the field valid.
Receiving rules, in order:
-
Verify the signature against the device certificate for
device_idon the sender's account. A claim that does not verify — or whosenamebreaks the rules below — is dropped silently, and the envelope carrying it is delivered normally. A name is never worth failing a message over.A claim whose signing key is simply not known yet is a different case and must be held, not dropped: a stranger's first message arrives before their device has ever been resolved, and that is precisely the message whose sender the recipient cannot place. Hold it (bounded, like the history), and check it when that account's key material is next fetched — anything that fails then is discarded, since the key is the answer it was waiting on. Fetching one on the spot is the wrong fix: this runs wherever messages are received, including a background push wake with no user and no guarantee of a working network.
-
Ignore a claim whose
issued_atis not strictly newer than the newest already stored for that account. Last writer wins, per account, by the sender's own clock; equal timestamps do not supersede, so a replay cannot displace the stored copy. -
Store it with its signature, keeping a bounded history of previous claims — what a rename notice is drawn from, and what an abuse report (§12) forwards.
-
Where the name changed and a conversation with that peer already exists, say so once in the transcript. Not on the first claim: it arrives before the conversation does, and there was no earlier name for it to differ from — the chat is simply labelled with it from the start. Not for a blocked peer either. And not left to a UI: this runs on receipt, including a background wake with nothing on screen, so a line the wake does not write is a line nobody ever sees.
Adopting a new name silently was rejected. An account renaming itself to something official-sounding is exactly what this could be misused for, and the person reading the chat is the only one positioned to notice.
name is at most 64 bytes of UTF-8, carries no leading or trailing
whitespace, and must contain no control characters, line breaks, or Unicode
bidirectional formatting characters (U+200E, U+200F, U+202A–U+202E,
U+2066–U+2069). The bidirectional rule is the one that matters: an override
makes a name render as something other than what it says, which would defeat
the point of ever showing an asserted name to a moderator. Zero-width joiners
are allowed — composed emoji need them and they reorder nothing.
An empty name is a withdrawal, not an empty name: it is a normal, signed
claim that retracts the one before it. Without it there is no way back out of
having stated a name.
A sender must also make each new claim's issued_at strictly later than its
own previous one, stepping it forward when two changes fall in the same
second. Ordering is strictly-newer, so two claims sharing a timestamp make the
second unsendable and unadoptable — somebody who mistypes their name and
corrects it a moment later would otherwise be stuck with the typo on every
peer, permanently. The value is a version number for the claim more than it is
a time.
Nothing about this reaches a server, and no server-side support exists or is needed — there is no capability to discover and nothing an older server is missing. It is also not verification: anyone may call themselves anything, and all a signature proves is that this account said it.
Session re-key signal (v: 3) — the invisible carrier for a re-key (§5):
{
"v": 3,
"kind": "rekey",
"reason": "decrypt_failures | user_requested | unspecified"
}It deliberately carries nothing else. Its entire purpose is that sending any
message right after discarding the local session puts a fresh prekey block on
the wire; this payload is what goes inside so the recovery costs no visible
message. A recipient MUST NOT store or notify it, and MUST still delete it from
its queue (§7) like any other processed envelope. reason is informational —
useful for wording the transcript marker (a recovery the user triggered reads
differently from one the app performed) and for diagnostics; an unrecognized
value is treated as unspecified and never affects any security decision.
Group chat content (v: 4) — v: 1's fields unchanged, plus:
{
"v": 4,
"group_id": "...",
"state_hash": "sha256 hex, over the sender's applied group event ids",
"...": "every v: 1 field (text, message id, reply reference, attachments)"
}Shown to the user exactly like an ordinary chat message; group_id and
state_hash exist only so a recipient can detect that it is missing a
membership fact (see v: 5 below) and are not otherwise part of rendering.
Group control (v: 5) — invisible, carries the group's membership and
role facts, never chat content:
{
"v": 5,
"kind": "events" | "snapshot" | "sync_request",
"group_id": "...",
"events": [
{ "type": "member_add", "fields": { }, "signer": { }, "signature": "base64" }
],
"state_hash": "..."
}Each entry in events is one signed fact (genesis, role_grant,
role_revoke, member_add, member_remove, join_accept, leave,
group_meta, or dissolve). signer is §9's identity block verbatim,
omitted when an event is signed directly by the group root key (its public
key is already in the group's genesis event, so no separate identity block
is needed to verify it). kind: "events" carries one act plus whatever
certificate chain authorizes it; kind: "snapshot" carries a party's entire
known fact set, sent once per learned state_hash mismatch; kind: "sync_request" asks for one without yet having anything to compare against.
§6 documents only this wire shape. The membership/role model behind it — the
founder/admin/moderator/member hierarchy, the fold these facts feed, why
state_hash convergence is safe without server-side consensus, and the
freizone://group?id=...&via=... invitation link — is
docs/design/01-groups.md's subject, not repeated
here.
Enqueues a message envelope (§6) for a recipient device.
{
"message_id": "client-generated, e.g. a random hex/UUID string",
"recipient_device_id": "16hexchars",
"payload": { "...the envelope from §6..." }
}202 {"status":"queued"} — durably queued, not yet necessarily delivered ·
404 unknown_recipient unknown/inactive recipient device — the same word the
batch form uses as its per-item status, and to a sender the queueing-time form
of a stale cached device id (§4's stale-device rule) · 409 message_id
already used ·
429 recipient device's queue is already at FREIZONE_MAX_QUEUED_MESSAGES_PER_DEVICE
(§9) · 413 request body exceeds FREIZONE_MAX_REQUEST_BODY_BYTES (enforced
as middleware ahead of every route except the two blob upload routes, which
are capped by FREIZONE_MAX_BLOB_BYTES instead — see §10 — since an
attachment is legitimately far larger than any message; so in practice a
same-server signed request this large is rejected by the auth middleware's own
body read first, surfacing as the generic 401 §3 already documents for every
auth failure, rather than this 413).
If the recipient device has no live SSE stream (GET /v1/messages/stream)
open, the server fires one best-effort wake, via whichever of the two
mechanisms the device has registered (a device has at most one at a
time). The same wake is also fired when POST /v1/devices/{device_id}/prekey-bundle
(§4) leaves a device's one-time-prekey pool below the low-water mark — the
wake carries no indication of which reason triggered it (see below), so
this reuses the identical mechanism rather than needing a second one:
- Push subscription (
PUT /v1/devices/{device_id}/push-endpoint): a Web Push notification, RFC 8291-encrypted via this server's one VAPID keypair (RFC 8292), sent directly to the registered UnifiedPush/Web Push distributor endpoint. - Push target (
PUT /v1/devices/{device_id}/push-target): this server signs a request with its own relay identity (an Ed25519 keypair generated on first boot, seeserver_settings.relay_pubkey— never exposed via any endpoint, only used as theSignature-Key-Idon outgoing relay requests) and POSTs{"platform": ..., "token": ...}toPOST /v1/push/sendon the freizone-gateway instance configured viaFREIZONE_PUSH_GATEWAY_URL, which holds the actual FCM/APNs credentials and relays the wake. There is no registration step with the gateway: it verifies the request against the public key embedded inSignature-Key-Iditself, the same self-certifying pattern this protocol already uses for account ids (§1). Skipped entirely ifFREIZONE_PUSH_GATEWAY_URLisn't configured.
Either way, the wake itself carries no content or metadata whatsoever —
not the message, not its sender, not even that it's specifically a "new
message" wake as opposed to any other reason (the Web Push path's
encrypted plaintext is itself empty; the push-target path's body is only
ever {"platform", "token"}, nothing message-related). The recipient is
expected to react to any wake by syncing over this same authenticated
API, exactly as if it had just reconnected — fetching queued messages
and checking GET /v1/devices/{device_id}/prekey-status (§4), since a
wake can't tell the client which of the two actually needs attention.
Delivery of the wake itself is not guaranteed (no retry, short timeout) —
the durable queue and the client's own reconnect/poll remain the actual
delivery guarantee, same as before push existed.
Wakes are coalesced per device, so a client must not assume one wake
per message. Because a wake says nothing beyond "go sync", several of them
in quick succession carry no more information than one, and the server
collapses them: the first wake to an idle device is sent immediately, and
any further wake within FREIZONE_PUSH_COALESCE_WINDOW (3 s by default,
0 to disable) is merged into a single one at the window's end. A device is
therefore woken at most once per window, no matter how many messages
arrive — which is exactly why the reaction to a wake has to be a full sync
rather than a fetch of one message. That was already required, since a
wake never said which message or even which of the two reasons triggered
it; coalescing only makes the consequence more visible.
Enqueues several envelopes in one request. Introduced for group fan-out (SRV-01), where one author sends N separately encrypted copies: batching collapses that to one request per distinct recipient server instead of one per recipient device. Authentication is unchanged — every item is from the one signing device, and the §3 signature covers the whole body as always.
{ "messages": [
{ "message_id": "...", "recipient_device_id": "16hexchars", "payload": { } }
] }200, one result per submitted item, in the submitted order:
{ "results": [ { "message_id": "...", "status": "queued" } ] }status |
Meaning |
|---|---|
queued |
durably queued, exactly as a single POST /v1/messages would |
duplicate |
message_id already used (the 409 case) |
unknown_recipient |
unknown or inactive device, or its account is not active |
queue_full |
that device is at FREIZONE_MAX_QUEUED_MESSAGES_PER_DEVICE |
invalid |
missing message_id/recipient_device_id/payload, or a recipient_account_id that contradicts the device |
internal_error |
server-side failure for that item alone |
Failures are per item and never fail the batch. In a group, one recipient
being at their queue cap is not the other members' problem, so their copies
still go out. The batch as a whole is rejected only for things that make it
uninterpretable: 400 invalid_request for an empty list, 400 batch_too_large
above FREIZONE_MAX_BATCH_MESSAGES (default 100), 413 above
FREIZONE_MAX_REQUEST_BODY_BYTES, and the usual generic 401.
The per-item statuses reveal nothing POST /v1/messages does not already
reveal through its 404/409/429.
Advertised as batch_messages on GET /v1/server-status (§4). A client that
does not find it there posts each message individually — which is why groups
work against every server already deployed.
Polls for messages queued for the caller's device. 200, an array of:
{
"message_id": "...",
"sender_account_id": "...",
"sender_device_id": "...",
"sent_at": "2026-07-17T12:00:00Z",
"payload": { "...the envelope from §6..." }
}Acknowledges a message, removing it from the queue once durably processed.
200 {"status":"deleted"} · 404 unknown message, or it doesn't belong to
the caller's device.
Content-Type: text/event-stream. On connect, flushes every currently
pending message (same shape as the GET /v1/messages poll, one per SSE
event: message / data: ... pair), then pushes newly-arrived messages
live for as long as the client stays connected. A : heartbeat comment is
sent roughly every 25s to keep the connection alive through proxies. This
is process-local (no cross-instance fan-out) — fine for a single server,
revisit for horizontal scaling.
Messages are never stored long-term: each is deleted immediately on
acknowledgment, or automatically after FREIZONE_MESSAGE_RETENTION_DAYS
(default 14) if never acknowledged.
Client-side convention, not a server endpoint: a URI that lets one device
hand another everything it needs to join a server, without typing an
address or invite code by hand. An app renders this as a QR code (whoever
can currently invite — see §4's POST /v1/admin/invites — on an invite
server, or anyone on an open server); another instance's setup wizard
scans it in place of the manual address-entry step.
freizone://join?server=<url>&code=<invite code>
server(required): the same address a user would otherwise type into the setup wizard, e.g.https://chat.example.org.code(optional): a single-use invite code fromPOST /v1/admin/invites. Omitted entirely when the target server's registration policy isopen(no code needed) — a QR for anopenserver carries onlyserver. Best encoded in the compact form (ABCDEFGHJKMN) rather than the grouped form the endpoint returns (ABCD-EFGH-JKMN): the code redeems either way, since the server normalizes it (§4), so dropping the hyphens is purely about keeping the QR sparse. A scanner must not assume the compact form, though — normalization means a QR produced by another client with hyphens in it is equally valid.
There's no case for an unclaimed (not-yet-bootstrapped) server: bootstrap needs a one-time setup token, which isn't part of this format, since QR invites are only ever generated by a server that's already been claimed. Scanning a QR pointing at an unclaimed server just falls through to the ordinary manual bootstrap step, address pre-filled — not an error, just no extra automation for a case that shouldn't occur.
A message to id*server where server isn't the sender's own home
server is delivered directly by the sending client to the recipient's
home server — there is no server-to-server relay, and no registration
or handshake between the two servers beforehand. This follows directly
from §1's self-certifying identity: since any party can independently
verify hash(root_pubkey) == id and a device certificate's signature,
the recipient's server can verify a stranger's request purely
cryptographically, with no need to trust (or even know about) the
sender's home server.
Account/prekey lookup needs no new endpoint: GET /v1/accounts/{id}
and POST /v1/devices/{device_id}/prekey-bundle (§4) are already public
and already answer only about this server's own accounts — calling them
against the recipient's server (instead of the caller's own) works
unchanged.
Message delivery does need a dedicated endpoint, since POST /v1/messages (§7) is authenticated by looking up the signing device in
this server's own devices table — which a foreign device, by
definition, isn't in.
Request body:
{
"sender_account_id": "...",
"sender_root_pub_key": "base64",
"sender_device_cert": {
"device_id": "...",
"device_pub_key": "base64",
"issued_at": "2025-01-01T00:00:00Z",
"signature": "base64"
},
"recipient_account_id": "...",
"recipient_device_id": "...",
"message_id": "...",
"payload": { "...same envelope shape as §6/§7..." }
}Signed exactly like any other request (§3), with one difference:
Signature-Key-Id is the sender device's own base64-encoded public
key (sender_device_cert.device_pub_key), not a device id — there is
no local row to look a device id up in. This is the same
self-describing-key convention freizone-gateway
already uses for its own no-registration caller model.
Server-side verification, in order:
hash(sender_root_pub_key) == sender_account_id(§1).sender_device_certis validly signed bysender_root_pub_key— the same check §4'sPOST /v1/devicesdoes once, at registration time, for a local device; here it's done inline, per request, since the sender was never registered here.sender_account_idis not on this server's federation blocklist (see below).Signature-Key-Idequalssender_device_cert.device_pub_keyexactly — binding "the signature proves possession of this key" to "the certificate proves this key is certified under this account."- The request signature verifies against that key, within the usual clock skew, and its nonce hasn't been replayed (§3, unchanged).
recipient_device_idnames an active local device, whose account is also active.message_idhasn't already been used (§7's existing idempotency guard).
On success the message is queued and delivered to the recipient exactly as any same-server message would be (§6/§7's SSE stream and push-wake path) — the recipient's client can't tell, and doesn't need to, whether a message arrived from this server or a federated one.
The federated twin of §7's batch endpoint: a group send from a member on another server, whose copies for every recipient on this server arrive together. The sender's identity block appears once, at the top level:
{
"sender_account_id": "...",
"sender_root_pub_key": "base64",
"sender_device_cert": { "...as above..." },
"messages": [
{ "message_id": "...", "recipient_device_id": "16hexchars", "payload": { } }
]
}Verification is the seven steps above, unchanged, with steps 6 and 7 applied per item. That the chain is verified once for the whole batch rather than once per recipient is the larger saving here — it is the expensive part of accepting a stranger's message, and a group send repeats it for every member on the same server.
The response shape, the per-item statuses, and the "failures are per item"
rule are identical to §7's. A failure of the sender's own verification is
not per item: it rejects the whole request with the generic 401, and nothing
is queued.
Learning the sender's server, for replies. Nothing above ties
sender_account_id to any particular hostname — that's deliberate;
self-certifying identity is host-independent by design, so there's no
reliable "which server is this account's home" fact for this endpoint to
assert or check. Instead, a client sending cross-server includes its own
server address inside the encrypted message content (not this
delivery-layer request), on every such message. This keeps the fact out
of anything a server ever sees in the clear, and makes a recipient's
knowledge of the sender's server self-healing (it's refreshed on every
message, not just the first) rather than a one-time fact that a lost
local device could never recover.
Abuse mitigation is per-account, not per-server. An admin can block a
specific remote account_id from delivering messages here:
GET /v1/admin/federation-blocklist— list blocked accounts (admin or moderator).POST /v1/admin/federation-blocklist{"account_id": "...", "reason": "..."}— block one (admin only).DELETE /v1/admin/federation-blocklist/{account_id}— unblock (admin only).
There is no per-origin-server block, because nothing here reliably identifies one — the whole point of self-certifying identity is that no server (including the recipient's own) is in a position to assert "this account really lives at that hostname." Blocking one abusive account doesn't stop a determined operator from minting another; that's a known, accepted limitation of this first version, not something this design solves.
Queue and body-size limits. Since federation accepts a caller that
was never registered here, a message-flooding sender no longer even
needs an account on this server (same-server sending is still bounded
by whatever FREIZONE_REGISTRATION_POLICY requires). Two limits guard
against this, applied identically to same-server and federated
delivery: FREIZONE_MAX_QUEUED_MESSAGES_PER_DEVICE (default 1000)
caps how many undelivered messages one recipient device's queue may
hold at once (429 once full), and FREIZONE_MAX_REQUEST_BODY_BYTES
(default 512 KiB, enforced as middleware ahead of every route, not
just message delivery) caps a single request's size. Neither existed
before federation did — the same unbounded-queue/unbounded-body gap
existed for plain same-server sending too, federation just removed the
"you need an account here first" friction that limited who could
exploit it.
Known gap: cross-server device revocation. §4's POST /v1/devices/{id}/revoke only updates this server's own devices
table. If a sender's device is revoked on its home server, a recipient
elsewhere has no channel to learn that — unlike same-server auth, where
a revocation takes effect on the very next request. A live check against
the sender's claimed home server was considered and rejected: the
"server" a message claims to come from is self-reported and
unauthenticated (see above), so exactly the attacker this would catch —
someone using a stolen, not-yet-revoked device key — can trivially omit
or falsify it, or simply wait out a transient unreachability window. A
best-effort version remains a natural future addition (reusing the
already-public GET /v1/accounts/{id}, no new endpoint needed) but isn't
built in this version. The sending half of the gap, though — a sender
holding a cached device id that its peer has since replaced, and failing
against it forever — is closed by §4's stale-device rule: the first
unknown_device/no_prekey_bundle/unknown_recipient answer tells the
sender to re-resolve the peer's device list and heal, so a replaced
device costs one failed request, not the conversation.
FREIZONE_FEDERATION_ENABLED (default true): seeds the inbound-
federation switch on first boot. Turned off, POST /v1/federation/messages
returns 404 for an operator who wants no inbound federation at all. Like
the registration policy, this is only the seed: the authoritative value
lives in the DB and is changed at runtime via PUT /v1/admin/federation
(§4), surfaced publicly on GET /v1/server-status so honest clients also
stop sending outbound federation when their home server has it off (a
peer's reply would otherwise be blocked here, stranding the conversation).
Explicitly out of scope: account portability/migrating servers, and server discovery — not needed, since an address already names the exact server. Groups used to be listed here too; a group send is still exactly N invocations of this same per-recipient delivery, fanned out per member, with the batch endpoint above as a pure transport shortcut for the recipients that happen to share a server. Nothing in federation assumes a single recipient server or a shared delivery transaction across members.
Messages carry text; anything larger — an image, later video or audio — travels out of band as a blob. The message itself only carries a reference plus the key to decrypt it, inside the end-to-end encrypted payload, so the server learns no more about an attachment than it does about a message: it stores ciphertext it cannot read.
A blob lives on the recipient's server, not the sender's. This mirrors the direction messages already travel (§9: the sender pushes to the recipient's server), and means a recipient only ever fetches from its own, trusted server — it never has to contact a stranger's server and reveal its IP to an operator it has no relationship with. The cost is that the upload route, like federated messages, accepts uploads from senders who never registered here, and is defended the same way: the federation kill switch, the blocklist, per-device quotas, and a retention window.
Flow:
- Sender encrypts the attachment with a fresh random key (not derived from the ratchet, so re-downloading still works after a session reset).
- Sender uploads the ciphertext to the recipient's server and gets a
blob_id. - Sender sends a normal message whose encrypted payload carries the
blob_idand the key. - Recipient decrypts the message, fetches the blob from its own server, and decrypts it locally.
Step 3's reference is a client-to-client contract, not something the server
ever parses — it lives inside the encrypted payload envelope of §6, in that
envelope's attachments list. Documented here for the same reason the
envelope itself is: anyone writing a second client needs it to interoperate.
{
"kind": "image",
"alg": "aes-256-gcm",
"blob_id": "64 hex chars",
"key": "base64 (32 bytes)",
"mime": "image/jpeg",
"size": 214512,
"w": 1600,
"h": 1200,
"thumb": "base64 (optional, ≤ 2 KiB)"
}kind—"image"today. An unrecognized kind must render as an unsupported-attachment placeholder rather than break the message, which is what lets video/audio be added without a format change.alg— a string rather than an implied cipher, so changing ciphers stays additive.key— this blob's own symmetric key, freshly generated per attachment and deliberately not ratchet-derived, so the picture stays decryptable after a secure-session reset (§5) discards ratchet state.size,w,h— the pixel dimensions let a client reserve the final aspect ratio before the blob has downloaded, so a transcript does not reflow as pictures arrive.thumb— an optional inline preview, small enough to ride inside the message itself (at most 2 KiB), shown blurred until the real blob lands. The cap must be enforced when decoding as well as encoding: otherwise a buggy or hostile peer could inflate the receiver's stored history through this field.- Deliberately absent: any
serverfield (a blob always lives on the recipient's own server, so the fetching client already knows where to look) and any filename (it would leak device paths and camera details for no benefit).
A malformed entry — no blob_id, no key, an oversized thumb — should be
dropped on its own rather than failing the whole message: the text still
deserves to arrive.
| Method | Path | Auth |
|---|---|---|
POST |
/v1/blobs?recipient_device_id=… |
device signature (§3) + Blob-Digest |
POST |
/v1/federation/blobs?recipient_device_id=… |
self-describing key (§3) + Blob-Digest |
GET |
/v1/blobs/{blob_id} |
device signature (§3), recipients only |
DELETE |
/v1/blobs/{blob_id} |
device signature (§3), recipients only |
Upload bodies are application/octet-stream — raw ciphertext, not base64
(the server never parses it, and base64 would add a third). They are
authenticated by the streamed-body variant in §3, so the signature is checked
before any bytes are read and the stored bytes are verified against the
signed digest.
On the federated route the sender's identity moves from JSON fields into
headers, since the body is raw bytes: Freizone-Sender-Account-Id,
-Root-Pub-Key, -Device-Id, -Device-Pub-Key, -Cert-Issued-At,
-Cert-Signature. They are verified exactly as §9 verifies a federated
message sender.
Success returns 201 with {"blob_id", "size", "expires_at", "recipients"}.
blob_id is 32 random bytes, hex-encoded — deliberately not a content
hash, which would let anyone holding a file test whether someone else
uploaded the same one, and would turn the id into an existence probe.
recipient_device_id may be repeated, up to max_blob_recipients (§4)
distinct devices. The server then stores the ciphertext once and every named
device may fetch it — which is what lets a group attachment cost one upload
per recipient server rather than one per member. Duplicates in the list are
collapsed, so naming a device twice charges its quota once.
Deliberately the same parameter repeated rather than a new list parameter: a one-recipient upload is then byte-identical to what a pre-SRV-18 client sends, and §3's signature already covers the raw query string, so the recipient set is signed without any change to the signature scheme.
recipients reports one outcome per named device, in the order they were
named — stored, unknown_recipient (unknown or inactive, indistinguishable
on purpose) or quota_exceeded. Failures are per recipient and never fail
the upload: one member at their quota must not cost the others their copy,
the same rule POST /v1/messages/batch follows.
The status code splits by form, so the pre-SRV-18 contract is untouched:
| Form | Success | Nothing stored |
|---|---|---|
| one recipient | 201 |
404 / 429, exactly as before |
| several recipients | 200 |
200, with blob_id and expires_at omitted |
200 rather than 201 for several, because nothing was created at a single
location and the outcomes differ per recipient. An older client is unaffected:
it never sends more than one.
Size is bounded by the largest remaining quota among the named recipients,
not the smallest — otherwise one nearly-full member would shrink the upload
out from under everyone else and they would all get a 413. Whoever the
stored size then does not fit is reported quota_exceeded individually. A
size that fits nobody is still refused as a 413 mid-stream, before the
body is written.
Each recipient is charged the full size, not a share of it: the quota measures what that device may still fetch, and charging a fraction would let a sender multiply one device's allowance by naming co-recipients.
Separately from the per-recipient quotas, an operator may set a whole-server
cap on the combined size of all stored blobs (FREIZONE_MAX_BLOB_BYTES_TOTAL,
off by default). Unlike a per-recipient quota_exceeded, reaching it rejects
the entire upload with 507 regardless of form — a full disk is not
something the sender can work around by dropping a recipient.
A DELETE removes only the calling device's own claim. The ciphertext is
removed when the last claim goes, so one group member deleting their copy
cannot take the attachment away from the rest.
GET serves the ciphertext via range-capable responses, so an interrupted
download can resume. A blob that does not exist and one belonging to another
device both answer 404, so the endpoint cannot be used to discover blob ids.
| Status | Meaning |
|---|---|
400 digest_mismatch |
body did not match the signed Blob-Digest |
400 too_many_recipients |
more than max_blob_recipients distinct recipients named |
401 |
signature/auth failure (generic, as everywhere) |
403 |
federated sender is blocked |
404 |
blobs disabled, unknown/inactive recipient device, or unknown/not-yours blob |
413 payload_too_large |
over FREIZONE_MAX_BLOB_BYTES, or over every named recipient's remaining quota |
429 blob_quota_exceeded |
recipient device is at its blob count or byte quota |
507 storage_full |
the server-wide blob cap (FREIZONE_MAX_BLOB_BYTES_TOTAL, off by default) is reached — refuses the whole upload, both forms |
The 404 and 429 rows are the single-recipient answers. With several,
the same two conditions are reported per recipient inside a 200 instead. The
507 is request-level, not per recipient: it applies to both forms alike,
since a full server has no room for the blob at all.
Operator-configurable: FREIZONE_MAX_BLOB_BYTES (default 8 MiB),
FREIZONE_MAX_BLOB_BYTES_PER_DEVICE (128 MiB), FREIZONE_MAX_BLOBS_PER_DEVICE
(200), FREIZONE_MAX_BLOB_BYTES_TOTAL (a whole-server ceiling on all stored
blob bytes, default 0 = off — see 507 storage_full above),
FREIZONE_MAX_BLOB_RECIPIENTS (100, matching
FREIZONE_MAX_BATCH_MESSAGES — the recipients of one upload are the members
whose message copies the same fan-out then batches to the same server),
FREIZONE_BLOB_RETENTION_DAYS (defaults to the message retention
window and may not be shorter, so a blob outlives the message referencing
it), FREIZONE_BLOBS_ENABLED, FREIZONE_BLOB_DIR.
Because these are the recipient server's limits and a federated sender
cannot know them in advance, GET /v1/server-status (§4) advertises
blobs_enabled and max_blob_bytes — a sender sizes or recompresses an
attachment against those rather than discovering them via a 413 after
uploading.
Blobs are deleted when their last recipient DELETEs them, when the
retention window expires, or once no recipient is left at all — a removed
device drops its claim by cascade, and a blob nobody claims any more is
collected by the same cleanup ticker that expires them.
The server does not delete on GET: a read proves nothing about whether
the client decrypted and stored the bytes, and range requests make "finished
downloading" ambiguous anyway. Instead the recipient is expected to DELETE
the blob once it has the plaintext safely on disk — the same
process-then-remove contract as the message queue (§5). Retention is the
backstop for blobs whose recipient never comes back.
Only a named recipient can retrieve a blob, so a sender cannot re-fetch what it uploaded; its own copy is whatever it kept locally at send time.
Client-side convention, not a server endpoint — the counterpart to §8's
freizone://join, but for contact initiation rather than server
registration: a URI that lets one already-registered account hand
another its own address, so the scanning device can start a
conversation directly (federated per §9 if the two accounts are on
different servers) instead of typing an id*server address by hand.
freizone://chat?id=<account id>&server=<url>&name=<display name>
id(required): the sharer's account id, in its canonical (unhyphenated) form.server(required): the sharer's home server, in the same form as §8'sserverparam.name(optional): a display-name suggestion for the conversation the scanning device creates — purely a local default on the recipient's side, not authoritative and freely overridden there.
Unlike §8's join invite, this carries no secret or capability: an
account's id*server address is already public information (it's
exactly what GET /v1/accounts/{id} on server answers about, per
§4/§9), so there's nothing here to expire or revoke. The QR is purely a
convenience encoding of already-public data, equivalent in trust terms
to reading the address off a screen and typing it in by hand — the
actual identity verification happens the same way it would for a
manually-typed address, via the existing account/prekey lookup and
device-certificate checks (§2, §9), not anything carried by this URI.
Blocking is what a user does about somebody; reporting is how an operator
learns that block or delete is warranted at all. The content is
end-to-end encrypted and nothing else is a signal, so without this an
operator has the powers and none of the information.
A report is an assertion with no proof behind it — the server cannot see what was said, and never will. So the counter is a reason to talk to someone, never a finding: nothing acts on a threshold, and none of these endpoints does anything on its own.
Reports are named. The reporter is stored, and shown to staff, with their address. Accusation carries responsibility, and an operator who cannot ask "what happened?" can do nothing useful with a number. Named reporting is also what makes brigading visible — two hundred reports from one account read as exactly that. Consequences a client must honour: it says so before sending, the reported account is never told who reported it, and withdrawal is always available.
Availability is discovered, not assumed: reports_enabled on GET /v1/server-status (§4), absent meaning off, on blobs_enabled's rule.
A client checks its own server before offering to report at all, and the
target's server before offering to forward — two separate questions.
A member of this server reports any address, local or federated.
{
"reported": "q2xjx*chat.example.org",
"category": "spam | harassment | fraud | other",
"evidence": [ { "name": "...", "device_id": "...", "issued_at": "...", "signature": "base64" } ]
}201, or 200 when it updated a report this reporter already had against
that address — one reporter counts once. Re-reporting refreshes the
category and evidence and reopens a resolved case; it never adds a row and
never raises a count.
category is a fixed set and there is deliberately no free-text field:
free text on a server is a store of personal allegations, and a channel for
writing at the operator that nothing moderates.
evidence is the §6 profile claims the reporter holds for that account —
the name it asserted about itself, never the reporter's private name for
it. Verified here when the target is local, since this server holds its root
key and device certificates; stored unverified for a federated target rather
than fetching a stranger's key material on a complainant's say-so. A claim
that does not verify is dropped, not refused: a report is worth having
without its evidence.
400 unparseable address, unknown category, oversized evidence · 403
reporting yourself · 404 unknown local account, or reporting switched off ·
409 report_limit past the per-reporter cap on open reports.
No server-to-server relay, per §9. This endpoint never forwards
anything. A reporter who also wants the target's operator to know posts to
that server's POST /v1/federation/reports itself, exactly as it delivers a
federated message itself — and whether to do so is the reporter's decision,
because handing your identity to an operator you do not know is not always
the right move. Filing with your own server always happens: it is the
operator who knows you, and the one who can act via the federation
blocklist.
Withdrawal by the reporter, where {reported} is the canonical address in
one path segment (* is a legal sub-delimiter). Somebody who bears
responsibility for an accusation has to be able to change their mind.
Deletes rather than resolves: keeping it as a resolved case would leave the
accusation on the record, which is what withdrawing is meant to undo.
404 if this reporter has no report on that address.
A reporter on another server, about an account on this one. Body and
authentication exactly as POST /v1/federation/messages (§9) — sender
account id, root public key, device certificate, signed with the
self-describing-key variant — plus the report fields above and one more:
sender_server(required): the reporter's home server. It has no counterpart in §9, where the home server travels inside the encrypted payload; a report has no such channel and needs the address anyway, since the whole point of naming a reporter is that the operator can go and ask them. It is not verifiable and does not need to be — the id is proven cryptographically, and naming the wrong server only makes the reporter unreachable. A reporter this server recognises as one of its own is filed as local whatever this field claims.
404 federation_disabled, 403 for a sender on the federation blocklist,
400 for a certificate that does not verify — the same three answers as a
federated message, for the same reasons. Withdrawal is DELETE /v1/federation/reports/{reported} with the same authentication.
The moderation queue, newest first; ?state=open for open cases only. Each
entry carries both addresses, category, state, timestamps, the resolver, and
the evidence with evidence_verified.
A moderator sees reports whose target is a user. Ones targeting a
moderator or an admin are admin-only, and the query does not ask for
them rather than filtering them out afterwards (§4's invited_by
precedent): a moderator investigating a colleague is not a moderation case
any more, it is the operator's. The same rule blanks the per-account
counters on staff rows in GET /v1/admin/accounts.
Anyone may report anyone, staff included. The server does not look at the target's role when accepting one — an admin who cannot be reported is an admin nobody can raise a problem about. The limit is on acting, not on saying.
One limit that cannot be designed away: when the reported account is the server's only admin, the report reaches the person it is about. Nothing sits above one's own operator in a federated system, and a client should say so at the moment it applies rather than let a reporter find out afterwards.
{"outcome": "actioned" | "dismissed" | "abusive"}.
Resolving is not deleting, and there is deliberately no counter reset:
the value of a report a year later is that the next moderator can see there
was one and how it went. abusive counts against the reporter, on their
own row — the counterweight to named reporting, without which responsibility
costs nothing. A moderator may not apply it to a report by staff, the mirror
of the rule above.
400 unknown outcome · 403 a moderator acting on a staff-targeted case,
or marking a staff member's report abusive · 404 unknown report.
GET /v1/admin/accounts (§4) carries four figures per account, 0 rather
than absent so "none" is distinguishable from an older server:
reports_local and reports_federated (open reports about it, never
summed — anybody on any server can raise the second, so a combined figure is
one a stranger can inflate and an operator cannot act on), reports_filed
and reports_abusive (the mirror, about reports it made).
Reports expire, resolved and open alike, after
FREIZONE_REPORT_RETENTION_DAYS (default 90). A counter that never falls
becomes a criminal record for something that was never proven, and the
evidence goes with the row. They are also cleared when either account is
deleted — a report about somebody who no longer exists is a claim nobody can
answer.