* 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>
52 KiB
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 atServer/api/auth_handler.go:201andServer/migrations/018_api_tokens.sql:17(thesessions.tokencolumn name is historical;018_api_tokens.sql:9says 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 atServer/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
slogcall in server code logscontent;Server/db/logvalue.go:14,:26andServer/config/logvalue.go:57redact hashes and secrets from every log line. Tests:TestUserSessionRedactedInLogs,TestSecretConfigsRedactedInLogs. - Message bodies in the audit log —
Server/db/audittest/audittest.go:95AssertSafeDetailsfails 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; 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 §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_acceptedand the six rejection cases inws_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'sHostScopedVerifier(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, clientClient/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 §"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:
- 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.
- Parse in native code — only the permitted schemes and ports; reject embedded credentials and malformed authorities.
- 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; extendingipAllowedto match is a separate server change. - Connect only to the validated addresses, keeping the hostname for SNI
and certificate checks. No second unconstrained lookup after validation
(
host_http.go:182-216is the server-side shape). - Disable automatic redirects. Follow at most a small fixed number by hand, re-running steps 2–4 on every hop; reject scheme downgrades.
- Bound time, bytes and concurrency — a total deadline, a streaming byte
ceiling enforced while reading (a
Content-Lengthheader is not a limit), an allowed content-type list, and a cap on concurrent fetches. - Return a typed minimum — title, description, dimensions, and the preview image as bytes or an opaque local handle fetched by the broker under clauses 2–6 — 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.
- Narrow the renderer capability once the broker owns these requests:
the
https://*entry inClient/src-tauri/capabilities/default.jsonis 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.
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 |
| 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 INTOof the SQLite file only (Server/admin/handlers_backup.go:76-84,Server/db/admin_queries.go:404);upload.storage_dirmust be backed up separately or a restore loses every attachment. - See who is online, who is in which voice channel, session devices and IPs
(
sessionstable,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 §"Audit Logging", andTestAuditCoverage_*fails if a new admin mutation ships without one. - Install plugins, when the WASM runtime is compiled in and enabled —
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; testTestForceLogout_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; testTestDeleteAccount_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.
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/directoryroute makes it red; that run is in the B2-7 evidence block.TestAbsenceContract_NoFederationDirectoryOrListingWireTypes— every WebSocket message type inprotocol/schema.json, both directions.TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys— everykoanfkey ofconfig.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 50000–60000 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
50000–60000 (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.