cautpartener / client / src / public / public-rooms.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
 * Public-plane mailbox room derivations (docs/PROTOCOL.md §2). Pure functions:
 * every room id is HKDF-SHA256 over PUBLIC data (a profile's raw pub bytes, or
 * a feed shard string), salt = APP_SALT, rendered as unpadded b64url. This is
 * deliberate obfuscation-free design — anyone holding the pub can derive the
 * same rooms, which is exactly the point (§1: keys derivable from public data
 * would be obfuscation, so the public plane doesn't pretend to encrypt).
 *
 * Separate rooms per concern on purpose: comment floods must not evict
 * profile docs (§2).
 */

import { APP_SALT } from "../config";
import { fromB64url, toB64url, type Bytes } from "../lib/crypto";

const utf8 = (s: string): Bytes => new TextEncoder().encode(s) as Bytes;

async function derive(ikm: Bytes, info: string): Promise<string> {
  const key = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveBits"]);
  const bits = await crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt: utf8(APP_SALT) as BufferSource, info: utf8(info) as BufferSource },
    key,
    256,
  );
  return toB64url(new Uint8Array(bits));
}

/** ikm = the 32 raw bytes of a 43-char b64url Ed25519 pub. Throws on garbage. */
function rawPubBytes(pub: string): Bytes {
  const raw = fromB64url(pub);
  if (raw.length !== 32) throw new Error("bad pub");
  return raw;
}

/** profileRoom(P) — the owner's profile + mod docs + avatar blocks. */
export function profileRoom(pub: string): Promise<string> {
  return derive(rawPubBytes(pub), "cp/mbx/profile|v1");
}

/** commentsRoom(P) — comments ON P's profile (anyone deposits). */
export function commentsRoom(pub: string): Promise<string> {
  return derive(rawPubBytes(pub), "cp/mbx/comments|v1");
}

/** inboxRoom(P) — contact requests TO P (creq) and acks TO P (cack). */
export function inboxRoom(pub: string): Promise<string> {
  return derive(rawPubBytes(pub), "cp/mbx/inbox|v1");
}

/** feedRoom(shard, stripe) — M5 posts; stripe ∈ 0..3. */
export function feedRoom(shard: string, stripe: number): Promise<string> {
  return derive(utf8(`shard|${shard}|s${stripe}`), "cp/mbx/feed|v1");
}

/** feedTopic(shard) — the live gossipsub announce topic for one shard (M5). */
export async function feedTopic(shard: string): Promise<string> {
  return "cp/1/feed/" + (await derive(utf8(`shard|${shard}`), "cp/topic/feed|v1"));
}

/** stripe(author) = rawPubBytes(author)[0] & 0x03 — which feed stripe P posts to. */
export function stripe(pub: string): number {
  return rawPubBytes(pub)[0] & 0x03;
}

/**
 * poolRoom(pool) — matching cards for one pool (P2). pool = "<category>|<county>"
 * (the same open-vocabulary slug shape as feed shards, §2). ONE room per pool, no striping:
 * cards are tiny, LWW-collapsed to at most one per author, and browse walks
 * backward under the same page budget — stripes would buy nothing at v1 scale.
 */
export function poolRoom(pool: string): Promise<string> {
  return derive(utf8(`pool|${pool}`), "cp/mbx/pool|v1");
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git