Files
J3vbandClaude Fable 5 88c7a8249a docs(b2-7): trust model, absence proofs, plugin boundary (#1443)
* docs(b2-7): trust model — who can read what (BPR-050/051, C-09 contract)

One document states the operator trust model in plain language and traces
every claim to a code line or test: server-readable text and files and why,
E2EE voice/video/screen with the key-holder and TOFU rules, transport per TLS
mode with desktop pinning and the browser rule, the C-09 preview destination
contract B7 implements, at-rest storage, operator can/cannot, multi-device
sessions, and what beta does not claim. Linked from security.md,
deployment.md, quick-start.md and docs/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b2-7): absence proofs — no federation/directory routes, outbound host table

TestAbsenceContract_NoFederationDirectoryOrListingRoutes builds the production
router with uploads, voice and GIF on, walks the whole mounted tree with
chi.Walk and fails on any route matching federat|directory|discover|listing.
A floor on the route count and a check that the admin subtree was traversed
keep it from passing vacuously. trust-model.md gains "What OwnCord does not
have" (BPR-040/082/083) and the outbound-host table B6's network capture
checks against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-7): plugin boundary — off twice, compiled out of releases, no API promise

docs/architecture/plugins.md records the experimental WASM boundary (BPR-080/
081, BG-17): disabled by build tag and by config, absent from release.yml and
Dockerfile builds, the HP-2 configuration audit (fresh, upgraded, Docker,
standalone), the beta release-notes wording, what exists today with its limits
and tests, the post-beta plugin candidates that stay in core during beta, and
the core concerns that never move. Linked from architecture/README.md,
architecture/server.md and docs/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-7): L-08 re-tagged to B10 with the reason; B2-7 evidence block

L-08's remaining gate ("deterministic source build passes") cannot pass in
principle — TinyGo embeds host paths and has no -trimpath — and a compile-only
drift check would need a second Go SDK, TinyGo and Binaryen on every PR for a
subsystem release builds compile out. Re-tagged to B10, which runs the compile
once against the release candidate or closes on the provenance record; the
"no API promise" half is closed by docs/architecture/plugins.md. The plan's
B2-7 evidence block records the four pre-squash SHAs, the absence test's RED
output, the release-build finding, the decision, and the BPR-051 reader
placeholder. CHANGELOG gains a Documentation block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex review — TOFU windows disclosed, legacy TOTP, absence test at three boundaries

Two P1 and three P2 from the Codex review of 56f23a36, all verified against
the code. trust-model.md now states the desktop's first-connection TLS TOFU
window and the out-of-band fingerprint check; scopes identity-key pinning to
changes after the first pin, not first contact; discloses that databases from
before TOTP encryption may still hold plaintext secrets and how re-enrolment
fixes that. The absence contract gains two sibling tests — WebSocket wire
types from protocol/schema.json and every koanf key of config.Config (one
allowlisted on-disk path whose presence the test asserts) — and the document
states what the three tests bound. plugins.md corrected: unknown config keys
are warned about and ignored, not rejected. Evidence block records PR #1443
and the review outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex re-review — desktop first-use window in every TLS mode; tls off is plaintext

The desktop pins the fingerprint it sees on first connection in every
tls.mode (CaptureVerifier, no web-PKI validation on the server connection),
so a public-CA certificate closes the first-use window only for a browser;
the short answer and the pinning list now say so. tls.mode off served
directly is plaintext HTTP with nothing enforcing a proxy; the transport
table row states it. Evidence block records the round-2 outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 3 — E2EE does not survive a hostile operator on first contact; memory cap is server-wide

Identity keys are trusted on first use, so a modified server can deliver an
unpinned peer's first announce with keys the operator holds and the key
holder wraps the room key to it. trust-model.md now scopes E2EE to an
operator who reads, and to a modified server only for peers pinned and
compared out of band beforehand, in every place the stronger claim stood.
plugins.md: the wazero runtime is sized from plugins.max_memory_mb alone;
a manifest's memory value is validated but not applied. Evidence block
records the round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 4 — transport intro excludes tls off; ipAllowed is not the full C-09 deny-set

The transport section opened with "everything is TLS"; it now excludes
tls.mode off. The C-09 contract cited the server's ipAllowed as the complete
address deny-set; it rejects loopback, private, link-local, unspecified,
multicast and CGN only, so the clause now lists the documentation and
benchmarking ranges the native broker must add and records that widening
ipAllowed is a separate server change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 5 — backups exclude uploads; release-time appimagetool fetch listed

The built-in backup is VACUUM INTO of the SQLite file only; the trust model
and deployment.md now say uploaded files are not in it and upload.storage_dir
needs its own backup. The outbound-host section scopes "no script fetches an
external host" to scripts the server runs and lists the release workflow's
build-time appimagetool download.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 6 — one pin per account, LiveKit media in the host table, quick-start wording

