Files
Toju/agents-docs/features/message-integrity.md
2026-06-05 18:34:01 +02:00

3.9 KiB

Message Integrity

Signed, append-only message revisions give P2P chat a verifiable history without central message storage. The materialized Message row in local SQLite/IDB is a cache; peers converge via inventory snapshots and revision events.

Responsibilities

  • Revision chain — Every create, edit, delete, moderation, or plugin mutation appends a MessageRevision with monotonically increasing revision, prevRevisionHash, and headHash.
  • Dual emit — Outgoing mutations broadcast the legacy event (chat-message, message-edited, message-deleted) and message-revision so older peers keep working while integrity-aware peers prefer revisions.
  • Inventory — Sync inventories include { id, ts, rc, ac, revision, headHash }. Peers re-fetch when remote revision is newer or the same revision has a different hash (tamper detection).
  • Signing — Human authors sign revisions with per-user Ed25519 keys. Public keys are registered on the signaling server; private keys stay in browser localStorage.

Boundaries

Layer Owns
Product client (toju-app) Revision construction, merge, verification, P2P broadcast, local persistence
Signaling server (server) PUT /api/users/me/signing-key, GET /api/users/:id/signing-public-key — key directory only, no message storage
Electron / mobile persistence revision + headHash on message rows; revision audit log (IDB store / SQLite meta)

Plugin API messages may emit unsigned revisions (plugin-edit / plugin-delete) when the actor is a synthetic plugin user.

Key types

  • Message.revision, Message.headHash — materialized cache fields on the shared Message model.
  • MessageRevision — wire + persistence audit record (message-revision.models.ts).
  • MessageRevisionTypecreate, author-edit, author-delete, moderate-edit, moderate-delete, plugin-edit, plugin-delete.
  • ChatEvent.type: 'message-revision' — P2P envelope carrying a full MessageRevision.

Merge rules

  1. Valid signed revision with higher revision wins over legacy timestamp edits.
  2. Same revision, different headHash → treat as stale/tampered and re-fetch.
  3. Unsigned revisions (no signature) are accepted for backward compatibility when verification is skipped.
  4. Legacy peers without revision/headHash in inventory fall back to ts / rc / ac comparison.

Client touchpoints

  • Domain rules: message-integrity.rules.ts, message-revision.builder.rules.ts, message-sync.rules.ts
  • Services: MessageRevisionService, MessageSigningService
  • Store: messages.effects.ts (outgoing dual-emit), messages-incoming.handlers.ts (handleMessageRevision), messages.helpers.ts (inventory + merge)
  • Plugins: plugin-client-api.service.ts emits revisions for send/edit/delete

Server API

Method Path Auth Body / response
PUT /api/users/me/signing-key Bearer { publicKeyJwk } — stores Ed25519 public JWK on the user row
GET /api/users/:id/signing-public-key Public { publicKeyJwk } — used by peers to verify signatures

Registration runs automatically after login/register via AuthenticationService.

Degraded-mode behavior

  • Outgoing revision signing is best-effort: if Ed25519 signing fails, the client still broadcasts the legacy chat-message envelope (unsigned revision).
  • Incoming signed revisions are accepted without cryptographic verification when the sender's public key is not yet registered on the server, so chat is not blocked during key-registration races.

Testing

  • Unit: message-integrity.rules.spec.ts, message-revision.builder.rules.spec.ts, message-revision-signing.rules.spec.ts, message-sync.rules.spec.ts, messages-incoming.handlers.spec.ts
  • Outgoing revision wiring is covered indirectly through existing message effect tests; add focused specs when changing merge or signing behavior.