summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/roster.py
blob: f231792963d6427b54d259dc026fb8c67f1120e7 (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
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
"""
Node roster — who this node recognises, and which keys are theirs.

The node keeps its own answer to "may this person have the group key", derived from
what the operator authorized locally. It is deliberately NOT derived from the hub:
the hub decides group membership, and a hub that invents an account and mints a
token for it would otherwise collect the GEK on connect. Hub membership is an input
to the decision; it is not the decision.

Three tables:

  identities — one row per person, not per group. Someone paired for one group
               needs no code for the next one on the same node.
  members    — role and status per (group, user).
  invites    — one-time pairing codes, stored as a hash. The code itself exists
               only in the operator's hands and the invitee's.

The code is what binds a public key to an account without asking the hub
(finding H3). See `docs/invite-pairing-v1.md`.
"""

from __future__ import annotations

import hashlib
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path

import aiosqlite

log = logging.getLogger(__name__)

# Crockford base32 without I, L, O and U: no character pair a human can confuse
# when reading a code aloud or typing it from a phone screen.
_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
CODE_LEN = 8                      # 8 × 5 bits = 40 bits of entropy
DEFAULT_INVITE_TTL = 24 * 3600    # seconds

_SCHEMA = """\
CREATE TABLE IF NOT EXISTS identities (
    user_id    TEXT PRIMARY KEY,
    username   TEXT NOT NULL,
    pk_ed25519 TEXT NOT NULL,
    pk_x25519  TEXT NOT NULL,
    pinned_at  TEXT NOT NULL,
    pinned_via TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS members (
    group_id    TEXT NOT NULL,
    user_id     TEXT NOT NULL,
    role        TEXT NOT NULL,
    status      TEXT NOT NULL,
    approved_by TEXT NOT NULL,
    approved_at TEXT NOT NULL,
    PRIMARY KEY (group_id, user_id)
);

CREATE TABLE IF NOT EXISTS invites (
    code_hash  TEXT PRIMARY KEY,
    group_id   TEXT NOT NULL,
    user_id    TEXT NOT NULL,
    role       TEXT NOT NULL,
    created_by TEXT NOT NULL,
    created_at TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    used_at    TEXT
);
"""


def generate_code() -> str:
    """A fresh pairing code, formatted for a human to read out: XXXX-XXXX."""
    raw = "".join(secrets.choice(_ALPHABET) for _ in range(CODE_LEN))
    return f"{raw[:4]}-{raw[4:]}"


def normalize_code(code: str) -> str:
    """
    Fold what a human typed onto what was generated.

    Crockford's rules: case-insensitive, dashes and spaces are decoration, and the
    excluded letters map onto the digits they resemble. Someone reading a code over
    the phone should not be able to get it wrong in a way we could have absorbed.
    """
    out = []
    for ch in code.upper():
        if ch in "- \t":
            continue
        if ch in "IL":
            out.append("1")
        elif ch == "O":
            out.append("0")
        elif ch == "U":
            out.append("V")
        else:
            out.append(ch)
    return "".join(out)


def hash_code(code: str) -> str:
    """
    Store codes hashed: a stolen roster DB must not yield usable invitations.

    SHA-256 rather than a password KDF on purpose — the input is 40 bits of
    uniformly random secret, not a human-chosen string, so there is nothing for a
    slow hash to defend.
    """
    return hashlib.sha256(normalize_code(code).encode()).hexdigest()


def _now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


class Roster:
    def __init__(self, db_path: Path):
        self._db_path = db_path
        self._db: aiosqlite.Connection | None = None

    async def open(self) -> None:
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db = await aiosqlite.connect(str(self._db_path))
        self._db.row_factory = aiosqlite.Row
        # WAL: the CLI writes invites (`operator pair`) while the daemon reads them.
        await self._db.execute("PRAGMA journal_mode=WAL")
        await self._db.executescript(_SCHEMA)
        await self._db.commit()

    async def close(self) -> None:
        if self._db:
            await self._db.close()
            self._db = None

    # ── Identities ───────────────────────────────────────────────────────────

    async def pin_identity(
        self,
        user_id: str,
        username: str,
        pk_ed25519: str,
        pk_x25519: str,
        via: str,
    ) -> None:
        assert self._db
        await self._db.execute(
            "INSERT OR REPLACE INTO identities "
            "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (user_id, username, pk_ed25519, pk_x25519, _now(), via),
        )
        await self._db.commit()

    async def get_identity(self, user_id: str) -> dict | None:
        assert self._db
        async with self._db.execute(
            "SELECT * FROM identities WHERE user_id = ?", (user_id,)
        ) as cur:
            row = await cur.fetchone()
        return dict(row) if row else None

    async def unpin(self, user_id: str) -> bool:
        assert self._db
        cur = await self._db.execute(
            "DELETE FROM identities WHERE user_id = ?", (user_id,))
        await self._db.commit()
        return cur.rowcount > 0

    async def list_identities(self) -> list[dict]:
        assert self._db
        async with self._db.execute(
            "SELECT * FROM identities ORDER BY pinned_at"
        ) as cur:
            return [dict(r) for r in await cur.fetchall()]

    # ── Authority ────────────────────────────────────────────────────────────

    async def operator_pks(self) -> list[str]:
        """
        Base64 Ed25519 keys allowed to authorize admin operations on this node.

        Read fresh on every check rather than cached: an unpin must take effect at
        once, and this runs only on admin operations, which are rare.
        """
        assert self._db
        async with self._db.execute(
            "SELECT i.pk_ed25519 FROM identities i "
            "JOIN members m ON m.user_id = i.user_id "
            "WHERE m.role = 'operator' AND m.status = 'active'"
        ) as cur:
            return [r["pk_ed25519"] for r in await cur.fetchall()]

    async def has_operator(self) -> bool:
        return bool(await self.operator_pks())

    async def is_authorized(self, group_id: str, user_id: str) -> bool:
        """
        May this person be handed the group key?

        The node's own answer, not the hub's. Hub membership is what lets someone
        reach the node; this is what decides whether the key is wrapped for them —
        otherwise a hub that invents an account and mints a token for it would be
        served the GEK on connect.

        An operator is authorized for every group this node hosts: their authority
        is node-wide and is recorded with an empty group_id.
        """
        assert self._db
        async with self._db.execute(
            "SELECT 1 FROM members WHERE user_id = ? AND status = 'active' "
            "AND (group_id = ? OR (group_id = '' AND role = 'operator')) LIMIT 1",
            (user_id, group_id),
        ) as cur:
            return await cur.fetchone() is not None

    # ── Members ──────────────────────────────────────────────────────────────

    async def set_member(
        self,
        group_id: str,
        user_id: str,
        role: str,
        status: str,
        approved_by: str,
    ) -> None:
        assert self._db
        await self._db.execute(
            "INSERT OR REPLACE INTO members "
            "(group_id, user_id, role, status, approved_by, approved_at) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (group_id, user_id, role, status, approved_by, _now()),
        )
        await self._db.commit()

    async def get_member(self, group_id: str, user_id: str) -> dict | None:
        assert self._db
        async with self._db.execute(
            "SELECT * FROM members WHERE group_id = ? AND user_id = ?",
            (group_id, user_id),
        ) as cur:
            row = await cur.fetchone()
        return dict(row) if row else None

    async def list_members(self, group_id: str | None = None) -> list[dict]:
        assert self._db
        sql = (
            "SELECT m.*, i.username, i.pk_ed25519, i.pinned_at, i.pinned_via "
            "FROM members m LEFT JOIN identities i ON i.user_id = m.user_id"
        )
        args: tuple = ()
        if group_id is not None:
            sql += " WHERE m.group_id = ?"
            args = (group_id,)
        async with self._db.execute(sql + " ORDER BY m.approved_at", args) as cur:
            return [dict(r) for r in await cur.fetchall()]

    async def set_status(self, group_id: str, user_id: str, status: str) -> bool:
        assert self._db
        cur = await self._db.execute(
            "UPDATE members SET status = ? WHERE group_id = ? AND user_id = ?",
            (status, group_id, user_id),
        )
        await self._db.commit()
        return cur.rowcount > 0

    # ── Invites ──────────────────────────────────────────────────────────────

    async def create_invite(
        self,
        group_id: str,
        user_id: str,
        role: str,
        created_by: str,
        ttl: int = DEFAULT_INVITE_TTL,
    ) -> str:
        """
        Issue a one-time code. Returns it in the clear — this is the only moment it
        exists outside the operator's hands; only its hash is kept.

        Any earlier unused invite for the same person and group is dropped, so
        re-inviting supersedes rather than accumulating valid codes.
        """
        assert self._db
        await self._db.execute(
            "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL",
            (group_id, user_id),
        )
        code = generate_code()
        expires = datetime.now(timezone.utc) + timedelta(seconds=ttl)
        await self._db.execute(
            "INSERT INTO invites "
            "(code_hash, group_id, user_id, role, created_by, created_at, expires_at) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (hash_code(code), group_id, user_id, role, created_by, _now(),
             expires.isoformat(timespec="seconds")),
        )
        await self._db.commit()
        return code

    async def consume_invite(self, code: str, user_id: str) -> dict | None:
        """
        Redeem a code for `user_id`, or return None.

        Single use is enforced by the UPDATE's WHERE clause: two connections racing
        the same code cannot both see `used_at IS NULL`, so exactly one wins.
        """
        assert self._db
        code_hash = hash_code(code)
        async with self._db.execute(
            "SELECT * FROM invites WHERE code_hash = ?", (code_hash,)
        ) as cur:
            row = await cur.fetchone()
        if not row:
            return None

        invite = dict(row)
        if invite["used_at"] is not None:
            return None
        # A code is valid for exactly one account, so a leaked code cannot be
        # redeemed by whoever finds it first.
        if invite["user_id"] != user_id:
            return None
        if datetime.fromisoformat(invite["expires_at"]) < datetime.now(timezone.utc):
            return None

        cur = await self._db.execute(
            "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL",
            (_now(), code_hash),
        )
        await self._db.commit()
        if cur.rowcount == 0:
            return None
        return invite

    async def list_invites(self, include_used: bool = False) -> list[dict]:
        assert self._db
        sql = "SELECT * FROM invites"
        if not include_used:
            sql += " WHERE used_at IS NULL"
        async with self._db.execute(sql + " ORDER BY created_at") as cur:
            return [dict(r) for r in await cur.fetchall()]

    async def purge_expired(self) -> int:
        assert self._db
        cur = await self._db.execute(
            "DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?",
            (_now(),),
        )
        await self._db.commit()
        return cur.rowcount


async def open_roster(data_dir: Path) -> Roster:
    roster = Roster(data_dir / "roster.db")
    await roster.open()
    return roster


def write_code_file(data_dir: Path, code: str, expires_at: str) -> Path:
    """
    Leave the code in a file as well as on stdout.

    An operator working over SSH may not be able to copy out of their terminal,
    and a code that can only be read off a scrolled-away screen is a dead end.
    """
    path = data_dir / "pair-code"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(f"{code}\nexpires {expires_at}\n")
    os.chmod(path, 0o600)
    return path