Skip to content

Commit 3980348

Browse files
committed
Add sogs blinded key permission migration
Adds migration of unblinded user permissions to blinded user permissions on first login. This works by composing a list of unmigrated ids at sogs startup: we track the positive alternative of the two possible blinded Ed keys. When a user first authenticates and doesn't exist, we check to see if their |kA| is in the needs_migration table and, if it is, we construct the new `users` row by copying over everything from the old (unblinded) `users` row, updating all permissions and futures referencing the old id to instead reference the new id, and then remove global settings on the old account. Effectively this *transfers* all permissions from the old account to the new one, so that we aren't left with duplicates moderators/bans/etc. anywhere. This commit includes blinding tests, and removes the old derived key code (which was from an obsolete, draft blinded id design).
1 parent 32527e1 commit 3980348

File tree

10 files changed

+483
-43
lines changed

10 files changed

+483
-43
lines changed

contrib/pg-import.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"user_ban_futures",
6666
"user_request_nonces",
6767
"inbox",
68+
"needs_blinding",
6869
]
6970

7071
with pgsql.transaction():

sogs/crypto.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@
66
from nacl.public import PrivateKey
77
from nacl.signing import SigningKey, VerifyKey
88
from nacl.encoding import Base64Encoder, HexEncoder
9-
from nacl.bindings import crypto_scalarmult
9+
import nacl.bindings as sodium
1010

1111

1212
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
1313
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
1414

15-
from .utils import decode_hex_or_b64
1615
from .hashing import blake2b
1716

18-
import binascii
1917
import secrets
2018
import hmac
2119
import functools
@@ -93,15 +91,63 @@ def server_encrypt(pk, data):
9391
xed25519_verify = pyonionreq.xed25519.verify
9492
xed25519_pubkey = pyonionreq.xed25519.pubkey
9593

94+
# AKA "k" for blinding crypto:
95+
blinding_factor = sodium.crypto_core_ed25519_scalar_reduce(
96+
blake2b(server_pubkey_bytes, digest_size=64)
97+
)
98+
9699

97100
@functools.lru_cache(maxsize=1024)
98-
def compute_derived_key_bytes(pk_bytes):
99-
"""compute derived key as bytes with no prefix"""
100-
return crypto_scalarmult(server_pubkey_hash_bytes, pk_bytes)
101+
def compute_blinded_abs_key(x_pk: bytes, *, k: bytes = blinding_factor):
102+
"""
103+
Computes the *positive* blinded Ed25519 pubkey from an unprefixed session X25519 pubkey (i.e. 32
104+
bytes). The returned value will always have the sign bit (i.e. the most significant bit of the
105+
last byte) set to 0; the actual derived key associated with this session id could have either
106+
sign.
107+
108+
Input and result are in bytes, without the 0x05 or 0x15 prefix.
109+
110+
k allows you to compute for an alternative blinding factor, but should normally be omitted.
111+
"""
112+
A = xed25519_pubkey(x_pk)
113+
kA = sodium.crypto_scalarmult_ed25519_noclamp(k, A)
101114

115+
if kA[31] & 0x80:
116+
return kA[0:31] + bytes([kA[31] & 0x7F])
117+
return kA
118+
119+
120+
def compute_blinded_abs_id(session_id: str, *, k: bytes = blinding_factor):
121+
"""
122+
Computes the *positive* blinded id, as hex, from a prefixed, hex session id. This function is a
123+
wrapper around compute_derived_key_bytes that handles prefixes and hex conversions.
124+
125+
k allows you to compute for an alternative blinding factor, but should normally be omitted.
126+
"""
127+
return '15' + compute_blinded_abs_key(bytes.fromhex(session_id[2:]), k=k).hex()
128+
129+
130+
def blinded_abs(blinded_id: str):
131+
"""
132+
Takes a blinded hex pubkey (i.e. length 66, prefixed with 15) and returns the positive pubkey
133+
alternative: that is, if the pubkey is already positive, it is returned as-is; otherwise the
134+
returned value is a copy with the sign bit cleared.
135+
"""
136+
137+
# Sign bit is the MSB of the last byte, which will be at [31] of the private key, hence 64 is
138+
# the most significant nibble once we convert to hex and add 2 for the prefix:
139+
msn = int(blinded_id[64], 16)
140+
if msn & 0x8:
141+
return blinded_id[0:64] + str(msn & 0x7) + blinded_id[65:]
142+
return blinded_id
143+
144+
145+
def blinded_neg(blinded_id: str):
146+
"""
147+
Counterpart to blinded_abs that always returns the *negative* pubkey alternative.
148+
"""
102149

