cautpartener / client / src / core / audience.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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
/**
 * Cryptographic audience restriction (M5b — docs/PROTOCOL.md §2 kGroup,
 * §4 gkey, §5 grant, §5b sealed shapes).
 *
 * Per contact-group g the OWNER mints a 32-byte epoch secret G_1 (gkey op in
 * the home log — plaintext there; the room is seed-holders-only) plus a
 * random public `gidHint` (16 B) that stands in for the gid on the public
 * plane, so outsiders can't correlate group structure across users. Content
 * restricted to g is sealed under
 *   kGroup(g,n) = HKDF(G_n, salt=APP_SALT, info="cp-group-op|v1")
 * with AAD "cp-aud|v1|<gidHint>|<epoch>|<ownerPub>" and embedded INSIDE
 * public docs — existence visible, content opaque.
 *
 * Membership changes:
 *  - member ADDED   → send the current epoch + the full keychain over the
 *    pairwise room ({t:"grant"} §5). A mid-life addition gets FULL history
 *    access (chain cascades backward) — the board semantic, documented.
 *  - member REMOVED (or a contact removed who was a member) → mint G_{n+1},
 *    re-grant to the remaining members, and let owners re-seal published
 *    content (onRekeyed hook). Removal is NOT retraction: the removed member
 *    keeps every epoch already held and everything already seen; only content
 *    sealed under LATER epochs goes dark for them. No forward secrecy exists
 *    within an epoch. Both limits ship in-app (RO/EN).
 *
 * Received grants persist as {t:"rgrant"} home-log ops keyed by the VERIFIED
 * pairwise sender — the home log is the keystore, so my other devices unseal
 * too, and one contact can never clobber keys granted by another.
 *
 * Keychain mechanics reuse the FROZEN lib/epoch.ts EpochKeyring (sealChain /
 * cascade): chain box n opens E_{n-1} with E_n; keyring "room id" =
 * "cp-aud|<gidHint>|<ownerPub>" so chain AADs bind to the group + owner.
 */

import { encode as cborEncode, decode as cborDecode } from "@ipld/dag-cbor";
import { APP_SALT } from "../config";
import { AtRestCipher, fromB64, fromB64url, randomBytes, toB64, toB64url, type Bytes } from "../lib/crypto";
import { EpochKeyring } from "../lib/epoch";
import type { Box } from "../lib/protocol";
import { sanitizeText, ACT_DESC_MAX, ACT_LOC_MAX, POST_TEXT_MAX, type SealedBoxEnv, type SealedSectionRef } from "../public/public-doc";
import { membersOfGroup } from "./contacts";
import { sanitizeName, type GKeyOp, RGRANT_CHAIN_MAX } from "./home-log";
import type { SelfRoom } from "./selfroom";

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

const HINT_RE = /^[A-Za-z0-9_-]{22}$/;
const SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
const goodEpoch = (v: unknown): v is number =>
  typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 1_000_000;

// ---- §5 pairwise grant op ------------------------------------------------------

/** Audience key delivery over a pairwise room. `chain[n]` opens E_{n-1}. */
export interface GrantOp {
  t: "grant";
  ts: number;
  gidHint: string;
  epoch: number;
  secret: string;
  chain?: Record<string, Box>;
}

/** Wire-shape clamp for a received pairwise grant; null ⇒ drop silently. */
export function parseGrant(raw: unknown): GrantOp | null {
  const g = raw as Partial<GrantOp> | null;
  if (!g || typeof g !== "object" || g.t !== "grant") return null;
  if (typeof g.gidHint !== "string" || !HINT_RE.test(g.gidHint)) return null;
  if (!goodEpoch(g.epoch)) return null;
  if (typeof g.secret !== "string" || !SECRET_RE.test(g.secret)) return null;
  let chain: Record<string, Box> | undefined;
  if (g.chain !== undefined) {
    if (!g.chain || typeof g.chain !== "object" || Array.isArray(g.chain)) return null;
    const entries = Object.entries(g.chain as Record<string, unknown>);
    if (entries.length > RGRANT_CHAIN_MAX) return null;
    chain = {};
    for (const [k, v] of entries) {
      if (!/^[0-9]{1,7}$/.test(k)) return null;
      const n = Number(k);
      if (!Number.isInteger(n) || n < 2 || n > 1_000_000) return null;
      const b = v as Partial<Box> | null;
      if (!b || typeof b !== "object") return null;
      if (typeof b.iv !== "string" || b.iv.length === 0 || b.iv.length > 24) return null;
      if (typeof b.ct !== "string" || b.ct.length === 0 || b.ct.length > 96) return null;
      chain[k] = { iv: b.iv, ct: b.ct };
    }
  }
  return {
    t: "grant",
    ts: typeof g.ts === "number" ? g.ts : Date.now(),
    gidHint: g.gidHint,
    epoch: g.epoch,
    secret: g.secret,
    ...(chain ? { chain } : {}),
  };
}