Identity pins are keyed by host and user id, so a peer's second device
overwrites the pin and the first device then mismatches; the document no
longer claims per-device pinning. The outbound-host table gains the
supervised LiveKit subprocess's WebRTC media and scopes the capture contract
to traffic the server initiates. The quick-start cross-link no longer says
voice and video are unreadable by the operator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:49:24 +00:00

464 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Trust model — who can read what
**Kind:** reference. **Verified against:** `dev` @ `2b2d58ab`, 2026-08-29.
**Satisfies:** BPR-050, BPR-051 (plain disclosure of the operator trust
model); the C-09 contract for B7; the absence proofs for BPR-040/082/083.
Every claim below points at the code line or the test that makes it true, in
`path:line` form or by test name. If a claim and the code disagree, the code
is right and this document has a bug — file it like any other.
## The short answer
**Who can read my messages?**
- **You and everyone in the channel or DM** — that is what a chat server is
for.
- **The person who runs the server** (the _operator_) can read every text
message, every file, and every name and timestamp. Text and files are stored
on the server in plain form so it can deliver, search, moderate and back
them up. Nothing about OwnCord hides stored text or files from the machine
they are stored on.
- **Moderators** can read and delete messages in the channels their role
allows.
- **Nobody in between** — not your network, not a reverse proxy the operator
did not set up — because the connection is TLS, **with one window**: on the
desktop's very first connection to a server, the app shows the certificate
fingerprint and asks you to trust it. It does this for **every** kind of
certificate — self-signed or from a public CA — because the desktop pins
the fingerprint it sees rather than checking the certificate against the
public CA list (`Client/src-tauri/src/ws_proxy.rs:140-147`,
`Client/src-tauri/src/tofu.rs:72-111`). It cannot know whether that
fingerprint is the server's or an attacker's on the path. Compare it with
the fingerprint the operator gives you another way (chat elsewhere, a call)
before clicking; every later connection is then checked against that pin.
A public-CA certificate closes this window only for a browser, which trusts
its own CA list, and a server run with TLS switched off has no protection
on the wire at all (see "Transport").
- **Voice, video and screen share are different**: they are end-to-end
encrypted between the people in the call. The server passes the encrypted
media along and never has the key, so an operator who only reads what the
server stores or relays cannot listen in. An operator who **changes the
server** is a different matter: the server decides who is in a call, and
your client accepts any new participant's key the first time it sees it —
so a modified server can add a participant it controls and receive the
room key. Pinning catches a **known** person's key changing, not a new
person appearing. Today, E2EE media is not a defence against an operator
who modifies the server (see "What is end-to-end encrypted").
If the operator is someone you trust with your words, OwnCord is built to keep
everyone else out. If you do not trust the operator, do not type there.
OwnCord is one server run by one person or group; there is no company behind
it that can see your data.
## What the server can read, and why
The server sees text and files in the clear. Each reason maps to a feature that
would not work otherwise.
| Data | Stored as | Why the server needs it in the clear |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Channel messages | plain `content` column — `Server/migrations/001_initial_schema.sql:76`; written by `Server/db/queries/sqlite/messages.sql:2` | **Delivery** to members who were offline (replay), **edit** history, **search** |
| Direct messages | the same table — a DM is a `channels` row of type `dm` (`Server/migrations/009_dm_tables.sql:6`, body read at `Server/db/queries/sqlite/dm.sql:50`) | Same as channels. There is no separate, more private DM store |
| Uploaded files | the bytes you sent, unchanged, under `upload.storage_dir``Server/storage/storage.go:136`, `:157`; served by `Server/api/upload_handler.go:281` | **Delivery** with permission checks (`upload_handler.go:293`), size caps |
| Search index | SQLite FTS5 over message text — `Server/migrations/001_initial_schema.sql:87-91`; queried at `Server/db/message_queries.go:385` | **Search** (`GET /api/v1/search`, `Server/api/channel_handler.go:79`). Test: `TestSearchMessages_FindsMatch` |
| Names, times, who-is-where | `users`, `messages`, `sessions` rows (`001_initial_schema.sql:37-46`) | **Everything** — routing, permissions, unread counts |
| Backups | a full plain copy of the database via `VACUUM INTO``Server/db/admin_queries.go:404`; written to `backup.dir` (`Server/config/config.go:275`) | **Backup and restore** (`Server/admin/api.go:163-172`). There is no download endpoint; the backup lives on the operator's disk |
What the server does **not** keep in the clear:
- **Passwords** — bcrypt, cost 12: `Server/auth/password.go:38`, `:17`.
Test: `Server/auth/password_test.go`.
- **Session tokens and API tokens** — SHA-256 of the token, never the token:
`Server/auth/session.go:21-22`; stored hashed at `Server/api/auth_handler.go:201`
and `Server/migrations/018_api_tokens.sql:17` (the `sessions.token` column
name is historical; `018_api_tokens.sql:9` says what it holds). Test:
`Server/auth/session_test.go`, `Server/auth/resolve_test.go`.
- **2FA secrets** — encrypted with a server-local AES-256 key
(`Server/api/router.go:47`; enrolment encrypts at
`Server/api/totp_handler.go:323`). **Exception:** a database from before
that encryption landed may still hold plaintext secrets; nothing rewrites
them in place. The server returns such a value as-is and logs a warning on
every read (`Server/auth/totp_encrypt.go:109-117`). Disabling and
re-enabling 2FA on that account stores it encrypted.
- **Message bodies in logs** — no `slog` call in server code logs `content`;
`Server/db/logvalue.go:14`, `:26` and `Server/config/logvalue.go:57` redact
hashes and secrets from every log line. Tests:
`TestUserSessionRedactedInLogs`, `TestSecretConfigsRedactedInLogs`.
- **Message bodies in the audit log** — `Server/db/audittest/audittest.go:95`
`AssertSafeDetails` fails any audit row whose detail carries a body, token,
hash or secret. Tests: `TestAuditCoverage_ServiceMutations`,
`TestAuditCoverage_APIMutations`, `TestAuditCoverage_AdminMutations`,
`TestAuditCoverage_PluginLifecycle`.
Text chat is **not** end-to-end encrypted, on purpose. The client sends the
composer string as-is in `chat_send`
(`Client/src/pages/main-page/ChannelController.ts:259-262`); the only
cryptography in the client is the voice key exchange (`Client/src/lib/e2eeCrypto.ts`,
`livekitE2EE.ts`, `identity.ts`). This is the hybrid model in BPR-050, the same
trade Discord makes: server-readable text buys search, moderation, replay and
backup.
## What is end-to-end encrypted
Voice, video and screen share. The design is in
[architecture/voice-e2ee.md](architecture/voice-e2ee.md); the rules and their
tests:
| Rule | Code | Test |
| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The room key is made on a participant's machine, never on the server | `Client/src/lib/e2eeCrypto.ts:202-204` (`generateRoomKey`, WebCrypto random); called from `Client/src/lib/livekitE2EE.ts:223-227` | `livekit-e2ee.test.ts` "setupKeyExchange as key holder generates the room key and sends a signed announce" |
| One participant (the **key holder**, lowest user ID) wraps the key for each peer with ECDH + AES-GCM | `e2eeCrypto.ts:240` `wrapRoomKey`, `:269` `unwrapRoomKey` | `e2eeCrypto.test.ts` "round-trips a room key between two keypairs" |
| Media frames are encrypted before they leave the machine; the SFU (LiveKit) relays ciphertext | `Client/src/lib/livekitSession.ts:424-429`, `:439` `setE2EEEnabled(true)` | `livekit-session.test.ts` "enables E2EE on the room created by createRoom (OC-0095)" |
| The server relays wrapped keys as opaque bytes; it checks size and encoding, never decodes | `Server/ws/voice_e2ee.go:182-193`, `:225` (copied verbatim), `:239` (delivered only to a member of that voice channel) | `TestE2EE_Offer_KeyHolderCanSend`, `TestE2EE_Offer_RejectsNonKeyHolder`, `TestE2EE_Offer_TargetChannelCheckAtomicWithLookup` |
| The server holds no room key | Absence: the only E2EE state on the server is the public-key map and the key-holder map (`voice_e2ee.go:47`, `:77`, `:278`); no decrypt path exists | Pinned by the relay tests above; there is no positive "no key" assertion because there is nothing to assert on |
| Each user has a long-lived identity key; peers pin it on first sight (TOFU) and block on a change | `Client/src/lib/identity.ts:116`, `:158`; decision in `livekitE2EE.ts:520-562`, first-sight pin `:611-620` | `identity.test.ts` "getIdentityPin reports a store error as 'unavailable', never 'unpinned' (DC-08)"; `livekit-e2ee.test.ts` "[finding 2] marks a first-sight peer 'unverified'…" |
| Accepting a new key re-pins the key the human saw, not a fresh read the server could have swapped | `livekitE2EE.ts:671-677` `rePinPeerIdentity(userId, verifiedKey)` | `livekit-session.test.ts` "re-pins the verified key, not a store re-read a malicious server mutated (TOCTOU)" |
| When someone leaves, the key holder rotates the key, so the leaver cannot decrypt what comes next | `livekitE2EE.ts:1235-1246` `handleParticipantLeft`, `:1217-1233` `rotateRoomKey` | `livekit-session.test.ts` "rotates the room key when a keyed peer leaves while I stay key holder (forward secrecy)" |
| The key also rotates on a timer while the call runs | `livekitE2EE.ts:1443` `rotateKeyPeriodically` | `livekit-e2ee.test.ts` "[T-47] arms the periodic rotation timer for the key holder…" |
| A reconnect during a rotation gets the current key, not a stale one | `Server/ws/hub.go:664-681` (re-announce on resume, OC-0316) | `TestRegisterNow_ReannouncesOwnKeyOnResume`; client `"[OC-0007] confirms the room key after a reconnect re-announce…"` |
| The server still decides **who may join and publish** — E2EE hides content, not membership | `Server/ws/livekit.go:90` `GenerateToken`, `:110-135` per-permission `CanPublishSources`; `Server/ws/voice_join.go:382` | `TestE2EE_VoiceToken_IncludesIsKeyHolder`; `Server/ws/voice_moderation_overrides_test.go` |
What E2EE does **not** hide from the operator: who is in which voice channel,
when, for how long, and who is muted or deafened. Those are server state.
The identity-key store is the trust root. On the desktop the private key
lives beside the session token in the OS keychain, one slot per account per
server — keychain account `identity:{userId}@{host}`
(`Client/src/lib/identity.ts:221-222`, `Client/src-tauri/src/credentials.rs:214-220`;
`identity:{host}` is the legacy slot that entry was migrated from). Its
protection is **trust on first use**, and that is the
limit: the first time your client meets a peer it has no pin, so it accepts
the public key the server delivers, checks that the announce is signed by
that key, and pins it (`Client/src/lib/livekitE2EE.ts:611-620`). A malicious
operator who substitutes a key at that first contact is not detected by the
pin. What the pin catches is any **later** change to a peer you have already
pinned — that is when the mismatch modal blocks, and why a "Trust new key"
click should be rare and deliberate. To close the first-contact window,
compare identity-key fingerprints out of band (the verification surface in
[architecture/ux/voice-and-e2ee.md](architecture/ux/voice-and-e2ee.md) §7
shows them). Pins are **one per account**, keyed `{host}:{userId}`
(`Client/src/lib/identity.ts:11-12`; `livekitE2EE.ts:521`, `:612`), while
identity keys are per install — so a peer's second device shows up as a
mismatch of the same account, trusting it overwrites the pin, and their first
device then mismatches in turn. Until a stable device identifier exists,
expect that flip-flop with multi-device peers and verify each key out of
band.
## Transport
In every `tls.mode` except `off`, everything between client and server is
TLS; `off` served directly is plaintext, and is only safe behind a
TLS-terminating reverse proxy the operator controls (its row below). Which
certificate, and how the client decides to trust it:
| Server `tls.mode` (`Server/config/config.go:255-262`, semantics `Server/auth/tls.go:87-117`) | Certificate | Desktop client | Browser client (B8, does not exist yet) |
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `self_signed` (default, `config.go:316`) | generated on first run | **TOFU pinning.** First connect shows the fingerprint and asks; the pin is stored (`Client/src-tauri/src/ws_proxy.rs:379-425`, only writer) and every later connection must match (`Client/src-tauri/src/tofu.rs:132-166`) | Must use a publicly trusted or locally installed CA certificate; no pinning |
| `acme` | Let's Encrypt via `autocert` (`Server/auth/tls.go:164-193`) | Pinned the same way | Trusted by the browser's CA store |
| `manual` | operator-supplied files | Pinned the same way | Trusted if the CA is |
| `off` | none. **Served directly, every connection is plaintext HTTP** — passwords, tokens and messages are readable by anyone on the path (`Server/auth/tls.go:94-95`, `Server/main.go:636-639`). Nothing in the server enforces a proxy; the operator must put a TLS-terminating reverse proxy in front and expose only that | Pins the proxy's certificate (behind a proxy); no protection without one | Trusted if the proxy's CA is (behind a proxy); plaintext without one |
Desktop pinning details, each with its test:
- The pin is the SHA-256 of the leaf certificate (`tofu.rs:25`); a mismatch
rejects the connection before the auth frame or any WebSocket payload is
sent (`tofu.rs:157-163`). The WebSocket upgrade request itself — path and
headers, which carry no credential, since the token travels in the first
frame — does reach the peer before the verdict
(`Client/src-tauri/src/ws_proxy.rs:148-178`).
Tests: `decide_first_use_when_no_pin`, `decide_trusted_when_pin_matches`,
`decide_mismatch_when_pin_differs`, `capture_verifier_records_leaf_not_intermediate`.
- First use also rejects: the app shows the fingerprint, and only an explicit
accept writes a pin (`tofu.rs:6-10`, `:380-381` "deciding never writes a
pin"). Tests: `valid_fingerprint_is_accepted` and the six rejection cases in
`ws_proxy.rs:440-503`.
- The first-use prompt is the same in every `tls.mode`: the desktop does
not validate a public-CA certificate against the CA list on the OwnCord
connection — it pins what it sees (`ws_proxy.rs:140-147`,
`tofu.rs:72-111`). Web-PKI validation exists only in the updater's
`HostScopedVerifier` (`tofu.rs:191-215`) for the GitHub download, not for
the server connection. Out-of-band fingerprint comparison is therefore the
only first-contact defence on the desktop, whatever certificate the server
has.
- All three native tunnels (WebSocket, HTTP, LiveKit) use the same verifier:
`ws_proxy.rs:207`, `http_proxy.rs:405`, `livekit_proxy.rs:448`.
- The session token travels inside the first WebSocket frame, never in the
URL: server `Server/ws/serve_auth.go:26-61`, client `Client/src/lib/ws.ts:546`
(path only) and `:447-455` (auth frame). Test: `ws-lifecycle.test.ts`
"sends auth message when Rust reports open".
A browser cannot inspect or pin a certificate. The browser adapter (B7/B8)
must require a certificate the browser already trusts and say so, not fake the
pinning flow — [architecture/platform-contracts.md](architecture/platform-contracts.md)
§"Hard cases" is the binding statement.
## Desktop preview destination policy (C-09) — contract for B7
Link previews, avatars and external inline images make the desktop client
fetch URLs that other users chose. Today those fetches are made from the
renderer through the Tauri HTTP plugin and the webview's own image loading,
with destination policy applied in TypeScript per call site rather than in
one place (`Client/src/components/message-list/embeds.ts`, `attachments.ts`,
`media.ts`; `docs/security.md` §"Tauri Capabilities") — which is the C-09
finding as publicly recorded: the policy is **not centralised at the native
boundary**, and not every automatic fetch path applies the same checks. Until
B7 lands, treat every automatic remote fetch on the desktop as governed by
the capability scope and CSP only. The B7 platform seam replaces that with
**one native fetch broker** that owns the whole policy. This is the
contract B7 implements; it is written here so B7 does not rediscover it.
The broker MUST:
1. **Own every automatic remote fetch** — previews, avatars, external images,
OG images, and any future automatic media path. Renderer code gets no
general-purpose HTTP client.
2. **Parse in native code** — only the permitted schemes and ports; reject
embedded credentials and malformed authorities.
3. **Resolve, then classify every answer** — all A and AAAA records,
normalised (IPv4-mapped IPv6 included). Reject if **any** address is
loopback, private, link-local, unspecified, multicast, carrier-grade NAT,
documentation, benchmarking, or otherwise non-global. The server's
`ipAllowed` (`Server/plugin/host_http.go:224-249`) is the starting point,
not the whole list: it rejects loopback, private, link-local, unspecified,
multicast and carrier-grade NAT, and **does not** reject the documentation
ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`,
`2001:db8::/32`), the benchmarking range (`198.18.0.0/15`) or the other
reserved non-global blocks. The broker adds those; extending `ipAllowed`
to match is a separate server change.
4. **Connect only to the validated addresses**, keeping the hostname for SNI
and certificate checks. No second unconstrained lookup after validation
(`host_http.go:182-216` is the server-side shape).
5. **Disable automatic redirects.** Follow at most a small fixed number by
hand, re-running steps 24 on every hop; reject scheme downgrades.
6. **Bound time, bytes and concurrency** — a total deadline, a streaming byte
ceiling enforced while reading (a `Content-Length` header is not a limit),
an allowed content-type list, and a cap on concurrent fetches.
7. **Return a typed minimum** — title, description, dimensions, and the
preview image **as bytes or an opaque local handle fetched by the broker
under clauses 26** — never a remote image URL for the renderer to load
itself (a second, un-brokered request would bypass every control above),
and never raw status, headers or bodies to message-controlled code.
8. **Narrow the renderer capability** once the broker owns these requests:
the `https://*` entry in `Client/src-tauri/capabilities/default.json` is a
URL-pattern control, not a DNS control, and stays only as defence in depth.
Regression coverage B7 owes: names resolving into each blocked class, a public
name redirecting to a private target, mixed answer sets, CNAME chains, an
address that changes between validation and connect, and boundary tests for
the redirect count, deadline, byte ceiling, content types and TLS validity —
against the real preview, avatar and external-image entry points.
Status: contract (this document, B2-7); implementation B7; tracked as C-09 in
[plans/repo-health-issue-register-2026-08-23.md](plans/repo-health-issue-register-2026-08-23.md).
## At rest
| Where | What | Protection |
| -------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Server database | SQLite file in `server.data_dir` | **Not encrypted.** Plain `modernc.org/sqlite` driver, no cipher (`Server/db/db.go:17`, `:219`, DSN pragmas `:62-68`). Filesystem permissions and full-disk encryption are the operator's job |
| Server uploads | plain files under `upload.storage_dir` | Same |
| Server backups | plain database copies under `backup.dir` | Same — treat the backup directory as the database. Uploads are **not** included; back up `upload.storage_dir` alongside it |
| Server secrets | password hashes, token hashes, encrypted TOTP secrets | See "What the server does not keep in the clear" above |
| Desktop session + identity | OS keychain via the `keyring` crate: Windows Credential Manager, macOS Keychain, Secret Service | Every write is read back and verified (`Client/src-tauri/src/secret_store.rs:94`). If the keychain fails the round trip, a sealed file (DPAPI on Windows, ChaCha20-Poly1305 elsewhere) takes over until it works again — [credential-storage.md](credential-storage.md) |
| Desktop certificate pins | `certs.json` in the app data dir (`Client/src-tauri/src/constants.rs:2`) | Plain file; a user who can edit it can re-pin, which is the same user who can click "trust" |
| Desktop identity pins | `identity_pins.json` (`Client/src/lib/identity.ts:11-12`) | Plain file; same reasoning. The identity _private_ key is in the keychain, not here |
## What the operator can and cannot do
Can, by design:
- Read, search and delete any text or file on the server; export the text
via the database backup. **Uploaded files are not in that backup** — it is
`VACUUM INTO` of the SQLite file only (`Server/admin/handlers_backup.go:76-84`,
`Server/db/admin_queries.go:404`); `upload.storage_dir` must be backed up
separately or a restore loses every attachment.
- See who is online, who is in which voice channel, session devices and IPs
(`sessions` table, `001_initial_schema.sql:37-46`).
- Ban, force-logout (`Server/admin/api.go:114`), change roles, and change any
setting. Each of these writes an audit row — the list is in
[security.md](security.md) §"Audit Logging", and `TestAuditCoverage_*`
fails if a new admin mutation ships without one.
- Install plugins, when the WASM runtime is compiled in and enabled —
[architecture/plugins.md](architecture/plugins.md). Off by default
(`Server/config/config.go:352`).
Cannot, and the code is what stops them:
- Read a voice, video or screen-share stream from what the server stores
or relays (see "What is end-to-end encrypted"). The active attack — a
modified server adding a participant it controls — is in the next
paragraph, and it works.
- Recover a password (bcrypt) or a live session token (hashed) from the
database.
- Impersonate a user's E2EE identity to a peer who has already pinned it — the
peer's client blocks with a mismatch modal.
- Read a user's messages on another OwnCord server: each server is an island
(next section).
Can, with effort outside the code — and this is the honest boundary: an
operator with shell access can edit the binary, the database or the
configuration. No control in this document survives a hostile operator with
root on the box, **E2EE media included**. Call membership is
server-controlled and unauthenticated: a modified server can add a member —
an invented user id, or a real peer's first contact — with an identity key
and an ephemeral key the operator holds, the client accepts and pins any
first-sight identity (`Client/src/lib/livekitE2EE.ts:603-638`), and the key
holder wraps the room key to it (`:842-912`). Pinning every legitimate peer
and comparing keys out of band does not close this: pins detect a **known**
peer's key changing, not an unknown member joining. What E2EE gives today is
exact and narrower: the server never holds the room key, so an operator who
**reads** — database, logs, relayed frames, backups — gets nothing, and a
known peer's key cannot be swapped without the mismatch modal. A defence
against a modified server needs authenticated membership or the client
refusing unrecognised participants; neither exists in beta (see "What beta
does not claim").
## Multi-device sessions
- A user may hold up to 25 sessions; the 26th evicts the oldest
(`Server/db/auth_queries.go:237`, `:249-251`).
- Each session records device, IP, creation, last use and expiry
(`001_initial_schema.sql:37-46`).
- Users list and revoke their own sessions: `GET`/`DELETE /api/v1/users/me/sessions`
(`Server/api/profile_handler.go:92-93`); revocation is scoped to the calling
user (`Server/service/user.go:366`).
- A password change revokes every other session (`Server/service/user.go:336`).
- Admin force-logout revokes all of a user's sessions
(`Server/admin/api.go:114`; test `TestForceLogout_AuthorizationMatrix`).
- Voice E2EE identity is per install, but peers keep **one pin per account**
(`{host}:{userId}`, `Client/src/lib/identity.ts:11-12`): your second device
appears to them as a key change, trusting it replaces the pin, and your
first device then triggers the mismatch modal. Not per-device pinning.
## What beta does not claim
- **No deniability, no metadata privacy.** The operator sees who talked to
whom and when.
- **No encrypted text.** If that changes it is a new protocol epoch, a new
document, and a new trade-off against search, moderation and replay — not a
toggle.
- **No protection against a hostile operator, E2EE media included.** E2EE
resists an operator who reads. A modified server controls call membership
and can add a participant it holds the keys for; pins do not cover a member
that was never a known peer. Authenticated membership is not in beta.
- **No secure deletion.** Account deletion blanks message bodies and
anonymises the name (`Server/db/account.go:116`, `:131`; test
`TestDeleteAccount_AnonymisesUsername`), but backups taken before the
deletion still hold the text, and SQLite does not scrub freed pages.
- **No browser client yet**, and when there is one it will not pin
certificates.
- **No stable plugin API** — [architecture/plugins.md](architecture/plugins.md).
## What OwnCord does not have
Stated so the absence is a promise, not an accident (BPR-040, BPR-082,
BPR-083):
- **No federation and no cross-server messaging.** Servers never talk to each
other. There is no protocol for it and no route that would carry it.
- **No directory, discovery or public listing.** Nobody can find your server
unless you give them the address or an invite link.
- **No global identity.** An account is a row in one server's database
(`users`, `Server/migrations/001_initial_schema.sql`). The same username on
two servers is two unrelated people; nothing links them, and a ban, a role or
a friend on one means nothing on the other. Even the voice E2EE identity is
pinned per host (`Client/src/lib/identity.ts:11-12`, key `{host}:{userId}`).
- **No required external service.** A server on a LAN with no internet works.
Every outbound connection it can make is in the next table, each with the
condition that triggers it and the control where one exists. Two rows have
a condition but no configuration switch: GitHub release metadata (only on
request, never on a timer) and LiveKit signalling (only on a voice join,
to `voice.livekit_url`).
The proof is three tests in `Server/api/absence_contract_test.go`, one per
boundary a new feature has to cross, each failing on the pattern
`federat|directory|discover|listing` and each with a floor on how much it
inspected so it cannot pass by looking at nothing:
- `TestAbsenceContract_NoFederationDirectoryOrListingRoutes` — builds the
production router with every optional route family on and walks the whole
mounted tree, admin and plugin subrouters included. Mounting a `/directory`
route makes it red; that run is in the B2-7 evidence block.
- `TestAbsenceContract_NoFederationDirectoryOrListingWireTypes` — every
WebSocket message type in `protocol/schema.json`, both directions.
- `TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys` — every
`koanf` key of `config.Config`, with one allowlisted on-disk path
(`plugins.directory`).
What they prove is bounded and stated: they pin **vocabulary at the three
boundaries**, not semantics. A feature smuggled in under a neutral name would
pass them. The network boundary is covered differently — by the outbound-host
table below, which B6 checks against a packet capture — and the review rule is
the last line of defence: a route, message type or config key that
legitimately needs one of those words, or any feature that makes the server
talk to another server, must update this section and that table first.
## Outbound connections the server makes
For B6's network capture: everything the server process reaches out to, from
a read of every `http.Client`, `net.Dial` and URL literal in non-test server
code, plus the one companion process the server supervises. Traffic the
server **serves** — responses on `server.port` to clients it did not initiate —
is not in the table; the capture filters it by direction. **DNS** is not a
row either: every hostname below is resolved through the host's configured
resolver first, so queries to that resolver (and nothing else on port 53)
are expected and environment-dependent; the capture filters them by
destination. Anything else on the wire is a finding.
| Host | When | Why | Off switch | Code |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `api.github.com` | on request to `/api/v1/client-update/…` or the admin update panel; cached 1 h | latest-release metadata for client and server updates | none needed — never on a timer; `github.owner`/`github.repo` pick the repo | `Server/updater/updater.go:22`, `:224-233`; `Server/api/client_update.go:34`; `Server/admin/update_handlers.go:24` |
| `github.com` (+ `*.githubusercontent.com` on redirect) | admin clicks "update server" | download and verify the signed server release | do not click; URL is prefix-checked against the configured repo | `Server/updater/download.go:21`, `:265-273`; `Server/updater/assets.go:164-174` |
| `github.com/livekit/livekit/releases/download` (+ the GitHub asset hosts on redirect, `objects.githubusercontent.com` / `release-assets.githubusercontent.com`, as in the updater row) | startup, only if `voice.auto_download_livekit` and no `voice.livekit_binary` | fetch the pinned, checksum-verified `livekit-server` binary ; fetched with `http.DefaultClient`, which follows redirects (`Server/ws/livekit_download.go:271-300`) — the pinned SHA-256 bounds what is accepted, not the host | `voice.auto_download_livekit: false` or set `voice.livekit_binary` | `Server/ws/livekit_download.go:31`, `:36`, `:272-300`; `Server/ws/livekit_process.go:230` |
| `api.klipy.com` | a user opens the GIF picker | GIF search/trending, proxied so the API key stays on the server | leave `gif.api_key` empty (default) — endpoints answer 503 | `Server/api/gif_handler.go:34`, `:56-62`; `Server/config/config.go:68-76` |
| STUN and cloud-metadata endpoints — external-address discovery by the **LiveKit subprocess** the server supervises | LiveKit start-up, because the generated config sets `use_external_ip: true` unconditionally | learn the public address to advertise in ICE candidates | not running LiveKit (as below); setting `voice.node_ip` advertises that address but does not turn discovery off (`Server/ws/livekit_process.go:130-133`) | `Server/ws/livekit_process.go:130-133`; `Server/config/config.go:160` |
| participants' addresses — WebRTC media from the **LiveKit subprocess** the server supervises, UDP 5000060000 and TCP 7881 | a call is in progress | encrypted media frames (E2EE); ICE/TURN | not running LiveKit (leave `voice.livekit_binary` unset and `voice.auto_download_livekit: false`) or hosting it elsewhere | `Server/ws/livekit_process.go:123-144` (`port_range_start`/`end`); ports in `docs/deployment.md` §Firewall and Ports |
| LiveKit at `voice.livekit_url` (default `ws://localhost:7880`) | a user joins voice; health probe of the supervised process | media SFU signalling | **none today**: empty credentials are replaced by random ones and an empty URL defaults to `ws://localhost:7880` (`Server/config/config.go:645-662`), so a voice join always attempts `voice.livekit_url`; with no LiveKit running the attempt fails on loopback. A real `voice.enabled` switch is a server change | `Server/api/livekit_proxy.go:23-28`; `Server/ws/livekit_process.go:47-52`, `:361-366` |
| hosts on `plugins.http_allowlist` | an installed plugin with the `http` capability calls out | plugin feature | empty by default; plugins off by default (`Server/config/config.go:352`, `:356`) | `Server/plugin/host_http.go:66`, `:136-155` |
| `acme-v02.api.letsencrypt.org` | startup and renewal, only when `tls.mode: acme` | certificate issuance | any other `tls.mode` (default `self_signed`) | `Server/auth/tls.go:164-193` |
| operator's OTLP collector (`telemetry.otlp_endpoint`) | startup, only when `telemetry.exporter: otlp` | traces and metrics to a collector the operator runs | `telemetry.exporter: none` (default) | `Server/config/config.go:107-114`; `Server/main.go:373` |
| `8.8.8.8:80` (UDP, **no packet is sent**) | startup banner | asks the OS which local address routes out, to print the LAN URL | none | `Server/main.go:1005-1012` |
| `127.0.0.1:<port>/health` | `healthcheck` subcommand (Docker `HEALTHCHECK`) | liveness probe of itself | n/a — loopback | `Server/main.go:748-757` |
Not in the table because it does not exist: analytics, crash reporting,
telemetry to the project, licence checks, a phone-home of any kind. The only
`telemetry` package is OpenTelemetry instrumentation whose exporter defaults to
`none`. The Docker image is distroless with no shell or `curl`
(`Server/Dockerfile:30`), and no tracked script the **server runs** fetches
an external host. One build-time exception, outside the server: the release
workflow's AppImage step downloads `appimagetool` from GitHub
(`Client/scripts/strip-appimage-bundled-libs.sh:25-26`, invoked by
`.github/workflows/release.yml`). That runs on the CI runner when a release
is cut, never on an operator's machine.
Two of the GitHub paths (`updater.go`, `livekit_download.go`) use a plain
`http.Client` against fixed, prefix-validated GitHub URLs; the Klipy proxy and
plugin HTTP go through the guarded dialer that refuses private and loopback
answers (`Server/plugin/host_http.go:182-249`; tests
`TestGuardedDial_FallsBackAcrossVettedIPs`,
`TestGuardedDial_PrivateRecordRefusesBeforeAnyDial`).
The **desktop client** reaches, on its own: the server; LiveKit **signalling**
through the server's `/livekit/*` proxy for remote servers, or **directly** to
the LiveKit URL the server hands out when the server is local
(`Client/src/lib/livekitSession.ts:711-738`, `direct_url`); LiveKit **media**
always directly, to the SFU's advertised ICE endpoints on TCP 7881 / UDP
5000060000 (`Server/ws/livekit_process.go:130-133`; `docs/deployment.md`
§Firewall and Ports); `www.youtube.com` and `img.youtube.com` for video
embeds (`Client/src/components/message-list/media.ts:173-174`, `:210`, `:229`);
the Klipy CDN for GIF media; GitHub for its own updates via the server's
`client-update` endpoint; and any URL a user posted, for link previews — the
C-09 contract above governs that last one.
## How this document is kept true
- HP-2 question 3 requires every claim above to trace to a test or a code
line; the anchors are that trace. Update them when the code moves.
- The absence proofs (federation, directory) are a test that fails when a
matching route appears; see "What OwnCord does not have".
- BPR-051's exit evidence is one non-developer reading "The short answer" and
answering "who can read my messages?" correctly — recorded in the B2-7
evidence block of
[plans/b2-protocol-trust-compat-2026-08-28.md](plans/b2-protocol-trust-compat-2026-08-28.md).