103-
def compute_derived_id(session_id, prefix='15'):
104-
"""compute derived session"""
105-
return prefix + binascii.hexlify(
106-
compute_derived_key_bytes(decode_hex_or_b64(session_id[2:], 32))
107-
).decode('ascii')
150+
msn = int(blinded_id[64], 16)
151+
if msn & 0x8:
152+
return blinded_id
153+
return blinded_id[0:64] + f"{msn | 0x8:x}" + blinded_id[65:]

sogs/db.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ def database_init(create=None, upgrade=True):
174174
# Make sure the system admin users exists
175175
create_admin_user(conn)
176176

177+
check_needs_blinding(conn)
178+
177179
return created or migrated
178180

179181

@@ -195,6 +197,36 @@ def create_admin_user(dbconn):
195197
)
196198

197199

200+
def check_needs_blinding(dbconn):
201+
if not config.REQUIRE_BLIND_KEYS:
202+
return
203+
204+
with transaction(dbconn):
205+
for uid, sid in query(
206+
"""
207+
SELECT id, session_id FROM users WHERE id IN (
208+
SELECT "user" FROM user_permission_overrides
209+
UNION
210+
SELECT "user" FROM user_permission_futures
211+
UNION
212+
SELECT "user" FROM user_ban_futures
213+
UNION
214+
SELECT id FROM users WHERE session_id LIKE '05%' AND (admin OR moderator OR banned)
215+
EXCEPT
216+
SELECT "user" FROM needs_blinding
217+
)
218+
""",
219+
dbconn=dbconn,
220+
):
221+
pos_derived = crypto.compute_blinded_abs_id(sid)
222+
query(
223+
'INSERT INTO needs_blinding (blinded_abs, "user") VALUES (:blinded, :uid)',
224+
blinded=pos_derived,
225+
uid=uid,
226+
dbconn=dbconn,
227+
)
228+
229+
198230
engine, engine_initial_pid, metadata = None, None, None
199231

200232

sogs/migrations/new_tables.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@
5252
expiry FLOAT DEFAULT (extract(epoch from now() + '15 days'))
5353
);
5454
CREATE INDEX inbox_recipient ON inbox(recipient);
55+
""",
56+
},
57+
'needs_blinding': {
58+
'sqlite': [
59+
"""
60+
CREATE TABLE needs_blinding (
61+
blinded_abs TEXT NOT NULL PRIMARY KEY,
62+
"user" BIGINT NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE
63+
)
64+
"""
65+
],
66+
'pgsql': """
67+
CREATE TABLE needs_blinding (
68+
blinded_abs TEXT NOT NULL PRIMARY KEY,
69+
"user" BIGINT NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE
70+
)
5571
""",
5672
},
5773
}

sogs/model/user.py

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from .. import crypto, db
3+
from .. import crypto, db, config
44
from ..db import query
55
from ..web import app
66
from .exc import NoSuchUser, BadPermission
@@ -28,9 +28,10 @@ def __init__(self, row=None, *, id=None, session_id=None, autovivify=True, touch
2828
"""
2929
Constructs a user from a pre-retrieved row *or* a session id or user primary key value.
3030
31-
autovivify - if True and we are given a session_id that doesn't exist, create a default user
32-
row and use it to populate the object. This is the default behaviour. If False and the
33-
session_id doesn't exist then a NoSuchUser is raised if the session id doesn't exist.
31+
autovivify - if True and we are given a session_id that doesn't exist, either consider
32+
importing from a pre-blinding user (if needed) or create a default user row and use it to
33+
populate the object. This is the default behaviour. If False and the session_id doesn't
34+
exist then a NoSuchUser is raised if the session id doesn't exist.
3435
3536
touch - if True (default is False) then update the last_activity time of this user before
3637
returning it.
@@ -56,11 +57,15 @@ def _refresh(self, *, row=None, id=None, session_id=None, autovivify=True):
5657
row = query("SELECT * FROM users WHERE session_id = :s", s=session_id).first()
5758

5859
if not row and autovivify:
59-
with db.transaction():
60-
query("INSERT INTO users (session_id) VALUES (:s)", s=session_id)
61-
row = query("SELECT * FROM users WHERE session_id = :s", s=session_id).first()
62-
# No need to re-touch this user since we just created them:
63-
self._touched = True
60+
if config.REQUIRE_BLIND_KEYS:
61+
row = self._import_blinded(session_id)
62+
63+
if not row:
64+
row = db.insert_and_get_row(
65+
"INSERT INTO users (session_id) VALUES (:s)", "users", "id", s=session_id
66+
)
67+
# No need to re-touch this user since we just created them:
68+
self._touched = True
6469

6570
elif id is not None:
6671
row = query("SELECT * FROM users WHERE id = :u", u=id).fetchone()
@@ -75,6 +80,64 @@ def _refresh(self, *, row=None, id=None, session_id=None, autovivify=True):
7580
bool(row[c]) for c in ('banned', 'moderator', 'admin', 'visible_mod')
7681
)
7782