// ---- §5b sealed plaintext shapes (dag-cbor) ------------------------------------

export const RESTRICTED_ABOUT_MAX = 500;
export const RESTRICTED_CONTACT_MAX = 200;
export const RESTRICTED_CUSTOM_MAX = 8;
export const RESTRICTED_LABEL_MAX = 40;
export const RESTRICTED_VAL_MAX = 200;

export interface RestrictedCustomField {
  label: string;
  val: string;
}

/** Plaintext of one sealed profile section (v1 shape). */
export interface RestrictedSection {
  about: string;
  contact: string;
  custom: RestrictedCustomField[];
}

/** A restricted section as edited/stored in the OWN profile source (§4). */
export interface RestrictedSectionSrc extends RestrictedSection {
  gid: string;
}

/** Clamp arbitrary section data into the canonical v1 shape. */
export function clampRestrictedSection(raw: Partial<RestrictedSection> | null | undefined): RestrictedSection {
  const about = typeof raw?.about === "string" ? sanitizeText(raw.about, RESTRICTED_ABOUT_MAX) : "";
  const contact = typeof raw?.contact === "string" ? sanitizeText(raw.contact, RESTRICTED_CONTACT_MAX) : "";
  const custom: RestrictedCustomField[] = [];
  for (const f of Array.isArray(raw?.custom) ? raw.custom : []) {
    if (custom.length >= RESTRICTED_CUSTOM_MAX) break;
    const label = typeof f?.label === "string" ? sanitizeName(f.label).slice(0, RESTRICTED_LABEL_MAX) : "";
    const val = typeof f?.val === "string" ? sanitizeText(f.val, RESTRICTED_VAL_MAX) : "";
    if (label && val) custom.push({ label, val });
  }
  return { about, contact, custom };
}

export function isEmptyRestrictedSection(s: RestrictedSection): boolean {
  return !s.about && !s.contact && s.custom.length === 0;
}

/** Sealed profile-section plaintext = dag-cbor {v:1, about?, contact?, custom?}. */
export function encodeRestrictedProfile(s: RestrictedSection): Bytes {
  const doc: Record<string, unknown> = { v: 1 };
  if (s.about) doc.about = s.about;
  if (s.contact) doc.contact = s.contact;
  if (s.custom.length > 0) doc.custom = s.custom.map((f) => ({ label: f.label, val: f.val }));
  return cborEncode(doc) as Bytes;
}

export function decodeRestrictedProfile(b: Bytes): RestrictedSection | null {
  try {
    const d = cborDecode(b as Uint8Array) as Partial<RestrictedSection> & { v?: unknown };
    if (!d || typeof d !== "object" || Array.isArray(d) || d.v !== 1) return null;
    const sec = clampRestrictedSection(d);
    return isEmptyRestrictedSection(sec) ? null : sec;
  } catch {
    return null;
  }
}

/** Sealed post body = dag-cbor {v:1, text} — restricted posts are text-only. */
export function encodeRestrictedPost(text: string): Bytes {
  return cborEncode({ v: 1, text: sanitizeText(text, POST_TEXT_MAX) }) as Bytes;
}

export function decodeRestrictedPost(b: Bytes): string | null {
  try {
    const d = cborDecode(b as Uint8Array) as { v?: unknown; text?: unknown };
    if (!d || typeof d !== "object" || Array.isArray(d) || d.v !== 1) return null;
    if (typeof d.text !== "string") return null;
    const text = sanitizeText(d.text, POST_TEXT_MAX);
    return text.length > 0 ? text : null;
  } catch {
    return null;
  }
}

