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
|
"""
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.
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,
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._db.commit()
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) -> None:
assert self._db
await self._db.execute(
"INSERT OR REPLACE INTO keypair_bundles "
"(user_id, bundle_enc, stored_at) VALUES (?, ?, datetime('now'))",
(user_id, bundle_enc),
)
await self._db.commit()
async def fetch_keypair(self, user_id: str) -> str | None:
assert self._db
async with self._db.execute(
"SELECT bundle_enc FROM keypair_bundles WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
return row[0] if row else None
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
|