J3vbandClaude Fable 5 1e9ac9a842 test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol/auth/predicate-parity fuzz targets, make fuzz green (#1457)
* test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol + predicate-parity fuzz targets

Workstream 3. Every Fuzz* target `make fuzz` loops over now has a committed
corpus, so a plain `go test ./...` replays the real wire and not only the
hand-written f.Add shapes. 17 -> 20 targets, 3 -> 20 with a corpus, 98 corpus
files added.

Two new targets:

- ws/protocol_fuzz_test.go — FuzzHandleMessageDecode drives the inbound
  envelope decoder (handlers.go) through a headless NewHubForTest +
  NewTestClient; FuzzCommandPayloads drives all 24 payload decoders in
  commandConstructors, which are pure funcs of (userID, reqID, raw) and so
  need no hub at all. Between them they pin: a rejected frame yields no log
  fields and one invalid-count tick, an accepted frame yields the 64-byte
  capped fields and re-encodes to an equal envelope, a rejected payload never
  returns a command alongside its error, and a decoded command always carries
  the authenticated sender rather than a user id lifted from the payload.
- permissions/predicates_fuzz_test.go — FuzzPredicateParity continues the
  B2-5 parity tables by machine: each predicate against the two-layer
  override formula written out longhand, sentinel included (so "an
  unauthorized caller never learns a channel is archived" is pinned), plus
  CanAdmitSession == CanViewChannel and CanType == CanSendMessage.

Corpus entries are generated from protocol/fixtures/epoch-1 — every distinct
c2s frame of the 11 journeys for the two ws targets, and the role permission
values, channel types, message bodies, usernames, avatar URL and channel ids
those journeys carry for the rest. Replay costs <= 0.02s per target.

Test-only: no production file changes.

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

* docs(b3-6): evidence block for item 5 (fuzz seeds)

Seed counts per target, the two RED negative controls with their failing
excerpts, the replay wall clock, and — as the shared rules require — what was
found stale at HEAD for each of the item's four pointers and what was done
instead: the inbound decoders live in handlers.go/command.go not messages.go,
permissions.Subject has no wire form so parity replaces "round-trips", there
is no pure upload-admission function to fuzz, and there is no recovery-token
parser at all.

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

* test(b3-6): FuzzParseMentionTokens compares with db.LowerASCII, the OC-0131 rule — make fuzz green again

The target still asserted the Unicode fold (strings.ToLower) that OC-0131
removed from parseMentionTokens: usernames.username is COLLATE NOCASE, which
folds ASCII A-Z only, so the parser folds with db.LowerASCII to stay in step
with GetUserIDsByUsernames' equally ASCII-folded map key. Any mention of a
name starting with an uppercase non-ASCII letter (@Ǥ0, @Ł) therefore failed
the assertion, and `make fuzz` found one within four seconds.

The assertion now uses the same fold the code under test does. Nothing else
in the file changes, and no production behaviour is involved — the fold was
already correct; only the check disagreed with it.

30s of fuzzing on a cleared cache: PASS at 1,159,227 execs (it failed at
66,255 before).

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

* test(b3-6): fuzz seeds — every command constructor seeded, the auth payload decoder gets its own target, evidence corrected

commandConstructors registers 26 decoders, not the 24 the evidence block
claimed (the count missed the two E2EE keys), and only 16 had any input: ten
commands appear in no epoch-1 journey, so presence_update, call_ring,
call_decline, voice_token_refresh, voice_mute, voice_deafen, voice_camera,
voice_screenshare, voice_mod_deafen and voice_mod_kick were reachable only if
the fuzzer guessed the type string. Each now has a corpus entry carrying a
minimal valid payload taken from its own decoder struct, with the fixture
channel and user ids where they apply.

TestCommandPayloadSeedsCoverEveryConstructor is the guardrail that keeps that
true: it unions the hand-written seed list with the committed corpus and fails
when a registered command has neither, or when a seed names a command nothing
registers. Removing one corpus entry fails it by name.

auth was decoded by neither target. It is not in the constructor table —
authenticateConn reads it before the hub knows the client — so its two corpus
entries were inert under FuzzCommandPayloads. They move to FuzzAuthPayload,
which pins the property that matters in a handshake a stranger controls: no
numeric field takes a value its Go type cannot hold, and the token that will
be hashed is the string the JSON carried. The production decode is inline
behind a live socket read and a session lookup, so the target mirrors the
struct and the comment says why rather than reshaping production to expose it.

Corpus entries now credit the journey that owns the frame: the ping frame to
ping.json, the auth frame to fresh-connect.json.

Test-only: no production file changes.

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

* test(b3-6): fuzz seeds — auth target gates on the real epoch constants; corpus reader fails on a malformed entry

FuzzAuthPayload was proving encoding/json behaviour against a copy of the
handshake struct and nothing more. It now mirrors the two rejections
authenticateConn actually makes — the decode error and the empty token as one
(serve_auth.go:58), then the epoch window (:62) — using minClientEpoch and
ProtocolEpoch themselves, so moving either constant or that gate turns the
target red instead of leaving it quietly stale. The load-bearing case is the
absent epoch: every client up to v1.2.0-alpha.4 predates the field and relies
on the zero value being inside the window, so raising minClientEpoch above 0
now fails here rather than in the field. Setting it to 1 locally fails both
fixture-derived corpus entries and two seeds.

Deciding "absent" needed care, and fuzzing found that out in three seconds:
encoding/json falls back to a case-INSENSITIVE tag match, so "epoCh" populates
Epoch while an exact key lookup calls the field missing. The probe now decodes
into a *int, which is the same matching the server does, and three seeds pin
the rule.

corpusFirstString skipped a corpus file with no string(...) argument, which
would have let a malformed entry masquerade as a seeded command while the
coverage test still passed. It is now a failure naming the file.

The struct comment cited serve_auth.go:44; the struct starts at :45.

Test-only: no production file changes.

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

* test(b3-6): fuzz seeds — token expectation uses struct decoding semantics; parity oracle states the zero-permission ordering (Codex P2s on #1457)

FuzzAuthPayload derived the expected token from an exact key lookup, so
{"token":"a","TOKEN":"b"} failed the target: encoding/json resolves both keys
to the tagged field and the last one wins, leaving the handshake holding "b"
while the lookup expected "a". The expectation now comes from a probe struct
carrying the same json:"token" tag, so it follows the decoder's field
resolution rather than the raw key set — the same correction the epoch probe
already needed. A corpus entry pins it; reverting the probe fails on that
entry by name.

rawHas mirrors Subject.Has, which applies the Administrator bypass before the
zero-permission refusal, so an administrator holds an empty mask where
HasPerm(_, 0) is false. Parity with production is this target's purpose, so
the ordering stays; what changes is that the oracle's contract comment now
states it instead of claiming the tidier rule, and
TestSubjectHasZeroPermIsAdminBypassed records the divergence as observed
behaviour with a message that says to move both together if it is ever
changed deliberately.

The evidence block gains the call-site survey behind that: every leaf caller
of Subject.Has names a permissions.* constant, the variable-forwarding
wrappers are all reached with named constants, and the one table-driven site
has two rows.

No production code changed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:30:53 +00:00
2026-08-27 21:37:40 +02:00

CI Release Status Go Tauri Platforms License: AGPL-3.0

OwnCord

A self-hosted chat app I build for me and my friends — text channels, voice and video, and a server you actually own.

Alpha, and a hobby project. This is something I build for fun and run for a small group of friends. It isn't a product, it comes with no support commitment, and it isn't production-ready. Expect rough edges, rapid changes, and the occasional breaking change.

Don't use it for anything sensitive.

It's a Go server plus a Tauri desktop client: real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel. Run the server on a spare box or a VPS, hand your friends an invite code, and that's the whole thing.

OwnCord Client

Login Page Admin Panel

How it's built

Most of the implementation is generated with AI tooling, with quality held up by automated checks — CI, tests, linting — and by me and my friends actually using it. That keeps iteration fast, and it also means behaviour can change quickly between releases.

What works right now

Area Status
Core chat flow Working in alpha
Voice/video Working in alpha
Admin panel Working in alpha
Security hardening Ongoing review passes; findings and their statuses are tracked in the dated audits in docs/ (see the Docs Index below)

Platform Support (Current Releases)

Component Windows x64 Linux x64 Linux ARM64
Server binary Yes Yes Not yet
Desktop client Yes (NSIS installer) Yes (AppImage, .deb) Yes (AppImage, .deb)
Docker server N/A Build from source (compose) Not yet

Start Here

Quick Start

Option A: Prebuilt binaries

  1. Download assets from Releases (binaries, checksums, signatures, and a full source snapshot per release).
  2. Run the server binary:
    • Windows: chatserver.exe
    • Linux: ./chatserver
  3. Open https://localhost:8443/admin and complete the setup wizard — it creates your Owner account and configures the server for you (settings are saved to config.yaml automatically).
  4. Generate invite codes in the admin panel and share them with friends.

Option B: Docker (Linux server)

cd Server
cp .env.example .env
cp livekit.yaml.example livekit.yaml
# Edit both files before starting
docker compose up -d

See the full setup guide in docs/deployment.md.

The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections.

What OwnCord Already Has

  • Real-time channels and direct messages over WebSocket
  • Voice/video channels via LiveKit — the LiveKit server binary is downloaded and managed for you
  • Invite-only registration and role-based permissions
  • Web admin panel with logs, backups, and update tooling
  • File uploads and inline media rendering
  • TOTP 2FA support and API rate limiting
  • Desktop client auto-update with signature verification
  • WASM plugin system (slash commands; sandboxed, default-disabled — enable via plugins.enabled and build with -tags wazero)
  • GIF picker — off by default; each server supplies its own Klipy key via gif.api_key (setup)

See deeper feature and architecture docs in docs/architecture/ and docs/protocol.md.

Architecture

Two main components:

  • Go server (REST API, WebSocket hub, SQLite, admin panel)
  • Tauri v2 desktop client (Rust backend + TypeScript frontend)
+---------------------+         +---------------------+
|   OwnCord Client    |         |   OwnCord Server    |
|   (Tauri v2)        |         |       (Go)          |
|                     |         |                     |
|  +---------------+  |  WSS    |  +---------------+  |
|  |  Chat UI      |--+------->|  |  WebSocket Hub|  |
|  +---------------+  |         |  +---------------+  |
|  +---------------+  |  HTTPS  |  +---------------+  |
|  |  REST Client  |--+------->|  |  REST API     |  |
|  +---------------+  |         |  +---------------+  |
|  +---------------+  | LiveKit |  +---------------+  |
|  |  Voice/Video  |--+------->|  |  LiveKit SFU  |  |
|  +---------------+  |         |  +---------------+  |
+---------------------+         |  +---------------+  |
                                |  |  SQLite DB    |  |
                                |  +---------------+  |
                                +---------------------+

Build and Test

Prerequisites

  • Go 1.26+
  • Node.js 24+ (see Client/.nvmrc)
  • Rust stable (client builds)

Build from source

# Server (Windows)
cd Server
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.4" .

# Server (Linux)
cd Server
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.4" .

# Client
cd Client
npm install
npm run tauri build

Core verification commands

Everything CI gates on, from the repository root:

npm run check                  # server + client + Rust
npm run check:server           # or one stack at a time
node scripts/run.mjs --list    # exactly what each task runs, and where

Or run the stacks directly — the facade is a convenience, not the only path, and server work needs no Node at all:

# Server
cd Server
go test ./...

# Client
cd Client
npm run typecheck
npm run lint
npm test

For the full command set, use docs/contributing.md.

Configuration

On first run, the server generates config.yaml and a local data/ directory:

data/
├── chatserver.db
├── certs/
├── uploads/
└── backups/

Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions. See docs/server-configuration.md.

Security and Vulnerability Reporting

  • For vulnerabilities, use GitHub Security Advisories (private disclosure flow).
  • Do not open public issues for security bugs.
  • Read full policy and hardening notes in docs/security.md.

Update Signing Notes (Maintainers)

Client and server update signing keys are intentionally separate.

Required Actions secrets for release signing:

  • TAURI_SIGNING_PRIVATE_KEY
  • TAURI_SIGNING_PRIVATE_KEY_PASSWORD
  • SERVER_UPDATE_SIGNING_PRIVATE_KEY
  • SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD

When rotating the server updater key, update Server/updater/server_update_public_key.txt and use staged rollover for live fleets.

Docs Index

docs/README.md is the complete index — every document in docs/, grouped by whether it is guidance, a reference contract, a dated audit, or a plan. The most-used entries:

Audits are dated snapshots and are not maintained after the fact — read them as history. docs/README.md lists all nine, newest first.

Contributing

  1. Create a branch from dev (the active development branch).
  2. Keep changes focused and tested.
  3. Open a PR targeting devdev is merged to main for releases.

See docs/contributing.md for the full process.

Getting Help and Reporting Problems

Nothing here is a support promise — see the note at the top — but there is a right place for each kind of message:

Kind Where
A reproducible bug Issues
A question about setup or usage Discussions → Q&A
An idea or feature suggestion Discussions → Ideas
A security vulnerability Private advisory — never an issue

License

AGPL-3.0

S
Description
OwnCord is a self-hosted, open-source chat platform with text channels, voice/video chat, direct messages, and a desktop client — built for communities that want full control over their data.
Readme AGPL-3.0
111 MiB
Languages
TypeScript 48.7%
Go 44%
JavaScript 2.2%
Rust 2.1%
CSS 1.4%
Other 1.5%