cautpartener / client / src / core / contacts.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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
/**
 * Contact + group operations over the home-log fold (docs/PROTOCOL.md §3-4).
 *
 * The X25519 encryption identity is the lib's deterministic derivation
 * (wrapbox deriveXKeyPair, info "id-x25519|v1") but the certificate binding
 * it to the Ed25519 identity uses THIS app's domain-separated tag:
 *   "cp-x25519|v1|" + APP_SALT + "|" + idPub + "|" + xPub + "|" + name
 * signed by the Ed25519 key (§3) — NOT lib wrapbox's "board-x25519|v1" cert,
 * so certs are never replayable between board and Caut Partener. The
 * SANITIZED display name is inside the signed string, so a link-holder can
 * never spoof the name shown at trust establishment; verifiers reconstruct
 * the string from the card's LITERAL name and reject cards whose name is not
 * already in sanitized form (never normalize-then-verify). idPub/xPub are
 * validated b64url before reconstruction, so "|" injection is impossible.
 *
 * TOFU discipline: the FIRST xPub seen for a pub is trusted; a later card for
 * the same pub with a DIFFERENT xPub — against ANY stored binding, active or
 * removed — is never applied silently: addContact returns
 * { kind: "key-change" } and the UI must run an explicit blocking
 * confirmation (acceptKeyChange) before the binding moves.
 */

import { APP_SALT } from "../config";
import { fromB64url, randomBytes, toB64url, type Bytes } from "../lib/crypto";
import { verifyRaw, type Identity } from "../lib/identity";
import { deriveXKeyPair } from "../lib/wrapbox";
import { NAME_MAX, sanitizeName, type ContactOp, type GMemberOp, type GroupOp, type HomeState } from "./home-log";
import type { SelfRoom } from "./selfroom";

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

export { NAME_MAX, sanitizeName };

/** A contact card as exchanged over rendezvous / creq / cack. */
export interface ContactCard {
  pub: string;
  xPub: string;
  xSig: string;
  name: string;
}

// ---- X25519 cert (§3) --------------------------------------------------------

// idPub/xPub are b64url (validated by callers via isValidCard/isValidPub) and
// name is the SANITIZED display form — the cert covers the name so it cannot
// be swapped after signing (§3).
const certBytes = (idPub: string, xPub: string, name: string): Bytes =>
  utf8(`cp-x25519|v1|${APP_SALT}|${idPub}|${xPub}|${name}`);

/** Sign an X25519 pub + display name with this identity's Ed25519 key. */
export async function signContactCert(identity: Identity, xPub: string, name: string): Promise<string> {
  return toB64url(await identity.signRaw(certBytes(identity.publicKeyB64, xPub, sanitizeName(name))));
}

/**
 * Verify a card's cert: Ed25519(pub) signed the reconstructed cert string.
 * `name` is the card's LITERAL name — callers must have rejected unsanitized
 * names already (isValidCard); this never normalizes before verifying.
 */
export function verifyContactCert(pub: string, xPub: string, xSig: string, name: string): Promise<boolean> {
  return verifyRaw(pub, xSig, certBytes(pub, xPub, name));
}

/** My own certified encryption identity, derived fresh from the seed. */
export async function myXIdentity(
  identity: Identity,
  seed: string,
  name: string,
): Promise<{ xPub: string; xSig: string; name: string }> {
  const pair = await deriveXKeyPair(seed, APP_SALT);
  const clean = sanitizeName(name);
  return { xPub: pair.pubB64, xSig: await signContactCert(identity, pair.pubB64, clean), name: clean };
}

// ---- validation --------------------------------------------------------------

const B64URL = /^[A-Za-z0-9_-]+$/;

function isB64urlOfLength(s: unknown, bytes: number): s is string {
  if (typeof s !== "string" || !B64URL.test(s)) return false;
  try {
    return fromB64url(s).length === bytes;
  } catch {
    return false;
  }
}

export function isValidPub(pub: unknown): pub is string {
  return isB64urlOfLength(pub, 32);
}

