aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-roster.js
blob: ca0769d5c7e52dce03b80723429ec2f994c99962 (plain) (blame)
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
// What a node says about who is in a group, checked rather than trusted: the
// roster's signatures, and the keys this browser pinned for each account.

/**
 * Walk each account's devices outwards from the one nobody countersigned.
 *
 * A device is *verified* when a chain of real signatures reaches it from that
 * account's root — the device an operator code admitted, which by definition
 * has no countersignature and is the trust-on-first-use anchor. Anything the
 * node lists but cannot evidence stays out of `verified`, so a substituted key
 * is not laundered into the set merely by being mentioned.
 *
 * Devices pinned before the evidence was kept (2026-09-07) carry no signature
 * and are treated exactly like a root: honest about what they are, rather than
 * quietly accepted as verified.
 */
async function _verifyRoster(payload, nodePk) {
  const C = window.MeshBayCrypto;
  const byAccount = new Map();
  const devices = payload.devices || [];

  const per = new Map();
  for (const d of devices) {
    if (!per.has(d.user_id)) per.set(d.user_id, []);
    per.get(d.user_id).push(d);
  }

  for (const [userId, list] of per) {
    // Roots first: no countersigner, or one whose evidence was never stored.
    const verified = [];
    const chain = new Map();
    const pending = [];
    for (const d of list) {
      // A root is a device that names **no** countersigner: an operator code
      // admitted it, and there is nothing to verify.
      //
      // Naming one and carrying no proof is *not* a root, and treating it as
      // one was a hole this file's tests caught: a node that writes the roster
      // can put any key it likes in an account's row, and if "no signature"
      // meant "root" it would have been laundered straight into `verified`.
      // Such a device is unevidenced — which is also the honest reading of one
      // pinned before the evidence was kept.
      if (!d.added_by_pk) verified.push(d.pk_ed25519);
      else pending.push(d);
    }
    // Then repeatedly admit anything countersigned by something already in.
    let progress = true;
    while (progress && pending.length) {
      progress = false;
      for (let i = pending.length - 1; i >= 0; i--) {
        const d = pending[i];
        if (!verified.includes(d.added_by_pk)) continue;
        let ok = false;
        try {
          const transcript = C.deviceAddTranscript(
            payload.node_pk || nodePk, userId, d.pk_ed25519, d.pk_x25519,
            C.b64decode(d.add_nonce), d.add_ts);
          ok = await C.verifyNodeSignature(d.added_by_pk, d.add_sig, transcript);
        } catch { ok = false; }
        if (ok) {
          verified.push(d.pk_ed25519);
          chain.set(d.pk_ed25519, d.added_by_pk);
          pending.splice(i, 1);
          progress = true;
        }
      }
    }
    byAccount.set(userId, {
      username: (list[0] || {}).username || '',
      all: list.map(d => d.pk_ed25519),
      verified,
      chain,
      // Listed by the node and not reachable by any chain of signatures.
      unevidenced: pending.map(d => d.pk_ed25519),
    });
  }
  return { byAccount };
}

// Which device keys this browser has accepted for each account, per node.
// localStorage rather than a runtime capability: it is a per-viewer
// convenience whose loss costs one "first sight" and never a wrong answer —
// forgetting a pin makes the next key read as `first`, not as verified.
const _PIN_NS = 'meshbay_account_pins';

function _pinKey(nodePk, userId) {
  return `${_PIN_NS}:${nodePk || ''}:${userId}`;
}

async function _readPinnedAccount(nodePk, userId) {
  try {
    const raw = localStorage.getItem(_pinKey(nodePk, userId));
    return raw ? JSON.parse(raw) : null;
  } catch { return null; }
}

async function _writePinnedAccount(nodePk, userId, keys) {
  try {
    localStorage.setItem(_pinKey(nodePk, userId), JSON.stringify(keys));
  } catch { /* private window, or storage refused — one more "first sight" */ }
}

/**
 * A wire payload as text.
 *
 * A plaintext message arrives as a string from the node; msgpack `bin` arrives
 * as a Uint8Array. Both have to render.
 *
 * This function was deleted once, with an unrelated helper that sat next to it,
 * and nothing complained: its only caller is inside `_openChatMessage`, whose
 * rejection the chat panel swallows in a `.catch()` that just marks the page
 * unloaded. The visible result was a conversation that rendered completely
 * empty, with no error in the console and the node answering perfectly — found
 * by `chat_send_probe.py`, not by reading this file.
 */
function _asText(payload) {
  if (payload instanceof Uint8Array) return new TextDecoder().decode(payload);
  if (payload == null) return '';
  return String(payload);
}