Skip to content

Latest commit

 

History

History
2088 lines (1815 loc) · 104 KB

PROTOCOL.md

File metadata and controls

2088 lines (1815 loc) · 104 KB

Freizone Wire Protocol — Identity, Auth & E2E Messaging (v2)

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).

1. Addressing

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.

  1. Compute SHA-256(root_pubkey) (32 bytes).
  2. 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.
  3. Prepend a 5-bit version marker group. The only currently defined version is 0. This gives 15 payload groups (75 bits) total.
  4. Compute a 6-group bech32m checksum (BIP-350: charset qpzry9x8gf2tvdw0s3jn54khce6mua7l, XOR constant 0x2bc830a3) 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.
  5. 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.

2. Key hierarchy

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).

Device Certificate

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).

Device Revocation

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.

3. Per-request signature authentication (RFC 9421-inspired)

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))}
  • METHOD is the uppercase HTTP method (POST, GET, ...).
  • url_path is the request path only (no scheme/host/query), e.g. /v1/devices.
  • raw_query is the raw query string with no leading ? (empty string if none).
  • body is 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.

Self-describing-key variant (public, inline-authenticated endpoints)

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-Id is 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 stored root_pubkey for 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_nonces store rather than the device-auth in-memory cache, but the 5-minute skew window and generic-401 rule are identical.

Streamed-body variant (Blob-Digest)

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:

  1. verifies the signature from the headers alone, before reading any body — so a forged or unauthorized upload costs no disk at all;
  2. 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.

4. REST endpoints

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":"..."}}.

GET /healthz

No auth. 200 {"status":"ok"}, or 503 if the database is unavailable.

POST /v1/bootstrap/claim

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.

POST /v1/accounts

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.

POST /v1/accounts/{id}/recover (public — root-key signed)

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).

GET /v1/server-status

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).

DELETE /v1/accounts/{id} (signed, caller must be that account)

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).

GET /v1/vapid-public-key

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" }

POST /v1/devices (signed)

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.

POST /v1/devices/{device_id}/revoke (signed)

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.

PUT /v1/devices/{device_id}/push-endpoint (signed, caller must be that device)

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.

PUT /v1/devices/{device_id}/push-target (signed, caller must be that 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.

POST /v1/admin/invites (signed, admin or moderator)

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;
  • I and L are read as 1, and O as 0 — unambiguous precisely because the alphabet cannot produce those letters, so encountering one can only mean a misread digit. U is 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.

Server admin endpoints

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_at is 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_limit is the per-device quota (FREIZONE_MAX_BLOB_BYTES_PER_DEVICE) times device_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_by is 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"} · 400 invalid role · 404 unknown account · 409 last_admin demoting the server's only remaining admin.

  • POST /v1/admin/accounts/{id}/block / .../unblock (signed, admin, or moderator targeting a user) — no body. Blocking sets the account disabled, which internal/auth's middleware then rejects on every subsequent request from that account, from any of its devices. 200 {"status":"ok"} · 403 a moderator targeting a moderator or an admin · 404 unknown account · 409 last_admin blocking 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"} · 404 unknown account · 409 last_admin deleting 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_POLICY only seeds it on first boot, this endpoint is what actually governs it afterwards, and the change persists across restarts. 200 {"policy": "..."} · 400 invalid 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_ENABLED only seeds it on first boot, this endpoint governs it afterwards, and the change persists across restarts and is reflected in GET /v1/server-status's federation_enabled. 200 {"enabled": ...}.

  • GET /v1/admin/license (signed, admin only) — this server's active account count against its own attestation's advisory seats ceiling (SRV-19, pkg/attest's Seats). Deliberately not on GET /v1/server-status or 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
    }

    attested is false whenever there is nothing usable to report — no attestation configured, or one that fails to decode, verify, or falls outside its validity window — in which case tier, seats, and expires_at are all omitted; active_accounts and over_limit are still meaningful on their own. seats is omitted for 0 ("unspecified or unlimited"), in which case over_limit is always false — 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 on GET /v1/server-status or the landing page, for the same reason GET /v1/admin/license isn'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_bytes are both 0 when the server's host platform has no way to report them (internal/diskstat only covers Linux/Darwin) — read that as "unknown", not "completely full".

    The response also carries a forecast object (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}, "..."]
    }

    drain is arithmetic on facts rather than a prediction: every blob carries its own expires_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 reaches 0 one day past retention_days. with_inflow adds uploads continuing at the measured inflow_bytes_per_day; those live one retention window too, so it flattens out on equilibrium_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_day is measured, not guessed — the ciphertext actually stored over inflow_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) — every GET /v1/admin/stats-shaped snapshot recorded in the last N days (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 as GET /v1/admin/stats.

POST /v1/devices/{device_id}/prekeys (signed, caller must be that device)

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.

POST /v1/devices/{device_id}/prekey-bundle

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.

Claimant authentication

Three forms, in this order:

  1. 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):
    {
      "sender_account_id": "...",
      "sender_root_pub_key": "base64 Ed25519",
      "sender_device_cert": { "device_id": "...", "device_pub_key": "base64...", "issued_at": "...", "signature": "base64..." }
    }
    signed with the self-describing-key variant (§3): Signature-Key-Id is the base64 device public key named in the certificate. Subject to the same rules as a federated message: 404 federation_disabled when this server has federation switched off, 403 for a sender on the federation blocklist, 400 for a certificate that does not verify under the claimed root key.
  2. An ordinary §3 device signature, no body — for a claimant registered on this server. Rejected with 401 like any other signed route if it does not verify.
  3. Neither — an anonymous claim. Still answered 200 with 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.