/** UI-side name clamp — the same canonical form the certs sign (§3). */
export function clampName(name: string): string {
  return sanitizeName(name);
}

/**
 * Structural card check (no crypto): 32B pub/xPub, 64B sig, and a name that
 * IS its own sanitized form (non-empty, ≤40 units, no control/bidi chars,
 * collapsed whitespace). Wire cards carrying an unsanitized or oversized
 * name are REJECTED here — never normalized and then verified.
 */
export function isValidCard(c: unknown): c is ContactCard {
  if (!c || typeof c !== "object") return false;
  const card = c as Partial<ContactCard>;
  return (
    isB64urlOfLength(card.pub, 32) &&
    isB64urlOfLength(card.xPub, 32) &&
    isB64urlOfLength(card.xSig, 64) &&
    typeof card.name === "string" &&
    card.name.length > 0 &&
    card.name === sanitizeName(card.name)
  );
}

// ---- the implicit "all contacts" group (P1) ------------------------------------

/**
 * The built-in contacts-only audience (P1): a REAL contact group with a
 * CONSTANT self-minted gid (never contains "|", §4), auto-maintained by the
 * contacts service — every device converges on the same gid via the group
 * op's LWW, so two devices can never mint competing "all contacts" groups
 * (gkey mint races settle through the existing §3b convergence). Created
 * lazily on the first contacts-only publish; from then on every contact ADD
 * auto-enrolls (grant via the audience hook = full-history access, the
 * documented board semantic) and every contact REMOVE auto-unenrolls (rekey).
 * HONEST DIFFERENCE from a hand-made group (shipped in-app): membership
 * follows the contact book automatically, a later contact sees earlier
 * contacts-only content, and a removed contact keeps what they already hold
 * (removal ≠ retraction, §7).
 */
export const ALL_CONTACTS_GID = "toate-contactele";
const ALL_CONTACTS_NAME = "Toate contactele";

/** Is the all-contacts group currently active in this fold? */
export function allContactsGroupActive(s: HomeState): boolean {
  return s.groups.get(ALL_CONTACTS_GID)?.status === "active";
}

// ---- the service -------------------------------------------------------------

export type AddOutcome =
  | { kind: "added" }
  | { kind: "unchanged" } // already an active contact with the SAME xPub
  | { kind: "key-change"; existing: ContactOp } // TOFU violation — UI must confirm
  | { kind: "invalid" }; // malformed card / failed cert / own pub

export class ContactsService {
  #room: SelfRoom;
  readonly myPub: string;

  /**
   * Audience-layer hooks (M5b, wired by boot to core/audience.ts): fire
   * AFTER the membership/removal op landed in the home log, so the fold
   * already reflects the change when the audience service rekeys/grants.
   */
  onMembershipChanged: (gid: string, pub: string, on: boolean) => void = () => {};
  onContactRemoved: (pub: string) => void = () => {};
  /**
   * An accepted TOFU key change (M5b defense-in-depth): the audience layer
   * treats it as remove+re-add — rekey every group the contact is in, then
   * re-grant the current epoch over the NEW pair room. Fires AFTER the new
   * binding landed in the home log (the fold already holds the new xPub).
   */
  onKeyChanged: (pub: string) => void = () => {};
  /**
   * A group was deleted: the profile layer republishes so the sealed section
   * for that (now-inactive) group stops being served immediately.
   */
  onGroupDeleted: (gid: string) => void = () => {};

  constructor(room: SelfRoom, myPub: string) {
    this.#room = room;
    this.myPub = myPub;
  }

  get state(): HomeState {
    return this.#room.state;
  }

