summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/bundle_store.py
blob: 7f03caac95efe2626e0747f90f5462cfab0a3ed6 (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
"""
Bundle store — SQLite-backed storage for GEK bundles and keypair bundles.

GEK bundles: ECIES-wrapped GEK targeted at a specific user's X25519 key.
Keypair bundles: AES-GCM encrypted (Ed25519 + X25519) private keys, encrypted
with the user's password-derived bundle_key. Opaque to the node. An optional
second copy (bundle_enc_recovery) is wrapped under the account's recovery key
instead, so a forgotten passphrase does not strand the identity — see
docs/auth-confirm.md §4.3.

Both are stored and served over the P2P DataChannel during MNP handshake.
"""

import logging
from pathlib import Path

import aiosqlite

log = logging.getLogger(__name__)

_SCHEMA_GEK = """\
CREATE TABLE IF NOT EXISTS gek_bundles (
    group_id    TEXT NOT NULL,
    user_id     TEXT NOT NULL,
    pk_eph_b64  TEXT NOT NULL,
    nonce_b64   TEXT NOT NULL,
    wrapped_b64 TEXT NOT NULL,
    stored_at   TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (group_id, user_id)
);
"""

_SCHEMA_KEYPAIR = """\
CREATE TABLE IF NOT EXISTS keypair_bundles (
    user_id             TEXT PRIMARY KEY,
    bundle_enc          TEXT NOT NULL,
    bundle_enc_recovery TEXT,
    stored_at           TEXT NOT NULL DEFAULT (datetime('now'))
);
"""


class BundleStore:
    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))
        await self._db.execute(_SCHEMA_GEK)
        await self._db.execute(_SCHEMA_KEYPAIR)
        await self._migrate_keypair_recovery()
        await self._db.commit()

    async def _migrate_keypair_recovery(self) -> None:
        """
        Add bundle_enc_recovery to a keypair_bundles table created before it
        existed. SQLite has no ADD COLUMN IF NOT EXISTS, so check the columns
        first — this table is node-only and has no Alembic history.
        """
        assert self._db
        async with self._db.execute("PRAGMA table_info(keypair_bundles)") as cur:
            cols = {row[1] for row in await cur.fetchall()}
        if "bundle_enc_recovery" not in cols:
            await self._db.execute(
                "ALTER TABLE keypair_bundles ADD COLUMN bundle_enc_recovery TEXT")

    async def store(
        self,
        group_id: str,
        user_id: str,
        pk_eph_b64: str,
        nonce_b64: str,
        wrapped_b64: str,
    ) -> None:
        assert self._db
        await self._db.execute(
            "INSERT OR REPLACE INTO gek_bundles "
            "(group_id, user_id, pk_eph_b64, nonce_b64, wrapped_b64, stored_at) "
            "VALUES (?, ?, ?, ?, ?, datetime('now'))",
            (group_id, user_id, pk_eph_b64, nonce_b64, wrapped_b64),
        )
        await self._db.commit()

    async def fetch(self, group_id: str, user_id: str) -> dict | None:
        assert self._db
        async with self._db.execute(
            "SELECT pk_eph_b64, nonce_b64, wrapped_b64 FROM gek_bundles "
            "WHERE group_id = ? AND user_id = ?",
            (group_id, user_id),
        ) as cursor:
            row = await cursor.fetchone()
            if not row:
                return None
            return {
                "pk_eph_b64": row[0],
                "nonce_b64": row[1],
                "wrapped_b64": row[2],
            }

    async def store_keypair(
        self,
        user_id: str,
        bundle_enc: str,
        bundle_enc_recovery: str | None = None,
    ) -> None:
        """
        Store the passphrase-wrapped keypair bundle, and optionally a second
        copy wrapped under the account's recovery key.

        A call that omits bundle_enc_recovery — a plain re-backup, or a
        passphrase-change re-wrap (docs/auth-confirm.md §3.2) — must not erase a
        recovery copy already stored, so the upsert keeps the existing value
        when the new one is None.
        """
        assert self._db
        await self._db.execute(
            "INSERT INTO keypair_bundles "
            "(user_id, bundle_enc, bundle_enc_recovery, stored_at) "
            "VALUES (?, ?, ?, datetime('now')) "
            "ON CONFLICT(user_id) DO UPDATE SET "
            "  bundle_enc = excluded.bundle_enc, "
            "  bundle_enc_recovery = COALESCE(excluded.bundle_enc_recovery, "
            "                                 keypair_bundles.bundle_enc_recovery), "
            "  stored_at = excluded.stored_at",
            (user_id, bundle_enc, bundle_enc_recovery),
        )
        await self._db.commit()

    async def fetch_keypair(self, user_id: str) -> dict | None:
        assert self._db
        async with self._db.execute(
            "SELECT bundle_enc, bundle_enc_recovery FROM keypair_bundles "
            "WHERE user_id = ?",
            (user_id,),
        ) as cursor:
            row = await cursor.fetchone()
            if not row:
                return None
            return {"bundle_enc": row[0], "bundle_enc_recovery": row[1]}

    async def delete_keypair(self, user_id: str) -> bool:
        """
        Drop someone's keypair bundle at their own request.

        Backing keys up here is what lets a second browser recover them with the
        password — and it is also what puts a PBKDF2-protected blob on every node
        whose group they join (finding C4). Someone who does not need the first
        should be able to withdraw the second, and not merely stop adding to it.
        """
        assert self._db
        cur = await self._db.execute(
            "DELETE FROM keypair_bundles WHERE user_id = ?", (user_id,))
        await self._db.commit()
        return cur.rowcount > 0

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