/**
 * Sealed activity body (P1) = dag-cbor {v:1, desc?, loc?} — text-only, like
 * restricted posts. The activity TITLE stays public by design (the
 * discoverable shell); only desc/loc are sealed for the group.
 */
export function encodeRestrictedActivity(desc: string, loc: string): Bytes {
  const doc: Record<string, unknown> = { v: 1 };
  const d = sanitizeText(desc, ACT_DESC_MAX);
  const l = sanitizeText(loc, ACT_LOC_MAX);
  if (d) doc.desc = d;
  if (l) doc.loc = l;
  return cborEncode(doc) as Bytes;
}

export function decodeRestrictedActivity(b: Bytes): { desc: string; loc: string } | null {
  try {
    const d = cborDecode(b as Uint8Array) as { v?: unknown; desc?: unknown; loc?: unknown };
    if (!d || typeof d !== "object" || Array.isArray(d) || d.v !== 1) return null;
    const desc = typeof d.desc === "string" ? sanitizeText(d.desc, ACT_DESC_MAX) : "";
    const loc = typeof d.loc === "string" ? sanitizeText(d.loc, ACT_LOC_MAX) : "";
    return { desc, loc };
  } catch {
    return null;
  }
}

// ---- kGroup crypto (§2) --------------------------------------------------------