  /** LWW-safe stamp: strictly above the current winner for this key. */
  #stamp(prevTs: number | undefined): number {
    return Math.max(Date.now(), (prevTs ?? 0) + 1);
  }

  /**
   * Add a verified contact. Cert is verified here — an invalid card is
   * REJECTED, never stored. TOFU: a different xPub for ANY stored binding —
   * active OR removed (removeContact keeps the binding for exactly this) —
   * is surfaced as { kind: "key-change" } without touching the stored one.
   */
  async addContact(card: ContactCard): Promise<AddOutcome> {
    if (!isValidCard(card) || card.pub === this.myPub) return { kind: "invalid" };
    if (!(await verifyContactCert(card.pub, card.xPub, card.xSig, card.name))) return { kind: "invalid" };
    const existing = this.state.contacts.get(card.pub);
    if (existing && existing.xPub !== card.xPub) return { kind: "key-change", existing };
    if (existing?.status === "active") return { kind: "unchanged" };
    await this.#append(card, existing);
    await this.#autoEnrollAllContacts(card.pub);
    return { kind: "added" };
  }

  /**
   * The user EXPLICITLY accepted a key change after the blocking warning.
   * Still re-verifies the cert; keeps the locally chosen contact name.
   */
  async acceptKeyChange(card: ContactCard): Promise<AddOutcome> {
    if (!isValidCard(card) || card.pub === this.myPub) return { kind: "invalid" };
    if (!(await verifyContactCert(card.pub, card.xPub, card.xSig, card.name))) return { kind: "invalid" };
    const existing = this.state.contacts.get(card.pub);
    await this.#append(card, existing, existing?.status === "active" ? existing.name : undefined);
    // TOFU key rotation is a compromise signal: rekey the groups this contact
    // is in (the old key/pair room loses future content) and re-grant the
    // current epoch over the NEW pair room. Fires only for an ACTUAL key change.
    if (existing && existing.xPub !== card.xPub) {
      try {
        this.onKeyChanged(card.pub);
      } catch {}
    }
    await this.#autoEnrollAllContacts(card.pub);
    return { kind: "added" };
  }

  /**
   * P1 all-contacts auto-membership: a NEWLY-ADDED (or re-added) contact
   * joins the implicit contacts-only group when it exists — the membership
   * hook delivers the current epoch + keychain over the pairwise room, so a
   * later contact can read earlier contacts-only content (stated in-app).
   */
  async #autoEnrollAllContacts(pub: string): Promise<void> {
    if (!allContactsGroupActive(this.state)) return;
    try {
      await this.setMembership(ALL_CONTACTS_GID, pub, true);
    } catch {}
  }

  async #append(card: ContactCard, prev: ContactOp | undefined, keepName?: string): Promise<void> {
    await this.#room.appendHomeOp({
      t: "contact",
      ts: this.#stamp(prev?.ts),
      pub: card.pub,
      xPub: card.xPub,
      xSig: card.xSig,
      name: keepName ?? clampName(card.name),
      status: "active",
    } satisfies ContactOp);
  }

  /** Rename the LOCAL display name of a contact. */
  async renameContact(pub: string, name: string): Promise<void> {
    const c = this.state.contacts.get(pub);
    const clean = clampName(name);
    if (!c || c.status !== "active" || !clean) return;
    await this.#room.appendHomeOp({ ...c, ts: this.#stamp(c.ts), name: clean });
  }

  /** Tombstone a contact (LWW status:"removed"; key binding kept for TOFU). */
  async removeContact(pub: string): Promise<void> {
    const c = this.state.contacts.get(pub);
    if (!c || c.status !== "active") return;
    // P1: drop them from the implicit all-contacts group FIRST — the explicit
    // gmember-off op (and its rekey via the membership hook) keeps the group's
    // fold honest on every device; onContactRemoved below then skips it (off)
    // and rekeys only the OTHER keyed groups they were in.
    if (allContactsGroupActive(this.state) && this.state.gmembers.get(`${ALL_CONTACTS_GID}|${pub}`)?.on === true) {
      try {
        await this.setMembership(ALL_CONTACTS_GID, pub, false);
      } catch {}
    }
    await this.#room.appendHomeOp({ ...c, ts: this.#stamp(c.ts), status: "removed" });
    try {
      this.onContactRemoved(pub); // audience rekey for keyed groups they were in
    } catch {}
  }

  // ---- groups ----------------------------------------------------------------

  /** gid: 16 random bytes → 22-char b64url; never contains "|" (§4). */
  async createGroup(name: string): Promise<string> {
    const gid = toB64url(randomBytes(16));
    const clean = clampName(name);
    if (!clean) throw new Error("empty group name");
    await this.#room.appendHomeOp({ t: "group", ts: Date.now(), gid, name: clean, status: "active" } satisfies GroupOp);
    return gid;
  }

  async renameGroup(gid: string, name: string): Promise<void> {
    const g = this.state.groups.get(gid);
    const clean = clampName(name);
    if (!g || g.status !== "active" || !clean) return;
    await this.#room.appendHomeOp({ ...g, ts: this.#stamp(g.ts), name: clean });
  }

  async deleteGroup(gid: string): Promise<void> {
    const g = this.state.groups.get(gid);
    if (!g || g.status !== "active") return;
    await this.#room.appendHomeOp({ ...g, ts: this.#stamp(g.ts), status: "removed" });
    try {
      this.onGroupDeleted(gid); // profile republish drops the sealed section (M5b)
    } catch {}
  }

  /**
   * Ensure the implicit contacts-only group exists (P1): appends the constant
   * gid's group op if absent/removed, then enrolls every CURRENT active
   * contact that isn't a member yet — EXCEPT contacts with an explicit
   * on:false membership op (manual exclusion, respected). Lazily called on
   * the first contacts-only publish; idempotent; convergent across devices
   * (constant gid ⇒ one LWW slot). Returns the gid.
   */
  async ensureAllContactsGroup(): Promise<string> {
    const g = this.state.groups.get(ALL_CONTACTS_GID);
    if (!g || g.status !== "active") {
      await this.#room.appendHomeOp({
        t: "group",
        ts: this.#stamp(g?.ts),
        gid: ALL_CONTACTS_GID,
        name: ALL_CONTACTS_NAME,
        status: "active",
      } satisfies GroupOp);
    }
    for (const c of activeContacts(this.state)) {
      const m = this.state.gmembers.get(`${ALL_CONTACTS_GID}|${c.pub}`);
      // A stored on:false op is an EXPLICIT exclusion (the user removed this
      // contact from the automatic group, or removeContact unenrolled them).
      // Respect it: never silently re-enroll — enrollment grants the FULL
      // sealed history (the documented board semantic), so a silent re-add
      // would undo a deliberate exclusion with maximum blast radius. The UI
      // states this ("poți exclude manual un contact din grupul automat")
      // and offers the exclusion control on the read-only group row.
      if (m?.on === false) continue;
      if (m?.on !== true) await this.setMembership(ALL_CONTACTS_GID, c.pub, true);
    }
    return ALL_CONTACTS_GID;
  }

  /** Toggle a contact's membership; overlapping memberships are by design. */
  async setMembership(gid: string, pub: string, on: boolean): Promise<void> {
    const prev = this.state.gmembers.get(`${gid}|${pub}`);
    if (prev && prev.on === on) return;
    await this.#room.appendHomeOp({ t: "gmember", ts: this.#stamp(prev?.ts), gid, pub, on } satisfies GMemberOp);
    try {
      this.onMembershipChanged(gid, pub, on); // audience grant / rekey (M5b)
    } catch {}
  }
}

// ---- pure selectors over the folded state ------------------------------------

export function activeContacts(s: HomeState): ContactOp[] {
  return [...s.contacts.values()]
    .filter((c) => c.status === "active")
    .sort((a, b) => a.name.localeCompare(b.name, "ro"));
}

export function activeGroups(s: HomeState): GroupOp[] {
  return [...s.groups.values()]
    .filter((g) => g.status === "active")
    .sort((a, b) => a.name.localeCompare(b.name, "ro"));
}

/** Active groups this ACTIVE contact belongs to. */
export function groupsOfContact(s: HomeState, pub: string): GroupOp[] {
  return activeGroups(s).filter((g) => s.gmembers.get(`${g.gid}|${pub}`)?.on === true);
}

/** Active contacts that are members of this group. */
export function membersOfGroup(s: HomeState, gid: string): ContactOp[] {
  return activeContacts(s).filter((c) => s.gmembers.get(`${gid}|${c.pub}`)?.on === true);
}

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