Files
claudemesh/packages/db/migrations/0026_topic_keys.sql
Alejandro Gutiérrez da5103a315
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
feat(broker+api): per-topic symmetric keys — schema + creator seal
Phase 2 (infra layer) of v0.3.0. Topics now generate a 32-byte
XSalsa20-Poly1305 key on creation; the broker seals one copy via
crypto_box for the topic creator using an ephemeral x25519
sender keypair (whose public half lives on
topic.encrypted_key_pubkey). Topic key plaintext leaves memory
immediately after the creator's seal — the broker can't read it.

Schema 0026:
  + topic.encrypted_key_pubkey (text, nullable for legacy v0.2.0)
  + topic_message.body_version  (integer, 1=plaintext / 2=v2 cipher)
  + topic_member_key            (id, topic_id, member_id,
                                 encrypted_key, nonce, rotated_at)

API:
  + GET /v1/topics/:name/key — return the calling member's sealed
    copy. 404 if no copy exists yet (joined post-creation, no peer
    has re-sealed). 409 if the topic is legacy unencrypted.

Open question parked: how new joiners get their sealed copy
without ceding plaintext to the broker. Spec at
.artifacts/specs/2026-05-02-topic-key-onboarding.md picks
member-driven re-seal (Option B). Pending-seals endpoint, seal
POST, and the actual on-the-wire encryption ship in phase 3.

Mention fan-out from phase 1 (notification table) is decoupled
from ciphertext, so /v1/notifications + MentionsSection keep
working unchanged through both phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:28:10 +01:00

45 lines
2.0 KiB
SQL

-- Per-topic symmetric encryption keys (v0.3.0 phase 2 — schema layer).
--
-- Each topic gets a freshly-generated 32-byte XSalsa20-Poly1305 symmetric
-- key. That key is encrypted once per topic member with libsodium
-- crypto_box (recipient pubkey + sender ephemeral keypair) so only the
-- intended member can decrypt their copy. Server stores ciphertext only;
-- it can no longer read message bodies.
--
-- Writes are versioned via topic_message.body_version:
-- 1 = legacy v0.2.0 base64-of-plaintext (still readable)
-- 2 = real ciphertext (sealed to the topic key, server-blind)
--
-- Old messages stay v1; new clients send v2. Mention fan-out is already
-- decoupled from ciphertext via the notification table (migration 0025),
-- so /v1/notifications keeps working through the cutover.
ALTER TABLE "mesh"."topic"
ADD COLUMN IF NOT EXISTS "encrypted_key_pubkey" text;
COMMENT ON COLUMN "mesh"."topic"."encrypted_key_pubkey" IS
'Ephemeral x25519 sender pubkey used to seal per-member copies of the topic symmetric key. Null = legacy v0.2.0 topic with no encryption.';
ALTER TABLE "mesh"."topic_message"
ADD COLUMN IF NOT EXISTS "body_version" integer NOT NULL DEFAULT 1;
CREATE INDEX IF NOT EXISTS "topic_message_by_version"
ON "mesh"."topic_message" ("body_version");
CREATE TABLE IF NOT EXISTS "mesh"."topic_member_key" (
"id" text PRIMARY KEY NOT NULL,
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
/** crypto_box ciphertext of the 32-byte topic key, sealed for this member. */
"encrypted_key" text NOT NULL,
/** 24-byte nonce used to seal `encrypted_key`. */
"nonce" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"rotated_at" timestamp
);
CREATE UNIQUE INDEX IF NOT EXISTS "topic_member_key_unique"
ON "mesh"."topic_member_key" ("topic_id", "member_id");
CREATE INDEX IF NOT EXISTS "topic_member_key_by_member"
ON "mesh"."topic_member_key" ("member_id");