GET /v1/devices/{device_id}/prekey-status (signed, caller must be that device)

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.

5. X3DH + Double Ratchet

DH Identity Certificate & Signed Prekey Certificate

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/ecdh in 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:

  1. 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.
  2. Decides whether this is a deliberate re-key:
    • 2a. rekey present — take it at its word, false included.
    • 2b. rekey absent — infer, as clients did before the field existed: a v: 3 re-key signal in the plaintext means deliberate, anything else means simultaneous.
  3. 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.
  4. 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_id ordering. A rekey: false block 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 same false a 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 its account_id happens 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.
  5. 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 prekey block 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 lower account_id re-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.

6. Message envelope & queue

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.

The plaintext inside (client-to-client)

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:

  1. Verify the signature against the device certificate for device_id on the sender's account. A claim that does not verify — or whose name breaks 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.

  2. Ignore a claim whose issued_at is 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.

  3. 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.

  4. 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.

7. Message REST endpoints

POST /v1/messages (signed)

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, see server_settings.relay_pubkey — never exposed via any endpoint, only used as the Signature-Key-Id on outgoing relay requests) and POSTs {"platform": ..., "token": ...} to POST /v1/push/send on the freizone-gateway instance configured via FREIZONE_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 in Signature-Key-Id itself, the same self-certifying pattern this protocol already uses for account ids (§1). Skipped entirely if FREIZONE_PUSH_GATEWAY_URL isn'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.

POST /v1/messages/batch (signed)

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.

GET /v1/messages (signed)

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..." }
}

DELETE /v1/messages/{message_id} (signed)

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.

GET /v1/messages/stream (signed, SSE)

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.

8. Invite QR codes (freizone://join)

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 from POST /v1/admin/invites. Omitted entirely when the target server's registration policy is open (no code needed) — a QR for an open server carries only server. 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.

9. Federation

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.

POST /v1/federation/messages (public — does its own authentication)

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:

  1. hash(sender_root_pub_key) == sender_account_id (§1).
  2. sender_device_cert is validly signed by sender_root_pub_key — the same check §4's POST /v1/devices does once, at registration time, for a local device; here it's done inline, per request, since the sender was never registered here.
  3. sender_account_id is not on this server's federation blocklist (see below).
  4. Signature-Key-Id equals sender_device_cert.device_pub_key exactly — binding "the signature proves possession of this key" to "the certificate proves this key is certified under this account."
  5. The request signature verifies against that key, within the usual clock skew, and its nonce hasn't been replayed (§3, unchanged).
  6. recipient_device_id names an active local device, whose account is also active.
  7. message_id hasn'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.

POST /v1/federation/messages/batch (public — does its own authentication)

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.

10. Encrypted blob transport (attachments)

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:

  1. Sender encrypts the attachment with a fresh random key (not derived from the ratchet, so re-downloading still works after a session reset).
  2. Sender uploads the ciphertext to the recipient's server and gets a blob_id.
  3. Sender sends a normal message whose encrypted payload carries the blob_id and the key.
  4. Recipient decrypts the message, fetches the blob from its own server, and decrypts it locally.

The attachment reference (client-to-client)

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 server field (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.

Endpoints

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.

Several recipients per upload

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.

Retrieval and errors

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.

Limits and lifetime

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.

11. Chat invite QR codes (freizone://chat)

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's server param.
  • 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.

12. Reporting an account to its operator

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.

POST /v1/reports (signed)

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.

DELETE /v1/reports/{reported} (signed)

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.

POST /v1/federation/reports (public — does its own authentication)

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.

GET /v1/admin/reports (signed, admin or moderator)

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.

POST /v1/admin/reports/{id}/resolve (signed, admin or moderator)

{"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.

Counters and retention

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.