83+
def _import_blinded(self, session_id):
84+
"""
85+
Attempts to import the user and permission rows from an unblinded session_id to a new,
86+
blinded session_id row.
87+
88+
Any permissions/bans are *moved* from the old, unblinded id to the new blinded user record.
89+
"""
90+
91+
if not session_id.startswith('15'):
92+
return
93+
blind_abs = crypto.blinded_abs(session_id.lower())
94+
with db.transaction():
95+
to_import = query(
96+
"""
97+
SELECT * FROM users WHERE id = (
98+
SELECT "user" FROM needs_blinding WHERE blinded_abs = :ba
99+
)
100+
""",
101+
ba=blind_abs,
102+
).fetchone()
103+
104+
if to_import is None:
105+
return False
106+
107+
row = db.insert_and_get_row(
108+
"""
109+
INSERT INTO users
110+
(session_id, created, last_active, banned, moderator, admin, visible_mod)
111+
VALUES (:sid, :cr, :la, :ban, :mod, :admin, :vis)
112+
""",
113+
"users",
114+
"id",
115+
sid=session_id,
116+
cr=to_import["created"],
117+
la=to_import["last_active"],
118+
ban=to_import["banned"],
119+
mod=to_import["moderator"],
120+
admin=to_import["admin"],
121+
vis=to_import["visible_mod"],
122+
)
123+
# If we have any global ban/admin/mod then clear them (because we've just set up the
124+
# global ban/mod/admin permissions for the blinded id in the query above).
125+
query(
126+
"UPDATE users SET banned = FALSE, admin = FALSE, moderator = FALSE WHERE id = :u",
127+
u=to_import["id"],
128+
)
129+
130+
for t in ("user_permission_overrides", "user_permission_futures", "user_ban_futures"):
131+
query(
132+
f'UPDATE {t} SET "user" = :new WHERE "user" = :old',
133+
new=row["id"],
134+
old=to_import["id"],
135+
)
136+
137+
query('DELETE FROM needs_blinding WHERE "user" = :u', u=to_import["id"])
138+
139+
return row
140+
78141
def __str__(self):
79142
"""Returns string representation of a user: U[050123…cdef], the id prefixed with @ or % if
80143
the user is a global admin or moderator, respectively."""
@@ -248,13 +311,6 @@ def system_user(self):
248311
created for internal database tasks"""
249312
return self.session_id[0:2] == "ff" and self.session_id[2:] == crypto.server_pubkey_hex
250313

251-
@property
252-
def derived_key(self):
253-
"""get the derived key for this user"""
254-
if self.session_id[0:2] == '15':
255-
return self.session_id
256-
return crypto.compute_derived_id(self.session_id)
257-
258314

259315
class SystemUser(User):
260316
"""

sogs/schema.pgsql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,17 @@ FOR EACH ROW WHEN (NEW.admin AND NOT NEW.moderator)
172172
EXECUTE PROCEDURE trigger_user_admins_are_mods();
173173

174174

175+
-- This table tracks unblinded session ids in user_permission (and related) rows that need to be
176+
-- blinded, which will happen the first time the user authenticates with their blinded id (until
177+
-- they do, we can't know the actual sign bit of their blinded id). It is populated at startup
178+
-- when blinding is first enabled, and is used both for the initial blinding transition and when
179+
-- ids are added by raw session ID (e.g. when adding a moderator by session id).
180+
CREATE TABLE needs_blinding (
181+
blinded_abs TEXT NOT NULL PRIMARY KEY, -- the positive of the possible two blinded keys
182+
"user" BIGINT NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE
183+
);
184+
185+
175186
-- Effectively the same as `messages` except that it also includes the `session_id` from the users
176187
-- table of the user who posted it, and the session id of the whisper recipient (as `whisper_to`) if
177188
-- a directed whisper.

sogs/schema.sqlite

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ BEGIN
151151
END;
152152

153153

154+
-- This table tracks unblinded session ids in user_permission (and related) rows that need to be
155+
-- blinded, which will happen the first time the user authenticates with their blinded id (until
156+
-- they do, we can't know the actual sign bit of their blinded id). It is populated at startup
157+
-- when blinding is first enabled, and is used both for the initial blinding transition and when
158+
-- ids are added by raw session ID (e.g. when adding a moderator by session id).
159+
CREATE TABLE needs_blinding (
160+
blinded_abs TEXT NOT NULL PRIMARY KEY, -- the positive of the possible two blinded keys
161+
"user" INTEGER NOT NULL UNIQUE REFERENCES users ON DELETE CASCADE
162+
);
163+
164+
165+
154166
-- Effectively the same as `messages` except that it also includes the `session_id` from the users
155167
-- table of the user who posted it, and the session id of the whisper recipient (as `whisper_to`) if
156168
-- a directed whisper.

0 commit comments

Comments
 (0)