cautpartener / client / src / core / groupchat.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
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
 * Group chats (M3a): a group conversation is a minted 32-byte room secret,
 * registered in the creator's home log as a `chatreg` op (§4) and delivered
 * to each member as a `ginvite` op appended to the respective PAIRWISE room
 * log (§5) — the pair room doubles as the control channel. On the receiving
 * side the pairwise fold surfaces the ginvite (ChatRoom.onControl →
 * ChatService), which registers the same chatreg in the receiver's OWN home
 * log so all their devices see the group.
 *
 * The ginvite carries the member set alongside {id, secret, name}: without
 * it an invitee could not fold the group room (authorization = "author pub
 * is in the member set", core/chatlog.ts). PROTOCOL.md §5 documents the
 * receiver rules: shape clamps, first-wins per id, own pub ∈ members AND
 * the verified pairwise sender ∈ members (registerGinvite below).
 *
 * Membership v1 is STATIC and capped at 12 (CHAT_MEMBERS_MAX): no member
 * add/remove ops. Leave/eviction v1: there is no cryptographic eviction —
 * every member holds the room secret forever, so "removing" someone from a
 * group means CREATING A NEW group room without them (fresh secret, fresh
 * ginvites) and abandoning the old one. The UI documents this honestly
 * (chatGroupEvictNote) instead of pretending a revocation exists.
 */

import { randomBytes, toB64url } from "../lib/crypto";
import { CHAT_MEMBERS_MAX, sanitizeName, type ChatRegOp, type HomeState } from "./home-log";
import type { SelfRoom } from "./selfroom";

export { CHAT_MEMBERS_MAX };

const PUB_RE = /^[A-Za-z0-9_-]{43}$/;
const SECRET_RE = /^[A-Za-z0-9_-]{43}$/;

/** §5 ginvite (pairwise control op) + the members delta (see module doc). */
export interface GinviteOp {
  t: "ginvite";
  ts: number;
  id: string;
  secret: string;
  name: string;
  members: string[];
}

/**
 * Mint a new group chat: fresh 32-byte secret, self-minted id (no "|", §4),
 * chatreg appended to MY home log. Returns the registered op; the caller
 * (ChatService) delivers ginvites through the pair rooms it owns.
 * `memberPubs` are the OTHER participants — my own pub is added here.
 */
export async function mintGroupChat(
  selfroom: SelfRoom,
  myPub: string,
  name: string,
  memberPubs: string[],
): Promise<ChatRegOp> {
  const clean = sanitizeName(name);
  if (!clean) throw new Error("empty group name");
  const members = [...new Set([myPub, ...memberPubs])];
  if (members.length < 2) throw new Error("a group needs at least one other member");
  if (members.length > CHAT_MEMBERS_MAX) throw new Error("too many members");
  if (!members.every((p) => PUB_RE.test(p))) throw new Error("bad member pub");
  const reg: ChatRegOp = {
    t: "chatreg",
    ts: Date.now(),
    kind: "group",
    id: toB64url(randomBytes(16)), // 22-char b64url, never contains "|"
    secret: toB64url(randomBytes(32)),
    name: clean,
    members,
  };
  await selfroom.appendHomeOp(reg);
  return reg;
}

/** The pairwise invite op for a registered group. */
export function ginviteOf(reg: ChatRegOp): GinviteOp {
  return { t: "ginvite", ts: Date.now(), id: reg.id, secret: reg.secret, name: reg.name, members: reg.members };
}

/** Wire-shape clamp for a received ginvite; null ⇒ drop silently. */
export function parseGinvite(op: unknown): GinviteOp | null {
  const g = op as Partial<GinviteOp> | null;
  if (!g || typeof g !== "object" || g.t !== "ginvite") return null;
  if (typeof g.id !== "string" || g.id.length === 0 || g.id.includes("|")) return null;
  if (typeof g.secret !== "string" || !SECRET_RE.test(g.secret)) return null;
  if (typeof g.name !== "string" || sanitizeName(g.name).length === 0) return null;
  if (!Array.isArray(g.members) || g.members.length < 2 || g.members.length > CHAT_MEMBERS_MAX) return null;
  if (!g.members.every((p) => typeof p === "string" && PUB_RE.test(p))) return null;
  return {
    t: "ginvite",
    ts: typeof g.ts === "number" ? g.ts : Date.now(),
    id: g.id,
    secret: g.secret,
    name: sanitizeName(g.name),
    members: [...new Set(g.members)],
  };
}

/**
 * A pairwise fold surfaced a ginvite: register the chatreg in MY home log so
 * every device of mine sees the group. Idempotent and first-wins: an id
 * already registered is never re-bound to a different secret by a later
 * ginvite (a malicious re-invite can't hijack an existing conversation).
 * Requires MY pub in the member set — a room I can't write to is not a
 * conversation of mine — AND the verified pairwise SENDER in the member set
 * (§5): without that check a contact could hand out a room secret they hold
 * while omitting themselves from the roster, becoming a hidden reader of a
 * group whose member list never shows them. `senderPub` is the entry's
 * verified author (LogEntryMeta.from — the pairwise fold already dropped
 * anything not signed by one of the two pair identities).
 * Returns true when a new group was registered.
 */
export async function registerGinvite(
  selfroom: SelfRoom,
  myPub: string,
  senderPub: string,
  raw: unknown,
): Promise<boolean> {
  const g = parseGinvite(raw);
  if (!g || !g.members.includes(myPub) || !g.members.includes(senderPub)) return false;
  if (selfroom.state.chats.has(g.id)) return false;
  await selfroom.appendHomeOp({
    t: "chatreg",
    ts: Date.now(),
    kind: "group",
    id: g.id,
    secret: g.secret,
    name: g.name,
    members: g.members,
  });
  return true;
}

/** Registered group conversations, newest-registered first. */
export function groupConversations(state: HomeState): ChatRegOp[] {
  return [...state.chats.values()].filter((c) => c.kind === "group").sort((a, b) => b.ts - a.ts);
}

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