/** kGroup(g,n) content cipher — AAD binds group hint + epoch + owner (§5b). */
async function kGroupCipher(secretB64url: string, gidHint: string, epoch: number, ownerPub: string): Promise<AtRestCipher> {
  const ikm = await crypto.subtle.importKey("raw", fromB64url(secretB64url) as BufferSource, "HKDF", false, [
    "deriveKey",
  ]);
  const key = await crypto.subtle.deriveKey(
    {
      name: "HKDF",
      hash: "SHA-256",
      salt: utf8(APP_SALT) as BufferSource,
      info: utf8("cp-group-op|v1") as BufferSource,
    },
    ikm,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
  return new AtRestCipher(key, utf8(`cp-aud|v1|${gidHint}|${epoch}|${ownerPub}`));
}

/** [0x02|iv|ct] AtRestCipher envelope from a wire SealedBoxEnv; null on junk. */
function envOf(box: SealedBoxEnv): Bytes | null {
  try {
    const iv = fromB64(box.iv);
    const ct = fromB64(box.ct);
    if (iv.length !== 12 || ct.length < 16) return null;
    const env = new Uint8Array(1 + 12 + ct.length);
    env[0] = 0x02;
    env.set(iv, 1);
    env.set(ct, 13);
    return env;
  } catch {
    return null;
  }
}

/** The chain keyring "room id" — binds chain-box AADs to group + owner. */
const chainRoomId = (gidHint: string, ownerPub: string): string => `cp-aud|${gidHint}|${ownerPub}`;

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

/** A minimal pairwise sink: the pair room's append (core/hub RoomHandle). */
export type PairOpener = (pub: string) => Promise<{ append(op: { t: string }): Promise<unknown> } | null>;

export class AudienceService {
  #room: SelfRoom;
  #myPub: string;
  #openPair: PairOpener;
  /** Fired after a rekey landed — owners re-seal published content (profile). */
  onRekeyed: (gid: string) => void = () => {};
  /**
   * Every gkey THIS device minted, key `${gid}|${epoch}` → its secret. Used by
   * converge() to notice when one lost LWW to another device's concurrent mint
   * (§3b convergence). In-memory: the concern is concurrent LIVE minting; a
   * restart lands after the fold has already settled on the winner.
   */
  #minted = new Map<string, string>();
  #converging = false;

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

  // ---- owner-side key lifecycle ------------------------------------------------

  /** Every gkey op of one group, ascending by epoch. */
  #keysOf(gid: string): GKeyOp[] {
    return [...this.#room.state.gkeys.values()].filter((k) => k.gid === gid).sort((a, b) => a.epoch - b.epoch);
  }

  /** The group's CURRENT epoch key, or null when it was never restricted. */
  currentKey(gid: string): GKeyOp | null {
    const keys = this.#keysOf(gid);
    return keys.length > 0 ? keys[keys.length - 1] : null;
  }

  /**
   * Mint G_1 + gidHint on the group's first restriction use (no-op when a key
   * exists). Freshly minted keys are granted to the current members at once.
   */
  async ensureGroupKey(gid: string): Promise<GKeyOp> {
    const cur = this.currentKey(gid);
    if (cur) return cur;
    if (this.#room.state.groups.get(gid)?.status !== "active") throw new Error("unknown group");
    const op: GKeyOp = {
      t: "gkey",
      ts: Date.now(),
      gid,
      epoch: 1,
      secret: toB64url(randomBytes(32)),
      hint: toB64url(randomBytes(16)),
    };
    await this.#room.appendHomeOp(op);
    this.#minted.set(`${gid}|${op.epoch}`, op.secret);
    await this.#grantToMembers(gid);
    return op;
  }

  /**
   * Mint G_{n+1} (same hint) and re-grant the remaining members. Called on
   * member removal; new content seals under the new epoch, old epochs stay
   * with whoever holds them (removal ≠ retraction, §7).
   */
  async rekey(gid: string): Promise<void> {
    const cur = this.currentKey(gid);
    if (!cur) return;
    const op: GKeyOp = {
      t: "gkey",
      ts: Date.now(),
      gid,
      epoch: cur.epoch + 1,
      secret: toB64url(randomBytes(32)),
      hint: cur.hint,
    };
    await this.#room.appendHomeOp(op);
    this.#minted.set(`${gid}|${op.epoch}`, op.secret);
    await this.#grantToMembers(gid);
    try {
      this.onRekeyed(gid);
    } catch {}
  }

  /** The §5 grant for this group's CURRENT epoch + full keychain. */
  async #buildGrant(gid: string): Promise<GrantOp | null> {
    const keys = this.#keysOf(gid);
    if (keys.length === 0) return null;
    const cur = keys[keys.length - 1];
    const chain: Record<string, Box> = {};
    if (cur.epoch > 1) {
      const keyring = new EpochKeyring(APP_SALT, chainRoomId(cur.hint, this.#myPub));
      for (const k of keys) keyring.addEpoch(k.epoch, k.secret);
      // cap the chain to the NEWEST RGRANT_CHAIN_MAX boxes: the receiver clamps
      // an rgrant chain at RGRANT_CHAIN_MAX (home-log.ts) and DROPS a grant whose
      // chain exceeds it — so after that many rekeys an uncapped chain would cost
      // a member every key. The newest boxes cascade back the furthest reachable.
      const chainStart = Math.max(2, cur.epoch - RGRANT_CHAIN_MAX + 1);
      for (let n = chainStart; n <= cur.epoch; n++) {
        if (keyring.has(n) && keyring.has(n - 1)) chain[String(n)] = await keyring.sealChain(n);
      }
    }
    return {
      t: "grant",
      ts: Date.now(),
      gidHint: cur.hint,
      epoch: cur.epoch,
      secret: cur.secret,
      ...(Object.keys(chain).length > 0 ? { chain } : {}),
    };
  }

  /** Deliver the current grant to ONE member over the pairwise room. */
  async grantTo(gid: string, pub: string): Promise<void> {
    const op = await this.#buildGrant(gid);
    if (!op) return;
    try {
      const sink = await this.#openPair(pub);
      await sink?.append(op);
    } catch {} // best-effort: the pair room's persisted queue retries deposits
  }

  /** Re-grant every CURRENT member (fold state already reflects the change). */
  async #grantToMembers(gid: string): Promise<void> {
    const op = await this.#buildGrant(gid);
    if (!op) return;
    for (const m of membersOfGroup(this.#room.state, gid)) {
      try {
        const stamped: GrantOp = { ...op, ts: Date.now() };
        const sink = await this.#openPair(m.pub);
        await sink?.append(stamped);
      } catch {}
    }
  }

  // ---- membership-change hooks (wired to ContactsService by boot) --------------

  /** gmember toggled. Added ⇒ grant current epoch + chain; removed ⇒ rekey. */
  async onMembershipChanged(gid: string, pub: string, on: boolean): Promise<void> {
    if (!this.currentKey(gid)) return; // group never restricted anything — keyless
    if (on) await this.grantTo(gid, pub);
    else await this.rekey(gid);
  }

  /** A contact was removed: rekey every keyed group they were a member of. */
  async onContactRemoved(pub: string): Promise<void> {
    const state = this.#room.state;
    for (const g of state.groups.values()) {
      if (g.status !== "active") continue;
      if (state.gmembers.get(`${g.gid}|${pub}`)?.on !== true) continue;
      if (!this.currentKey(g.gid)) continue;
      await this.rekey(g.gid);
    }
  }

  /**
   * An accepted TOFU key change (contacts.ts acceptKeyChange): treat it as
   * remove+re-add. For every keyed group this STILL-ACTIVE contact is in,
   * rekey (the old key held via the compromised binding stops opening NEW
   * content) then re-grant the current epoch to them over the NEW pair room.
   * Defense-in-depth for the compromise path — old epochs already held stay
   * with whoever holds them (removal ≠ retraction, §7).
   */
  async onKeyChanged(pub: string): Promise<void> {
    const state = this.#room.state;
    for (const g of state.groups.values()) {
      if (g.status !== "active") continue;
      if (state.gmembers.get(`${g.gid}|${pub}`)?.on !== true) continue;
      if (!this.currentKey(g.gid)) continue;
      await this.rekey(g.gid); // re-grants remaining members over their pair rooms
      await this.grantTo(g.gid, pub); // ensure the re-added contact gets it over the NEW pair room
    }
  }

  // ---- multi-device convergence (§3b) ------------------------------------------

  /**
   * Called after every home-log fold. Two devices of one owner can concurrently
   * mint a group's FIRST epoch key (ensureGroupKey) — or a first restriction can
   * race a rekey — leaving two gkeys for the same (gid, epoch) with DIFFERENT
   * secrets and gidHints. The home log's LWW keeps exactly ONE per (gid, epoch),
   * so one device's mint silently loses: it has already granted members its
   * losing key and sealed content under its losing hint, while the fold now
   * points everyone at the winner.
   *
   * CONVERGENCE RULE (§3b): the current (highest-epoch) gkey surviving the fold
   * is canonical — its gidHint is THE group's hint. When this device detects
   * that a gkey it minted lost LWW (the folded secret for that (gid, epoch) is
   * not the one it wrote), or that the group's folded gkeys carry more than one
   * gidHint, it re-grants the CURRENT epoch + full chain to the current members
   * (over their pair rooms) and fires onRekeyed so the owner re-seals published
   * content under the canonical key. All devices fold the same winner, so they
   * converge deterministically; confidentiality is untouched (removed members
   * are never re-granted, and immutable content already sealed under a
   * superseded hint stays put — removal ≠ retraction, §7).
   */
  async converge(): Promise<void> {
    if (this.#converging) return;
    this.#converging = true;
    try {
      const state = this.#room.state;
      const diverged = new Set<string>();
      for (const [key, secret] of [...this.#minted]) {
        const won = state.gkeys.get(key);
        if (!won) continue; // not folded yet — re-checked on the next fold
        const gid = key.slice(0, key.lastIndexOf("|"));
        if (won.secret !== secret) {
          // my mint for this (gid, epoch) lost LWW to another device's gkey
          diverged.add(gid);
          this.#minted.delete(key);
        } else {
          // mine won this slot — but if the group's folded gkeys disagree on the
          // gidHint, still converge everyone onto the canonical (current) hint
          const cur = this.currentKey(gid);
          if (cur && this.#keysOf(gid).some((k) => k.hint !== cur.hint)) diverged.add(gid);
        }
      }
      for (const gid of diverged) {
        if (!this.currentKey(gid)) continue;
        await this.#grantToMembers(gid); // re-grant the canonical current epoch + chain
        try {
          this.onRekeyed(gid); // re-seal published content under the canonical key
        } catch {}
      }
    } finally {
      this.#converging = false;
    }
  }

  // ---- receiving grants (pairwise control channel, §5) -------------------------

  /**
   * A pairwise fold surfaced a grant from a verified contact: persist it as
   * an rgrant home op (keyed by sender) so all MY devices can unseal.
   * Idempotent — a duplicate of what the fold already holds appends nothing.
   */
  async registerGrant(senderPub: string, raw: unknown): Promise<boolean> {
    if (senderPub === this.#myPub) return false; // my own echo (other device)
    const g = parseGrant(raw);
    if (!g) return false;
    const key = `${senderPub}|${g.gidHint}|${g.epoch}`;
    const existing = this.#room.state.rgrants.get(key);
    const chainLen = (c?: Record<string, Box>): number => Object.keys(c ?? {}).length;
    if (existing && existing.secret === g.secret && chainLen(existing.chain) >= chainLen(g.chain)) return false;
    await this.#room.appendHomeOp({
      t: "rgrant",
      ts: Math.max(Date.now(), (existing?.ts ?? 0) + 1),
      from: senderPub,
      gidHint: g.gidHint,
      epoch: g.epoch,
      secret: g.secret,
      ...(g.chain ? { chain: g.chain } : {}),
    });
    return true;
  }

  // ---- seal / unseal -----------------------------------------------------------

  /** Seal plaintext for a group under its CURRENT epoch (mints G_1 on first use). */
  async seal(gid: string, plaintext: Bytes): Promise<SealedSectionRef> {
    const cur = await this.ensureGroupKey(gid);
    const cipher = await kGroupCipher(cur.secret, cur.hint, cur.epoch, this.#myPub);
    const sealed = await cipher.seal(plaintext);
    return {
      gidHint: cur.hint,
      epoch: cur.epoch,
      box: { v: 1, iv: toB64(sealed.slice(1, 13) as Bytes), ct: toB64(sealed.slice(13) as Bytes) },
    };
  }

  /**
   * Unseal a box from `authorPub`'s doc with whatever keys I hold: my own
   * gkeys when I am the author, else received rgrants (direct epoch match or
   * a keychain cascade from a newer grant). Null = no key / tamper — the
   * caller renders the honest locked state.
   */
  async unseal(authorPub: string, ref: SealedSectionRef): Promise<Bytes | null> {
    if (!HINT_RE.test(ref.gidHint) || !goodEpoch(ref.epoch)) return null;
    const secret = await this.#secretFor(authorPub, ref.gidHint, ref.epoch);
    if (!secret) return null;
    const env = envOf(ref.box);
    if (!env) return null;
    try {
      const cipher = await kGroupCipher(secret, ref.gidHint, ref.epoch, authorPub);
      return await cipher.open(env);
    } catch {
      return null;
    }
  }

  async #secretFor(authorPub: string, gidHint: string, epoch: number): Promise<string | null> {
    const state = this.#room.state;
    if (authorPub === this.#myPub) {
      for (const k of state.gkeys.values()) if (k.hint === gidHint && k.epoch === epoch) return k.secret;
      return null;
    }
    const direct = state.rgrants.get(`${authorPub}|${gidHint}|${epoch}`);
    if (direct) return direct.secret;
    // keychain cascade: a NEWER grant from the same contact + its chain boxes
    const mine = [...state.rgrants.values()]
      .filter((r) => r.from === authorPub && r.gidHint === gidHint)
      .sort((a, b) => a.epoch - b.epoch);
    if (mine.length === 0) return null;
    const keyring = new EpochKeyring(APP_SALT, chainRoomId(gidHint, authorPub));
    const chains = new Map<number, Box>();
    for (const r of mine) {
      keyring.addEpoch(r.epoch, r.secret);
      for (const [k, box] of Object.entries(r.chain ?? {})) chains.set(Number(k), box);
    }
    if (keyring.current < epoch) return null; // sealed under a newer epoch than any I hold
    await keyring.cascade(chains);
    return keyring.epochSecret(epoch);
  }

  /**
   * Local display label for an unsealed box: MY group's name when I am the
   * author (gid↔hint mapping lives only in my home log); null for content
   * unsealed as a member — the UI shows a generic "visible to you" badge
   * (grants deliberately carry no group name).
   */
  labelFor(authorPub: string, gidHint: string): string | null {
    if (authorPub !== this.#myPub) return null;
    for (const k of this.#room.state.gkeys.values()) {
      if (k.hint === gidHint) return this.#room.state.groups.get(k.gid)?.name ?? null;
    }
    return null;
  }
}

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