feat(broker+api+cli): per-topic E2E encryption — v0.3.0 phase 3 (CLI)
Wire format:
topic_member_key.encrypted_key = base64(
<32-byte sender x25519 pubkey> || crypto_box(topic_key)
)
Embedding sender pubkey inline lets re-sealed copies (carrying a
different sender than the original creator-seal) decode the same
way as creator copies, without an extra schema column or join.
topic.encrypted_key_pubkey stays for backwards-compat metadata
but the wire truth is the inline prefix.
API (phase 3):
GET /v1/topics/:name/pending-seals list members without keys
POST /v1/topics/:name/seal submit a re-sealed copy
POST /v1/messages now accepts bodyVersion (1|2); v2 skips the
regex mention extraction (server can't read v2 ciphertext).
GET /messages + /stream now return bodyVersion per row.
Broker + web mutations updated to use the inline-sender format
when sealing. ensureGeneralTopic (web) also generates topic keys
per the bugfix that landed earlier today; both producers now
share one wire format.
CLI (claudemesh-cli@1.8.0):
+ apps/cli/src/services/crypto/topic-key.ts — fetch/decrypt/encrypt/seal
+ claudemesh topic post <name> <msg> — encrypted REST send (v2)
* claudemesh topic tail <name> — decrypts v2 on render, runs a
30s background re-seal loop for pending joiners
Web client stays on v1 plaintext until phase 3.5 (browser-side
persistent identity in IndexedDB). Mention fan-out from phase 1
already works for both versions, so /v1/notifications keeps
working through the cutover.
Spec at .artifacts/specs/2026-05-02-topic-key-onboarding.md
updated with the implemented inline-sender format and the
phase 3.5 web plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,43 @@ ephemeral pubkey. Either:
|
|||||||
Decoder pulls the prefix, uses it as the sender pubkey. No schema
|
Decoder pulls the prefix, uses it as the sender pubkey. No schema
|
||||||
change beyond what 0026 already ships.
|
change beyond what 0026 already ships.
|
||||||
|
|
||||||
(b) wins on simplicity. Phase 2 implementation uses it.
|
**(b) wins on simplicity. Phase 3 implementation ships it. Both the
|
||||||
|
broker creator-seal and the CLI re-seal write the
|
||||||
|
`<32-byte sender pubkey><cipher>` blob.** `topic.encrypted_key_pubkey`
|
||||||
|
becomes informational only — the wire-format truth is the inline prefix.
|
||||||
|
|
||||||
|
## Web client gap (phase 3.5)
|
||||||
|
|
||||||
|
The CLI side of phase 3 ships in this cut. The web side does NOT —
|
||||||
|
because web member rows have `peerPubkey` registered server-side but
|
||||||
|
the corresponding ed25519 SECRET is discarded immediately after
|
||||||
|
generation (see `mutations.ts:createMyMesh`). Without the secret the
|
||||||
|
browser can't `crypto_box_open` its sealed topic key.
|
||||||
|
|
||||||
|
Three fixes, in increasing order of effort:
|
||||||
|
|
||||||
|
1. **Browser-side persistent identity (recommended)** — generate an
|
||||||
|
ed25519 keypair in the browser on first dashboard visit, store the
|
||||||
|
secret in IndexedDB, sync the public half to `mesh.member.peerPubkey`
|
||||||
|
via a new `POST /v1/me/peer-pubkey` endpoint. Topic keys then seal
|
||||||
|
to the new pubkey; web user decrypts locally. Existing #general
|
||||||
|
topics need a re-seal cycle (the v0.3.0 phase-3 re-seal loop in
|
||||||
|
the CLI already does this for any pending member, including web
|
||||||
|
ones). Spec lift: ~3 hours, mostly browser code + a sync endpoint.
|
||||||
|
|
||||||
|
2. **Server-held secret** — keep the member's ed25519 secret server-
|
||||||
|
side. Trivial to implement, but the broker can read everything,
|
||||||
|
defeating the security claim. **Rejected.**
|
||||||
|
|
||||||
|
3. **JWT-derived keys** — derive the member's keypair from a stable
|
||||||
|
user-secret (e.g. PBKDF2 over their session JWT). Means cross-
|
||||||
|
device same key, but needs the JWT to include ~32 bytes of stable
|
||||||
|
key material. Tied to v2.0.0 daemon redesign. **Deferred.**
|
||||||
|
|
||||||
|
Phase 3 ships option 1 deferred; web stays on v1 plaintext until 3.5.
|
||||||
|
The CLI re-seal loop in `topic tail` already handles re-sealing for
|
||||||
|
web members ONCE they have a real pubkey — no broker work needed
|
||||||
|
when 3.5 lands.
|
||||||
|
|
||||||
## Option C — leaderless protocol (DEFERRED)
|
## Option C — leaderless protocol (DEFERRED)
|
||||||
|
|
||||||
@@ -95,44 +131,48 @@ asks for FS (forward secrecy) on group chat.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase-2 implementation checklist
|
## Implementation checklist
|
||||||
|
|
||||||
Schema (0026 — done):
|
Schema (0026 — done):
|
||||||
- [x] `topic.encrypted_key_pubkey` (legacy field, will be unused in
|
- [x] `topic.encrypted_key_pubkey` (informational; wire truth is the
|
||||||
Option B's "embed in payload" mode, but keeping it for
|
inline 32-byte prefix on each `topic_member_key.encryptedKey`)
|
||||||
forward-compat if we ever switch to Option C)
|
|
||||||
- [x] `topic_member_key.(encrypted_key, nonce)`
|
- [x] `topic_member_key.(encrypted_key, nonce)`
|
||||||
- [x] `topic_message.body_version` (1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext)
|
- [x] `topic_message.body_version` (1 = plaintext, 2 = v2 ciphertext)
|
||||||
|
|
||||||
API (some done — see annotations):
|
API (phase 3 — done):
|
||||||
- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
|
- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
|
||||||
- [ ] `GET /v1/topics/:name/pending-seals` — list members without keys
|
- [x] `GET /v1/topics/:name/pending-seals` — list members without keys
|
||||||
- [ ] `POST /v1/topics/:name/seal` — submit a re-sealed copy
|
- [x] `POST /v1/topics/:name/seal` — submit a re-sealed copy
|
||||||
|
- [x] `GET /v1/topics/:name/messages` returns `bodyVersion`
|
||||||
|
- [x] `GET /v1/topics/:name/stream` emits `bodyVersion`
|
||||||
|
- [x] `POST /v1/messages` accepts `bodyVersion` (1|2) + skips regex
|
||||||
|
mention extraction on v2
|
||||||
|
|
||||||
Broker:
|
Broker / web mutation (phase 3 — done):
|
||||||
- [x] `createTopic` generates topic key + seals for creator
|
- [x] `createTopic` generates topic key + seals for creator with
|
||||||
- [ ] `joinTopic` becomes a "pending" insert — no key seal
|
inline-sender-pubkey blob format
|
||||||
- [ ] (optional) WS notification to online topic members when a new
|
- [x] `ensureGeneralTopic` (web) mirrors the same flow
|
||||||
joiner arrives, so re-seal latency is sub-second instead of
|
|
||||||
polling-bound
|
|
||||||
|
|
||||||
Client (CLI + web):
|
Client — CLI (phase 3 — done):
|
||||||
- [ ] On topic open, fetch sealed key, decrypt + cache in memory
|
- [x] `services/crypto/topic-key.ts` — fetch + decrypt + encrypt + reseal helpers
|
||||||
- [ ] On send, encrypt body with topic key, set `body_version: 2`
|
- [x] `topic tail` decrypts v2 messages on render
|
||||||
- [ ] On render, decrypt v2 messages with cached key; v1 stays
|
- [x] `topic post` encrypts v2 on send via REST POST /v1/messages
|
||||||
base64 plaintext (legacy)
|
- [x] Background re-seal loop in `topic tail` (30s cadence)
|
||||||
- [ ] Background re-seal loop — poll for pending joiners, seal,
|
|
||||||
POST
|
|
||||||
|
|
||||||
UX:
|
Client — web (phase 3.5 — DEFERRED):
|
||||||
- [ ] "waiting for a peer to share the topic key" state when GET key
|
- [ ] Browser-side persistent identity (IndexedDB)
|
||||||
returns 404
|
- [ ] `POST /v1/me/peer-pubkey` sync endpoint
|
||||||
- [ ] "you are the only online member — joiners can't read messages
|
- [ ] Web chat panel encrypt-on-send + decrypt-on-render (currently v1)
|
||||||
until someone else logs in" warning when sole online holder
|
|
||||||
goes offline
|
|
||||||
|
|
||||||
The phase-2 commit ships only the schema + creator-seal + GET /key.
|
UX surfaces (phase 3 — done in CLI):
|
||||||
The pending-seals endpoint, seal POST, and client encryption land in
|
- [x] "waiting for a peer to share the topic key" warning on tail
|
||||||
phase 3 once this spec gets a code review. Mention fan-out from
|
- [ ] (web) "your encryption keys are pending — pair this browser"
|
||||||
phase 1 already works for both v1 and v2 messages, so /v1/notifications
|
banner once 3.5 lands
|
||||||
keeps working through the cutover.
|
|
||||||
|
Mention fan-out from phase 1 already works for both v1 and v2
|
||||||
|
messages, so `/v1/notifications` keeps working through the cutover.
|
||||||
|
|
||||||
|
The phase-3 cut ships full CLI encryption + re-seal flow. Web remains
|
||||||
|
on v1 plaintext until 3.5 lands the browser identity layer. Mixed
|
||||||
|
CLI+web meshes in the meantime should keep using v1 sends OR accept
|
||||||
|
that web members can't read v2 messages.
|
||||||
|
|||||||
@@ -683,20 +683,28 @@ async function sealTopicKeyForMember(args: {
|
|||||||
recipientX25519,
|
recipientX25519,
|
||||||
args.bundle.senderSecret,
|
args.bundle.senderSecret,
|
||||||
);
|
);
|
||||||
|
// Embed sender x25519 pubkey as the first 32 bytes so re-sealed
|
||||||
|
// copies (which carry their own sender pubkey from a different
|
||||||
|
// member) decode the same way as creator-sealed copies.
|
||||||
|
const blob = new Uint8Array(32 + sealed.length);
|
||||||
|
blob.set(args.bundle.senderPubkey, 0);
|
||||||
|
blob.set(sealed, 32);
|
||||||
|
const encryptedKey = sodium.to_base64(blob, sodium.base64_variants.ORIGINAL);
|
||||||
|
const nonceB64 = sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(meshTopicMemberKey)
|
.insert(meshTopicMemberKey)
|
||||||
.values({
|
.values({
|
||||||
topicId: args.topicId,
|
topicId: args.topicId,
|
||||||
memberId: args.memberId,
|
memberId: args.memberId,
|
||||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
encryptedKey,
|
||||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
nonce: nonceB64,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
|
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
|
||||||
set: {
|
set: {
|
||||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
encryptedKey,
|
||||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
nonce: nonceB64,
|
||||||
rotatedAt: new Date(),
|
rotatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `<channel>` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill.
|
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `<channel>` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill.
|
||||||
|
|
||||||
> **What's new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed.
|
> **What's new in 1.8.0:** per-topic end-to-end encryption (v0.3.0 phase 3, CLI side). `claudemesh topic post <topic> <msg>` encrypts the body with `crypto_secretbox` under the topic's symmetric key — broker stores ciphertext only. `claudemesh topic tail` now decrypts v2 messages on render and runs a background re-seal loop every 30s, so new topic joiners get their sealed keys without manual action. `topic-key` cache is process-only — kill the CLI, the key forgets. Web dashboard reads v1 plaintext for now (phase 3.5 brings browser-side identity).
|
||||||
|
>
|
||||||
|
> **What was new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed.
|
||||||
>
|
>
|
||||||
> **What was new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket.
|
> **What was new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket.
|
||||||
>
|
>
|
||||||
@@ -45,7 +47,8 @@ USAGE
|
|||||||
claudemesh profile view or edit your profile
|
claudemesh profile view or edit your profile
|
||||||
|
|
||||||
claudemesh topic ... create, list, join, send to topics
|
claudemesh topic ... create, list, join, send to topics
|
||||||
claudemesh topic tail <t> live SSE tail of a topic
|
claudemesh topic tail <t> live SSE tail of a topic (decrypts v2)
|
||||||
|
claudemesh topic post <t> encrypted REST post (v2 ciphertext)
|
||||||
claudemesh member list mesh roster with online state
|
claudemesh member list mesh roster with online state
|
||||||
claudemesh notification list recent @-mentions of you
|
claudemesh notification list recent @-mentions of you
|
||||||
claudemesh apikey ... issue, list, revoke API keys (REST clients)
|
claudemesh apikey ... issue, list, revoke API keys (REST clients)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export { runSeedTestMesh } from "./seed-test-mesh.js";
|
|||||||
export { runNotificationList } from "./notification.js";
|
export { runNotificationList } from "./notification.js";
|
||||||
export { runMemberList } from "./member.js";
|
export { runMemberList } from "./member.js";
|
||||||
export { runTopicTail } from "./topic-tail.js";
|
export { runTopicTail } from "./topic-tail.js";
|
||||||
|
export { runTopicPost } from "./topic-post.js";
|
||||||
export { withMesh } from "./connect.js";
|
export { withMesh } from "./connect.js";
|
||||||
|
|||||||
130
apps/cli/src/commands/topic-post.ts
Normal file
130
apps/cli/src/commands/topic-post.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh topic post <name> <message>` — REST-encrypted send.
|
||||||
|
*
|
||||||
|
* Distinct from `claudemesh topic send` (WS-based, currently v1
|
||||||
|
* plaintext). This verb:
|
||||||
|
* 1. Mints an ephemeral REST apikey scoped to the topic.
|
||||||
|
* 2. Fetches + decrypts the topic key (crypto_box).
|
||||||
|
* 3. Encrypts the body with crypto_secretbox under the topic key.
|
||||||
|
* 4. POSTs body_version: 2 ciphertext to /api/v1/messages.
|
||||||
|
* 5. Revokes the apikey.
|
||||||
|
*
|
||||||
|
* If the topic doesn't yet have a sealed key for this member (404
|
||||||
|
* not_sealed) we surface a clear error and skip — the user must wait
|
||||||
|
* for a holder to re-seal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withRestKey } from "~/services/api/with-rest-key.js";
|
||||||
|
import { request } from "~/services/api/client.js";
|
||||||
|
import {
|
||||||
|
getTopicKey,
|
||||||
|
encryptMessage,
|
||||||
|
} from "~/services/crypto/topic-key.js";
|
||||||
|
import { render } from "~/ui/render.js";
|
||||||
|
import { clay, dim, green } from "~/ui/styles.js";
|
||||||
|
import { EXIT } from "~/constants/exit-codes.js";
|
||||||
|
|
||||||
|
export interface TopicPostFlags {
|
||||||
|
mesh?: string;
|
||||||
|
json?: boolean;
|
||||||
|
/** Force v1 plaintext send even if the topic is encrypted. */
|
||||||
|
plaintext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostResponse {
|
||||||
|
messageId: string | null;
|
||||||
|
historyId: string | null;
|
||||||
|
topic: string;
|
||||||
|
topicId: string;
|
||||||
|
notifications: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTopicPost(
|
||||||
|
topicName: string,
|
||||||
|
message: string,
|
||||||
|
flags: TopicPostFlags,
|
||||||
|
): Promise<number> {
|
||||||
|
if (!topicName || !message) {
|
||||||
|
render.err("Usage: claudemesh topic post <topic> <message>");
|
||||||
|
return EXIT.INVALID_ARGS;
|
||||||
|
}
|
||||||
|
const cleanName = topicName.replace(/^#/, "");
|
||||||
|
|
||||||
|
// Extract @-mention tokens for write-time fan-out so the server can
|
||||||
|
// populate notifications without reading ciphertext.
|
||||||
|
const mentions: string[] = [];
|
||||||
|
const mentionRe = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = mentionRe.exec(message)) !== null) {
|
||||||
|
mentions.push(m[2]!.toLowerCase());
|
||||||
|
if (mentions.length >= 16) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return withRestKey(
|
||||||
|
{
|
||||||
|
meshSlug: flags.mesh ?? null,
|
||||||
|
purpose: `post-${cleanName}`,
|
||||||
|
capabilities: ["read", "send"],
|
||||||
|
topicScopes: [cleanName],
|
||||||
|
},
|
||||||
|
async ({ secret, mesh }) => {
|
||||||
|
let bodyVersion: 1 | 2 = 1;
|
||||||
|
let ciphertext: string;
|
||||||
|
let nonce: string;
|
||||||
|
|
||||||
|
if (flags.plaintext) {
|
||||||
|
// Explicit v1: caller wants plaintext. Encode UTF-8 → base64.
|
||||||
|
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||||
|
nonce = Buffer.from(new Uint8Array(24)).toString("base64");
|
||||||
|
} else {
|
||||||
|
const keyResult = await getTopicKey({
|
||||||
|
apiKeySecret: secret,
|
||||||
|
memberSecretKeyHex: mesh.secretKey,
|
||||||
|
topicName: cleanName,
|
||||||
|
});
|
||||||
|
if (keyResult.ok && keyResult.topicKey) {
|
||||||
|
const enc = await encryptMessage(keyResult.topicKey, message);
|
||||||
|
ciphertext = enc.ciphertext;
|
||||||
|
nonce = enc.nonce;
|
||||||
|
bodyVersion = 2;
|
||||||
|
} else if (keyResult.error === "topic_unencrypted") {
|
||||||
|
// Legacy v0.2.0 topic — fall back to v1 plaintext.
|
||||||
|
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||||
|
nonce = Buffer.from(new Uint8Array(24)).toString("base64");
|
||||||
|
} else {
|
||||||
|
render.err(
|
||||||
|
`cannot encrypt for #${cleanName}: ${keyResult.error ?? "unknown"}${
|
||||||
|
keyResult.message ? " — " + keyResult.message : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return EXIT.INTERNAL_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await request<PostResponse>({
|
||||||
|
path: "/api/v1/messages",
|
||||||
|
method: "POST",
|
||||||
|
token: secret,
|
||||||
|
body: {
|
||||||
|
topic: cleanName,
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
bodyVersion,
|
||||||
|
...(mentions.length > 0 ? { mentions } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify({ ...result, bodyVersion, mentions }));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionTag = bodyVersion === 2 ? green("🔒 v2") : dim("v1");
|
||||||
|
render.ok(
|
||||||
|
"posted",
|
||||||
|
`${clay("#" + cleanName)} ${versionTag} ${dim(`(${result.notifications} mentions)`)}`,
|
||||||
|
);
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,13 @@
|
|||||||
import { URLS } from "~/constants/urls.js";
|
import { URLS } from "~/constants/urls.js";
|
||||||
import { withRestKey } from "~/services/api/with-rest-key.js";
|
import { withRestKey } from "~/services/api/with-rest-key.js";
|
||||||
import { request } from "~/services/api/client.js";
|
import { request } from "~/services/api/client.js";
|
||||||
|
import {
|
||||||
|
getTopicKey,
|
||||||
|
decryptMessage,
|
||||||
|
sealTopicKeyFor,
|
||||||
|
} from "~/services/crypto/topic-key.js";
|
||||||
import { render } from "~/ui/render.js";
|
import { render } from "~/ui/render.js";
|
||||||
import { bold, clay, dim } from "~/ui/styles.js";
|
import { bold, clay, dim, yellow } from "~/ui/styles.js";
|
||||||
import { EXIT } from "~/constants/exit-codes.js";
|
import { EXIT } from "~/constants/exit-codes.js";
|
||||||
|
|
||||||
export interface TopicTailFlags {
|
export interface TopicTailFlags {
|
||||||
@@ -26,6 +31,7 @@ interface TopicMessage {
|
|||||||
senderName: string;
|
senderName: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
|
bodyVersion?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +41,11 @@ interface HistoryResponse {
|
|||||||
messages: TopicMessage[];
|
messages: TopicMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCiphertext(b64: string): string {
|
/**
|
||||||
|
* v1 (legacy plaintext-base64) decode. v2 messages are decrypted via
|
||||||
|
* the topic key separately — see decryptForRender below.
|
||||||
|
*/
|
||||||
|
function decodeV1(b64: string): string {
|
||||||
try {
|
try {
|
||||||
return Buffer.from(b64, "base64").toString("utf-8");
|
return Buffer.from(b64, "base64").toString("utf-8");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -43,6 +53,16 @@ function decodeCiphertext(b64: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decryptForRender(
|
||||||
|
m: TopicMessage,
|
||||||
|
topicKey: Uint8Array | null,
|
||||||
|
): Promise<string> {
|
||||||
|
if ((m.bodyVersion ?? 1) === 1) return decodeV1(m.ciphertext);
|
||||||
|
if (!topicKey) return "[encrypted — no topic key]";
|
||||||
|
const plain = await decryptMessage(topicKey, m.ciphertext, m.nonce);
|
||||||
|
return plain ?? "[decrypt failed]";
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleTimeString([], {
|
return new Date(iso).toLocaleTimeString([], {
|
||||||
@@ -55,14 +75,19 @@ function fmtTime(iso: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printMessage(m: TopicMessage, json: boolean): void {
|
async function printMessage(
|
||||||
const text = decodeCiphertext(m.ciphertext);
|
m: TopicMessage,
|
||||||
|
topicKey: Uint8Array | null,
|
||||||
|
json: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const text = await decryptForRender(m, topicKey);
|
||||||
if (json) {
|
if (json) {
|
||||||
console.log(JSON.stringify({ ...m, message: text }));
|
console.log(JSON.stringify({ ...m, message: text }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : "";
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${text}\n`,
|
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${v2Marker}${text}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +143,89 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
|||||||
capabilities: ["read"],
|
capabilities: ["read"],
|
||||||
topicScopes: [cleanName],
|
topicScopes: [cleanName],
|
||||||
},
|
},
|
||||||
async ({ secret, meshSlug }) => {
|
async ({ secret, meshSlug, mesh }) => {
|
||||||
|
// Fetch + decrypt the topic key once. Stays in memory for this
|
||||||
|
// invocation; tail dies → key forgotten. v1 topics return
|
||||||
|
// not_sealed/topic_unencrypted and we just don't decrypt.
|
||||||
|
const keyResult = await getTopicKey({
|
||||||
|
apiKeySecret: secret,
|
||||||
|
memberSecretKeyHex: mesh.secretKey,
|
||||||
|
topicName: cleanName,
|
||||||
|
});
|
||||||
|
const topicKey = keyResult.ok ? keyResult.topicKey ?? null : null;
|
||||||
|
|
||||||
|
// Re-seal background loop. While we hold the topic key, every
|
||||||
|
// 30s we look for newly-joined members who don't have a sealed
|
||||||
|
// copy yet, seal the key for each, and POST. Soft-failures stay
|
||||||
|
// silent so a flaky network doesn't spam the tail output.
|
||||||
|
let resealTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
if (topicKey) {
|
||||||
|
const reseal = async () => {
|
||||||
|
try {
|
||||||
|
const pending = await request<{
|
||||||
|
pending: Array<{
|
||||||
|
memberId: string;
|
||||||
|
pubkey: string;
|
||||||
|
displayName: string;
|
||||||
|
}>;
|
||||||
|
}>({
|
||||||
|
path: `/api/v1/topics/${encodeURIComponent(cleanName)}/pending-seals`,
|
||||||
|
token: secret,
|
||||||
|
});
|
||||||
|
for (const target of pending.pending) {
|
||||||
|
const sealed = await sealTopicKeyFor(
|
||||||
|
topicKey,
|
||||||
|
target.pubkey,
|
||||||
|
mesh.secretKey,
|
||||||
|
);
|
||||||
|
if (!sealed) continue;
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
path: `/api/v1/topics/${encodeURIComponent(cleanName)}/seal`,
|
||||||
|
method: "POST",
|
||||||
|
token: secret,
|
||||||
|
body: {
|
||||||
|
memberId: target.memberId,
|
||||||
|
encryptedKey: sealed.encryptedKey,
|
||||||
|
nonce: sealed.nonce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!flags.json) {
|
||||||
|
render.info(
|
||||||
|
dim(`re-sealed topic key for ${target.displayName}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Another holder likely sealed first — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Soft-fail; next tick retries.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void reseal();
|
||||||
|
resealTimer = setInterval(reseal, 30_000);
|
||||||
|
}
|
||||||
|
if (!flags.json && !keyResult.ok) {
|
||||||
|
if (keyResult.error === "topic_unencrypted") {
|
||||||
|
render.info(
|
||||||
|
dim("topic is on v1 (plaintext) — encryption will activate after creator-seal"),
|
||||||
|
);
|
||||||
|
} else if (keyResult.error === "not_sealed") {
|
||||||
|
render.warn(
|
||||||
|
yellow(
|
||||||
|
"no topic key sealed for you yet — wait for a holder to re-seal",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (keyResult.error === "decrypt_failed") {
|
||||||
|
render.warn(
|
||||||
|
yellow(
|
||||||
|
`topic key fetched but decrypt failed: ${keyResult.message ?? ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Backfill the most recent N messages so the user sees context
|
// 1. Backfill the most recent N messages so the user sees context
|
||||||
// when they tail an active topic.
|
// when they tail an active topic.
|
||||||
if (!flags.forwardOnly && limit > 0) {
|
if (!flags.forwardOnly && limit > 0) {
|
||||||
@@ -134,7 +241,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
|||||||
}
|
}
|
||||||
// History is newest-first; reverse for chronological display.
|
// History is newest-first; reverse for chronological display.
|
||||||
for (const m of history.messages.slice().reverse()) {
|
for (const m of history.messages.slice().reverse()) {
|
||||||
printMessage(m, flags.json ?? false);
|
await printMessage(m, topicKey, flags.json ?? false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
render.warn(`backfill failed: ${(err as Error).message}`);
|
render.warn(`backfill failed: ${(err as Error).message}`);
|
||||||
@@ -176,7 +283,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
|||||||
if (ev.event === "message") {
|
if (ev.event === "message") {
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(ev.data) as TopicMessage;
|
const m = JSON.parse(ev.data) as TopicMessage;
|
||||||
printMessage(m, flags.json ?? false);
|
await printMessage(m, topicKey, flags.json ?? false);
|
||||||
} catch {
|
} catch {
|
||||||
// skip malformed
|
// skip malformed
|
||||||
}
|
}
|
||||||
@@ -190,6 +297,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
|
|||||||
} finally {
|
} finally {
|
||||||
process.removeListener("SIGINT", onSig);
|
process.removeListener("SIGINT", onSig);
|
||||||
process.removeListener("SIGTERM", onSig);
|
process.removeListener("SIGTERM", onSig);
|
||||||
|
if (resealTimer) clearInterval(resealTimer);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ Topic (conversation scope, v0.2.0)
|
|||||||
claudemesh topic history <t> fetch message history [--limit --before]
|
claudemesh topic history <t> fetch message history [--limit --before]
|
||||||
claudemesh topic read <topic> mark all as read
|
claudemesh topic read <topic> mark all as read
|
||||||
claudemesh topic tail <topic> live SSE tail [--limit --forward-only]
|
claudemesh topic tail <topic> live SSE tail [--limit --forward-only]
|
||||||
claudemesh send "#topic" "msg" send to a topic
|
claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2)
|
||||||
|
claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext)
|
||||||
claudemesh member list mesh roster with online state [--online]
|
claudemesh member list mesh roster with online state [--online]
|
||||||
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
||||||
|
|
||||||
@@ -586,7 +587,17 @@ async function main(): Promise<void> {
|
|||||||
const { runTopicTail } = await import("~/commands/topic-tail.js");
|
const { runTopicTail } = await import("~/commands/topic-tail.js");
|
||||||
process.exit(await runTopicTail(arg, tailFlags));
|
process.exit(await runTopicTail(arg, tailFlags));
|
||||||
}
|
}
|
||||||
else { console.error("Usage: claudemesh topic <create|list|join|leave|members|history|read|tail>"); process.exit(EXIT.INVALID_ARGS); }
|
else if (sub === "post") {
|
||||||
|
const postFlags = {
|
||||||
|
mesh: flags.mesh as string,
|
||||||
|
json: !!flags.json,
|
||||||
|
plaintext: !!flags.plaintext,
|
||||||
|
};
|
||||||
|
const message = positionals.slice(2).join(" ");
|
||||||
|
const { runTopicPost } = await import("~/commands/topic-post.js");
|
||||||
|
process.exit(await runTopicPost(arg, message, postFlags));
|
||||||
|
}
|
||||||
|
else { console.error("Usage: claudemesh topic <create|list|join|leave|members|history|read|tail|post>"); process.exit(EXIT.INVALID_ARGS); }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
245
apps/cli/src/services/crypto/topic-key.ts
Normal file
245
apps/cli/src/services/crypto/topic-key.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Per-topic symmetric-key cache + crypto_box plumbing.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* 1. CLI command minted a REST apikey via withRestKey().
|
||||||
|
* 2. Caller asks for a topic key by (mesh_secret_key, topic_name).
|
||||||
|
* 3. We fetch GET /v1/topics/:name/key for the sealed copy + sender pubkey.
|
||||||
|
* 4. We convert the mesh's ed25519 secret to x25519, then crypto_box_open
|
||||||
|
* the sealed key. Plaintext key is cached in-process and used to
|
||||||
|
* encrypt + decrypt v2 message bodies.
|
||||||
|
*
|
||||||
|
* Failures:
|
||||||
|
* - 404 key_not_sealed_for_member: caller is in the topic but no peer
|
||||||
|
* has re-sealed the key for them yet. Caller surfaces a "waiting for
|
||||||
|
* a peer to share the topic key" message and falls back to v1 path.
|
||||||
|
* - 409 topic_unencrypted: legacy v0.2.0 topic. Caller stays on v1.
|
||||||
|
* - decrypt failure: server fed us a junk seal. Caller re-fetches
|
||||||
|
* once; if still bad, surface error and fall back.
|
||||||
|
*
|
||||||
|
* The cache is keyed on (apiKeyHash, topicName) so it never crosses
|
||||||
|
* sessions. Process-only — no disk persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from "~/services/api/client.js";
|
||||||
|
import { ApiError } from "~/services/api/errors.js";
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
topicKey: Uint8Array;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
interface SealedKeyResponse {
|
||||||
|
topic: string;
|
||||||
|
topicId: string;
|
||||||
|
encryptedKey: string;
|
||||||
|
nonce: string;
|
||||||
|
senderPubkey: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopicKeyError =
|
||||||
|
| "not_sealed"
|
||||||
|
| "topic_unencrypted"
|
||||||
|
| "decrypt_failed"
|
||||||
|
| "bad_member_secret"
|
||||||
|
| "network";
|
||||||
|
|
||||||
|
export interface TopicKeyResult {
|
||||||
|
ok: boolean;
|
||||||
|
topicKey?: Uint8Array;
|
||||||
|
error?: TopicKeyError;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(apiKeySecret: string, topicName: string): string {
|
||||||
|
// First 12 chars of the apikey is plenty to dedupe within a session
|
||||||
|
// and short enough to avoid keeping the full secret in a Map key.
|
||||||
|
return `${apiKeySecret.slice(0, 12)}:${topicName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopicKey(args: {
|
||||||
|
apiKeySecret: string;
|
||||||
|
memberSecretKeyHex: string;
|
||||||
|
topicName: string;
|
||||||
|
/** Bypass cache — useful after a re-seal. */
|
||||||
|
fresh?: boolean;
|
||||||
|
}): Promise<TopicKeyResult> {
|
||||||
|
const cacheId = cacheKey(args.apiKeySecret, args.topicName);
|
||||||
|
if (!args.fresh) {
|
||||||
|
const cached = cache.get(cacheId);
|
||||||
|
if (cached) return { ok: true, topicKey: cached.topicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
let sealed: SealedKeyResponse;
|
||||||
|
try {
|
||||||
|
sealed = await request<SealedKeyResponse>({
|
||||||
|
path: `/api/v1/topics/${encodeURIComponent(args.topicName)}/key`,
|
||||||
|
token: args.apiKeySecret,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
if (e.status === 404) return { ok: false, error: "not_sealed" };
|
||||||
|
if (e.status === 409) return { ok: false, error: "topic_unencrypted" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "network",
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sodium = (await import("libsodium-wrappers")).default;
|
||||||
|
await sodium.ready;
|
||||||
|
|
||||||
|
let recipientX25519Secret: Uint8Array;
|
||||||
|
try {
|
||||||
|
const ed = sodium.from_hex(args.memberSecretKeyHex);
|
||||||
|
recipientX25519Secret = sodium.crypto_sign_ed25519_sk_to_curve25519(ed);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "bad_member_secret" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let topicKey: Uint8Array;
|
||||||
|
try {
|
||||||
|
const blob = sodium.from_base64(
|
||||||
|
sealed.encryptedKey,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
const nonce = sodium.from_base64(
|
||||||
|
sealed.nonce,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
// Wire format: first 32 bytes = sender x25519 pubkey, rest =
|
||||||
|
// crypto_box ciphertext. The topic.encryptedKeyPubkey on the topic
|
||||||
|
// record is the original creator's sender; subsequent re-seals
|
||||||
|
// each carry their own sender pubkey, so the joiner can decrypt
|
||||||
|
// regardless of who sealed for them.
|
||||||
|
if (blob.length < 32 + sodium.crypto_box_MACBYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "decrypt_failed",
|
||||||
|
message: "sealed key blob too short to contain sender pubkey + cipher",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const senderX25519 = blob.slice(0, 32);
|
||||||
|
const cipher = blob.slice(32);
|
||||||
|
topicKey = sodium.crypto_box_open_easy(
|
||||||
|
cipher,
|
||||||
|
nonce,
|
||||||
|
senderX25519,
|
||||||
|
recipientX25519Secret,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "decrypt_failed",
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cacheId, { topicKey, fetchedAt: Date.now() });
|
||||||
|
return { ok: true, topicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a UTF-8 plaintext message body with the topic's symmetric
|
||||||
|
* key via crypto_secretbox. Returns base64 ciphertext + base64 nonce
|
||||||
|
* suitable for POST /v1/messages with bodyVersion: 2.
|
||||||
|
*/
|
||||||
|
export async function encryptMessage(
|
||||||
|
topicKey: Uint8Array,
|
||||||
|
plaintext: string,
|
||||||
|
): Promise<{ ciphertext: string; nonce: string }> {
|
||||||
|
const sodium = (await import("libsodium-wrappers")).default;
|
||||||
|
await sodium.ready;
|
||||||
|
const nonceBytes = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const cipher = sodium.crypto_secretbox_easy(
|
||||||
|
sodium.from_string(plaintext),
|
||||||
|
nonceBytes,
|
||||||
|
topicKey,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ciphertext: sodium.to_base64(cipher, sodium.base64_variants.ORIGINAL),
|
||||||
|
nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a v2 message body. Returns null on auth failure (bad key
|
||||||
|
* or tampering) — caller should fall back to a placeholder string,
|
||||||
|
* not crash the renderer.
|
||||||
|
*/
|
||||||
|
export async function decryptMessage(
|
||||||
|
topicKey: Uint8Array,
|
||||||
|
ciphertextB64: string,
|
||||||
|
nonceB64: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const sodium = (await import("libsodium-wrappers")).default;
|
||||||
|
await sodium.ready;
|
||||||
|
const cipher = sodium.from_base64(
|
||||||
|
ciphertextB64,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
|
||||||
|
const plain = sodium.crypto_secretbox_open_easy(cipher, nonce, topicKey);
|
||||||
|
return sodium.to_string(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal a topic key for another member — used by the re-seal flow when
|
||||||
|
* a holder helps onboard a new joiner. Returns the bundle ready to
|
||||||
|
* POST to /v1/topics/:name/seal.
|
||||||
|
*/
|
||||||
|
export async function sealTopicKeyFor(
|
||||||
|
topicKey: Uint8Array,
|
||||||
|
recipientPubkeyHex: string,
|
||||||
|
ourMemberSecretKeyHex: string,
|
||||||
|
): Promise<{
|
||||||
|
/** base64( our_x25519_pubkey || crypto_box(topicKey) ). */
|
||||||
|
encryptedKey: string;
|
||||||
|
nonce: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const sodium = (await import("libsodium-wrappers")).default;
|
||||||
|
await sodium.ready;
|
||||||
|
const recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(recipientPubkeyHex),
|
||||||
|
);
|
||||||
|
const ourEdSecret = sodium.from_hex(ourMemberSecretKeyHex);
|
||||||
|
const ourX25519Secret = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
ourEdSecret,
|
||||||
|
);
|
||||||
|
// Derive our x25519 public from our ed25519 public half (back half
|
||||||
|
// of the secret key contains the ed25519 pubkey per libsodium spec).
|
||||||
|
const ourEdPublic = ourEdSecret.slice(32, 64);
|
||||||
|
const ourX25519Public = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
ourEdPublic,
|
||||||
|
);
|
||||||
|
const nonceBytes = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
||||||
|
const cipher = sodium.crypto_box_easy(
|
||||||
|
topicKey,
|
||||||
|
nonceBytes,
|
||||||
|
recipientX25519,
|
||||||
|
ourX25519Secret,
|
||||||
|
);
|
||||||
|
// Embed sender pubkey as the first 32 bytes so the recipient can
|
||||||
|
// decrypt without a separate lookup. Matches the format the broker's
|
||||||
|
// creator-seal writes (see broker.ts sealTopicKeyForMember).
|
||||||
|
const blob = new Uint8Array(32 + cipher.length);
|
||||||
|
blob.set(ourX25519Public, 0);
|
||||||
|
blob.set(cipher, 32);
|
||||||
|
return {
|
||||||
|
encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL),
|
||||||
|
nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,10 +262,16 @@ export const ensureGeneralTopic = async (
|
|||||||
recipientX25519,
|
recipientX25519,
|
||||||
senderKp.privateKey,
|
senderKp.privateKey,
|
||||||
);
|
);
|
||||||
|
// Embed sender x25519 pubkey as the first 32 bytes so future
|
||||||
|
// re-sealed copies (carrying a different sender) decode the same
|
||||||
|
// way as creator-sealed copies.
|
||||||
|
const blob = new Uint8Array(32 + sealed.length);
|
||||||
|
blob.set(senderKp.publicKey, 0);
|
||||||
|
blob.set(sealed, 32);
|
||||||
await db.insert(meshTopicMemberKey).values({
|
await db.insert(meshTopicMemberKey).values({
|
||||||
topicId: row.id,
|
topicId: row.id,
|
||||||
memberId: owner.id,
|
memberId: owner.id,
|
||||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL),
|
||||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||||
}).onConflictDoNothing();
|
}).onConflictDoNothing();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ const sendMessageSchema = z.object({
|
|||||||
/** base64 nonce. */
|
/** base64 nonce. */
|
||||||
nonce: z.string().min(1),
|
nonce: z.string().min(1),
|
||||||
priority: z.enum(["now", "next", "low"]).optional().default("next"),
|
priority: z.enum(["now", "next", "low"]).optional().default("next"),
|
||||||
|
/**
|
||||||
|
* Body format version. 1 = base64-of-plaintext (v0.2.0 placeholder),
|
||||||
|
* 2 = crypto_secretbox under the topic's symmetric key (v0.3.0). The
|
||||||
|
* server does not look inside ciphertext either way; this field
|
||||||
|
* tells readers how to interpret it.
|
||||||
|
*/
|
||||||
|
bodyVersion: z.literal(1).or(z.literal(2)).optional().default(1),
|
||||||
/**
|
/**
|
||||||
* Optional list of `@<displayName>` mentions extracted client-side
|
* Optional list of `@<displayName>` mentions extracted client-side
|
||||||
* from the plaintext. Capped at 16 to bound notification fan-out
|
* from the plaintext. Capped at 16 to bound notification fan-out
|
||||||
@@ -160,6 +167,7 @@ export const v1Router = new Hono<Env>()
|
|||||||
senderMemberId,
|
senderMemberId,
|
||||||
nonce: body.nonce,
|
nonce: body.nonce,
|
||||||
ciphertext: body.ciphertext,
|
ciphertext: body.ciphertext,
|
||||||
|
bodyVersion: body.bodyVersion,
|
||||||
})
|
})
|
||||||
.returning({ id: meshTopicMessage.id });
|
.returning({ id: meshTopicMessage.id });
|
||||||
|
|
||||||
@@ -176,12 +184,17 @@ export const v1Router = new Hono<Env>()
|
|||||||
.returning({ id: messageQueue.id });
|
.returning({ id: messageQueue.id });
|
||||||
|
|
||||||
// Mention fan-out → notification rows. Client-extracted mentions
|
// Mention fan-out → notification rows. Client-extracted mentions
|
||||||
// win when present (post-encryption clients MUST extract and send);
|
// win when present (v2 ciphertext clients MUST extract and send;
|
||||||
// otherwise we regex the base64 plaintext as a transitional fallback.
|
// server can't read v2 bodies). v1 plaintext falls back to a regex
|
||||||
|
// on the body so legacy senders don't lose mention notifications.
|
||||||
let mentionTokens = body.mentions?.map((s) => s.toLowerCase().replace(/^@/, ""));
|
let mentionTokens = body.mentions?.map((s) => s.toLowerCase().replace(/^@/, ""));
|
||||||
if (!mentionTokens || mentionTokens.length === 0) {
|
if (
|
||||||
|
(!mentionTokens || mentionTokens.length === 0) &&
|
||||||
|
body.bodyVersion === 1
|
||||||
|
) {
|
||||||
mentionTokens = extractMentionsFromBase64(body.ciphertext);
|
mentionTokens = extractMentionsFromBase64(body.ciphertext);
|
||||||
}
|
}
|
||||||
|
if (!mentionTokens) mentionTokens = [];
|
||||||
let notifications = 0;
|
let notifications = 0;
|
||||||
if (historyRow && mentionTokens.length > 0) {
|
if (historyRow && mentionTokens.length > 0) {
|
||||||
const recipients = await db
|
const recipients = await db
|
||||||
@@ -386,6 +399,7 @@ export const v1Router = new Hono<Env>()
|
|||||||
senderName: meshMember.displayName,
|
senderName: meshMember.displayName,
|
||||||
nonce: meshTopicMessage.nonce,
|
nonce: meshTopicMessage.nonce,
|
||||||
ciphertext: meshTopicMessage.ciphertext,
|
ciphertext: meshTopicMessage.ciphertext,
|
||||||
|
bodyVersion: meshTopicMessage.bodyVersion,
|
||||||
createdAt: meshTopicMessage.createdAt,
|
createdAt: meshTopicMessage.createdAt,
|
||||||
})
|
})
|
||||||
.from(meshTopicMessage)
|
.from(meshTopicMessage)
|
||||||
@@ -413,6 +427,7 @@ export const v1Router = new Hono<Env>()
|
|||||||
senderName: r.senderName,
|
senderName: r.senderName,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
ciphertext: r.ciphertext,
|
ciphertext: r.ciphertext,
|
||||||
|
bodyVersion: r.bodyVersion,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -484,6 +499,7 @@ export const v1Router = new Hono<Env>()
|
|||||||
senderName: meshMember.displayName,
|
senderName: meshMember.displayName,
|
||||||
nonce: meshTopicMessage.nonce,
|
nonce: meshTopicMessage.nonce,
|
||||||
ciphertext: meshTopicMessage.ciphertext,
|
ciphertext: meshTopicMessage.ciphertext,
|
||||||
|
bodyVersion: meshTopicMessage.bodyVersion,
|
||||||
createdAt: meshTopicMessage.createdAt,
|
createdAt: meshTopicMessage.createdAt,
|
||||||
})
|
})
|
||||||
.from(meshTopicMessage)
|
.from(meshTopicMessage)
|
||||||
@@ -510,6 +526,7 @@ export const v1Router = new Hono<Env>()
|
|||||||
senderName: r.senderName,
|
senderName: r.senderName,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
ciphertext: r.ciphertext,
|
ciphertext: r.ciphertext,
|
||||||
|
bodyVersion: r.bodyVersion,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -681,6 +698,178 @@ export const v1Router = new Hono<Env>()
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// GET /v1/topics/:name/pending-seals — list topic members that don't
|
||||||
|
// yet have a sealed copy of the topic key. Members who hold the key
|
||||||
|
// poll this and re-seal for any pending recipient via POST /seal.
|
||||||
|
//
|
||||||
|
// Returns roster format so the caller can do the crypto:
|
||||||
|
// { pending: [{ memberId, pubkey, displayName }] }
|
||||||
|
//
|
||||||
|
// Caps at 50 — if more are pending the next poll picks up the rest.
|
||||||
|
// Anyone with read capability + topic scope can list (any holder can
|
||||||
|
// re-seal; the trust model accepts that).
|
||||||
|
.get("/topics/:name/pending-seals", async (c) => {
|
||||||
|
const key = c.var.apiKey;
|
||||||
|
requireCapability(key, "read");
|
||||||
|
const name = c.req.param("name");
|
||||||
|
requireTopicScope(key, name);
|
||||||
|
|
||||||
|
const [topic] = await db
|
||||||
|
.select({
|
||||||
|
id: meshTopic.id,
|
||||||
|
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
|
||||||
|
})
|
||||||
|
.from(meshTopic)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshTopic.meshId, key.meshId),
|
||||||
|
eq(meshTopic.name, name),
|
||||||
|
isNull(meshTopic.archivedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!topic) {
|
||||||
|
return c.json({ error: "topic_not_found", topic: name }, 404);
|
||||||
|
}
|
||||||
|
if (!topic.encryptedKeyPubkey) {
|
||||||
|
return c.json({ pending: [], senderPubkey: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member is "pending" iff joined the topic but has no key row yet.
|
||||||
|
// LEFT JOIN topic_member_key on the same (topic, member) pair —
|
||||||
|
// NULL = pending.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
memberId: meshTopicMember.memberId,
|
||||||
|
pubkey: meshMember.peerPubkey,
|
||||||
|
displayName: meshMember.displayName,
|
||||||
|
})
|
||||||
|
.from(meshTopicMember)
|
||||||
|
.innerJoin(meshMember, eq(meshMember.id, meshTopicMember.memberId))
|
||||||
|
.leftJoin(
|
||||||
|
meshTopicMemberKey,
|
||||||
|
and(
|
||||||
|
eq(meshTopicMemberKey.topicId, meshTopicMember.topicId),
|
||||||
|
eq(meshTopicMemberKey.memberId, meshTopicMember.memberId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshTopicMember.topicId, topic.id),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
isNull(meshTopicMemberKey.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
topic: name,
|
||||||
|
topicId: topic.id,
|
||||||
|
senderPubkey: topic.encryptedKeyPubkey,
|
||||||
|
pending: rows,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /v1/topics/:name/seal — submit a re-sealed copy of the topic
|
||||||
|
// key for a specific member. Body: {memberId, encryptedKey, nonce}.
|
||||||
|
// Idempotent on (topicId, memberId) — re-submitting overwrites.
|
||||||
|
//
|
||||||
|
// The CALLER must already hold the topic key (otherwise their seal
|
||||||
|
// would be garbage). Server can't verify that at submission time —
|
||||||
|
// the joiner verifies on first decrypt by attempting crypto_box_open
|
||||||
|
// and discarding the row if it fails. Bad seals waste a round-trip
|
||||||
|
// but can't break the security model.
|
||||||
|
.post(
|
||||||
|
"/topics/:name/seal",
|
||||||
|
validate(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
memberId: z.string().min(1),
|
||||||
|
encryptedKey: z.string().min(1),
|
||||||
|
nonce: z.string().min(1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const key = c.var.apiKey;
|
||||||
|
requireCapability(key, "send");
|
||||||
|
const name = c.req.param("name");
|
||||||
|
requireTopicScope(key, name);
|
||||||
|
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [topic] = await db
|
||||||
|
.select({
|
||||||
|
id: meshTopic.id,
|
||||||
|
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
|
||||||
|
})
|
||||||
|
.from(meshTopic)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshTopic.meshId, key.meshId),
|
||||||
|
eq(meshTopic.name, name),
|
||||||
|
isNull(meshTopic.archivedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!topic) {
|
||||||
|
return c.json({ error: "topic_not_found", topic: name }, 404);
|
||||||
|
}
|
||||||
|
if (!topic.encryptedKeyPubkey) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: "topic_unencrypted",
|
||||||
|
topic: name,
|
||||||
|
hint: "legacy v0.2.0 topic — no key to seal",
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient must be a non-revoked member of the same mesh AND
|
||||||
|
// already a topic_member (joined the topic). Otherwise we'd let
|
||||||
|
// anyone seal for any member, which the joiner would then accept
|
||||||
|
// on first GET /key — that's a denial-of-content vector.
|
||||||
|
const [recipient] = await db
|
||||||
|
.select({ id: meshMember.id })
|
||||||
|
.from(meshTopicMember)
|
||||||
|
.innerJoin(meshMember, eq(meshMember.id, meshTopicMember.memberId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshTopicMember.topicId, topic.id),
|
||||||
|
eq(meshMember.id, body.memberId),
|
||||||
|
eq(meshMember.meshId, key.meshId),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!recipient) {
|
||||||
|
return c.json({ error: "recipient_not_in_topic" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.insert(meshTopicMemberKey)
|
||||||
|
.values({
|
||||||
|
topicId: topic.id,
|
||||||
|
memberId: body.memberId,
|
||||||
|
encryptedKey: body.encryptedKey,
|
||||||
|
nonce: body.nonce,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
|
||||||
|
set: {
|
||||||
|
encryptedKey: body.encryptedKey,
|
||||||
|
nonce: body.nonce,
|
||||||
|
rotatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
topic: name,
|
||||||
|
topicId: topic.id,
|
||||||
|
memberId: body.memberId,
|
||||||
|
sealedAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// GET /v1/notifications — recent @-mentions of the viewer across
|
// GET /v1/notifications — recent @-mentions of the viewer across
|
||||||
// all topics in the key's mesh. Reads from mesh.notification, which
|
// all topics in the key's mesh. Reads from mesh.notification, which
|
||||||
// is populated at write time by POST /v1/messages and the broker's
|
// is populated at write time by POST /v1/messages and the broker's
|
||||||
|
|||||||
Reference in New Issue
Block a user