docs: document IRC message protocol, signing, and canonicalization

- Add IRC command/numeric mapping tables (C2S, S2C, S2S)
- Document structured message bodies (array/object, never raw strings)
- Document RFC 8785 JCS canonicalization for deterministic hashing
- Document Ed25519 signing/verification flow with TOFU key distribution
- Document PUBKEY message type for public key announcement
- Update message examples to use IRC command format
- Update curl examples to use command-based messages
- Note web client as convenience UI; primary interface is IRC-style clients
- Add schema/ to project structure
This commit is contained in:
clawbot 2026-02-10 10:26:32 -08:00
parent 909da3cc99
commit 02acf1c919

248
README.md
View File

@ -20,7 +20,7 @@ This project builds a chat server that:
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
- Supports multiple concurrent connections per user session
- Provides IRC-like semantics: channels, nicks, topics, modes
- Uses structured JSON messages with arbitrary extensibility
- Uses structured JSON messages with IRC command names and numeric reply codes
## Architecture
@ -38,6 +38,151 @@ request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP.
The entire read/write loop for a client is two endpoints. Everything else is
channel management and history.
### Message Protocol
All messages use **IRC command names and numeric reply codes** from RFC 1459/2812.
The `command` field identifies the message type.
#### Message Envelope
Every message is a JSON object with these fields:
| Field | Type | Required | Description |
|-----------|-----------------|----------|-------------|
| `command` | string | ✓ | IRC command name or 3-digit numeric code |
| `from` | string | | Sender nick or server name |
| `to` | string | | Destination: `#channel` or nick |
| `params` | array\<string\> | | Additional IRC-style parameters |
| `body` | array \| object | | Structured body (never a raw string) |
| `meta` | object | | Extensible metadata (signatures, etc.) |
| `id` | string (uuid) | | Server-assigned message ID |
| `ts` | string | | Server-assigned ISO 8601 timestamp |
**Important:** Message bodies MUST be objects or arrays, never raw strings.
This enables:
- Multiline messages (array of lines)
- Deterministic canonicalization for hashing/signing (RFC 8785 JCS)
- Structured data where needed (e.g. PUBKEY)
#### IRC Command Mapping
**Client-to-Server (C2S):**
| Command | Description |
|----------|-------------|
| PRIVMSG | Send message to channel or user |
| NOTICE | Send notice (no auto-reply expected) |
| JOIN | Join a channel |
| PART | Leave a channel |
| QUIT | Disconnect from server |
| NICK | Change nickname |
| MODE | Set/query channel or user modes |
| TOPIC | Set/query channel topic |
| KICK | Kick a user from a channel |
| PING | Client keepalive |
| PUBKEY | Announce public signing key |
**Server-to-Client (S2C):**
All C2S commands may be echoed back as S2C (relayed to other users), plus:
| Command | Description |
|----------|-------------|
| PONG | Server keepalive response |
| PUBKEY | Relayed public key from another user |
| ERROR | Server error message |
**Numeric Reply Codes (S2C):**
| Code | Name | Description |
|------|-------------------|-------------|
| 001 | RPL_WELCOME | Welcome after registration |
| 002 | RPL_YOURHOST | Server host information |
| 322 | RPL_LIST | Channel list entry |
| 353 | RPL_NAMREPLY | Names list for a channel |
| 366 | RPL_ENDOFNAMES | End of names list |
| 372 | RPL_MOTD | Message of the day line |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 376 | RPL_ENDOFMOTD | End of MOTD |
| 401 | ERR_NOSUCHNICK | No such nick or channel |
| 403 | ERR_NOSUCHCHANNEL | No such channel |
| 433 | ERR_NICKNAMEINUSE | Nickname already in use |
**Server-to-Server (S2S):**
| Command | Description |
|---------|-------------|
| RELAY | Relay message to linked server |
| LINK | Establish server link |
| UNLINK | Tear down server link |
| SYNC | Synchronize state between servers |
| PING | Server-to-server keepalive |
| PONG | Server-to-server keepalive response |
#### Message Examples
```json
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"], "meta": {"sig": "base64...", "alg": "ed25519"}}
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"]}
{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]}
{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["alice", "bob", "@charlie"]}
{"command": "JOIN", "from": "bob", "to": "#general", "body": []}
{"command": "ERROR", "body": ["Closing link: connection timeout"]}
```
#### JSON Schemas
Full JSON Schema (draft 2020-12) definitions for all message types are in
[`schema/`](schema/). See [`schema/README.md`](schema/README.md) for the
complete index.
### Canonicalization and Signing
Messages support optional cryptographic signatures for integrity verification.
#### Canonicalization (RFC 8785 JCS)
To produce a deterministic byte representation of a message for signing:
1. Remove `meta.sig` from the message (the signature itself is not signed)
2. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785):
- Object keys sorted lexicographically
- No whitespace
- Numbers in shortest form
- UTF-8 encoding
3. The resulting byte string is the signing input
This is why `body` must be an object or array — raw strings would be ambiguous
under canonicalization.
#### Signing Flow
1. Client generates an Ed25519 keypair
2. Client announces public key: `{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}`
3. Server relays PUBKEY to channel members / stores for the session
4. When sending a message, client:
a. Constructs the message without `meta.sig`
b. Canonicalizes per JCS
c. Signs with private key
d. Adds `meta.sig` (base64) and `meta.alg`
5. Recipients verify by repeating steps ac and checking the signature
against the sender's announced public key
#### PUBKEY Message
```json
{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}}
```
Servers SHOULD relay PUBKEY messages to all channel members. Clients SHOULD
cache public keys and use them to verify `meta.sig` on incoming messages.
Key distribution is trust-on-first-use (TOFU) by default.
### Core Concepts
#### Users
@ -63,36 +208,6 @@ channel management and history.
- Channel history is stored server-side (configurable depth)
- No eternal logging by default — history rotates
#### Messages
Every message is a structured JSON object:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"ts": "2026-02-09T20:00:00.000Z",
"from": "nick",
"to": "#channel",
"type": "message",
"body": "Hello, world!",
"meta": {}
}
```
Fields:
- `id` — Server-assigned UUID, globally unique
- `ts` — Server-assigned timestamp (ISO 8601)
- `from` — Sender nick
- `to` — Destination: channel name (`#foo`) or nick (for DMs)
- `type` — Message type: `message`, `action`, `notice`, `join`, `part`, `quit`,
`topic`, `mode`, `nick`, `system`
- `body` — Message content (UTF-8 text)
- `meta` — Arbitrary extensible metadata (JSON object). Can carry:
- Cryptographic signatures
- Rich content hints (URLs, embeds)
- Client-specific extensions
- Reactions, edits, threading references
### API Endpoints
All endpoints accept and return `application/json`. Authenticated endpoints
@ -116,14 +231,14 @@ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-d '{"nick":"alice"}' | jq -r .token)
# Join a channel
curl -s -X POST http://localhost:8080/api/v1/channels/join \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-d '{"channel":"#general"}'
-d '{"command":"JOIN","to":"#general"}'
# Send a message
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-d '{"to":"#general","content":"hello world"}'
-d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}'
# Poll for messages (long-poll)
curl -s http://localhost:8080/api/v1/messages?after=0 \
@ -149,8 +264,8 @@ GET /api/v1/state — User state: nick, id, and list of joined channe
GET /api/v1/messages — Single message stream (long-poll supported)
All message types: channel, DM, notices, events
Query params: ?after=<message-id>&timeout=30
POST /api/v1/messages — Send a message
Body: { "to": "#channel" or "nick", "content": "..." }
POST /api/v1/messages — Send a message (IRC command in body)
Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] }
```
#### History
@ -187,8 +302,9 @@ POST /api/v1/federation/relay — Relay messages between linked servers
GET /api/v1/federation/status — Link status
```
Federation uses the same HTTP+JSON transport. Messages are relayed between
servers so users on different servers can share channels.
Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK,
UNLINK, SYNC, PING, and PONG commands. Messages are relayed between servers so
users on different servers can share channels.
### Channel Modes
@ -239,44 +355,31 @@ Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/s
```
chat/
├── cmd/
│ └── chat/
│ └── chatd/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── database/
│ │ └── database.go
│ ├── globals/
│ │ └── globals.go
│ ├── handlers/
│ │ ├── handlers.go
│ │ ├── auth.go
│ │ ├── channels.go
│ │ ├── federation.go
│ │ ├── healthcheck.go
│ │ ├── messages.go
│ │ └── users.go
│ ├── healthcheck/
│ │ └── healthcheck.go
│ ├── logger/
│ │ └── logger.go
│ ├── middleware/
│ │ └── middleware.go
│ ├── models/
│ │ ├── channel.go
│ │ ├── message.go
│ │ └── user.go
│ ├── queue/
│ │ └── queue.go
│ └── server/
│ ├── server.go
│ ├── http.go
│ └── routes.go
├── schema/
│ ├── message.schema.json
│ ├── c2s/
│ ├── s2c/
│ ├── s2s/
│ └── README.md
├── web/
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
├── CONVENTIONS.md → (copy from gohttpserver)
├── CONVENTIONS.md
└── README.md
```
@ -298,26 +401,27 @@ Per gohttpserver conventions:
### Web Client
The server embeds a single-page web client (Preact) served at `/`. This is a
**convenience/reference implementation** — not the primary interface. It
demonstrates the API and provides a quick way to test the server in a browser.
The primary intended clients are IRC-style terminal applications and bots
talking directly to the HTTP API.
**convenience/reference implementation** — not the primary interface. The
primary intended clients are IRC-style terminal applications, bots, and custom
clients talking directly to the HTTP API.
### Design Principles
1. **API-first** — the HTTP API is the product. Clients are thin. If you can't
build a working IRC-style TUI client in an afternoon, the API is too complex.
2. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol
2. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812.
Familiar to anyone who's built IRC clients or bots.
3. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol
negotiation. HTTP is universal, proxy-friendly, and works everywhere.
3. **Server holds state** — clients are stateless. Reconnect, switch devices,
4. **Server holds state** — clients are stateless. Reconnect, switch devices,
lose connectivity — your messages are waiting.
4. **Structured messages** — JSON with extensible metadata. Enables signatures,
rich content, client extensions without protocol changes.
5. **Simple deployment** — single binary, SQLite default, zero mandatory
5. **Structured messages** — JSON with extensible metadata. Bodies are always
objects or arrays for deterministic canonicalization (JCS) and signing.
6. **Simple deployment** — single binary, SQLite default, zero mandatory
external dependencies.
6. **No eternal logs** — history rotates. Chat should be ephemeral by default.
7. **Federation optional** — single server works standalone. Linking is opt-in.
7. **No eternal logs** — history rotates. Chat should be ephemeral by default.
8. **Federation optional** — single server works standalone. Linking is opt-in.
9. **Signable messages** — optional Ed25519 signatures with TOFU key distribution.
## Status