Skip to content

Commit 5e91ee2

Browse files
authored
Merge pull request #38 from jagerman/new-authentication
New authentication
2 parents 929f422 + 52fdbd4 commit 5e91ee2

20 files changed

+1190
-38
lines changed

api.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,8 +1089,8 @@ paths:
10891089
body:
10901090
description: >
10911091
Request body of the onion request. This is typically used for text-based
1092-
values (such as JSON requests). Exclusive of `body_b64` and `body_json`. Not
1093-
accepted for `GET` requests.
1092+
values (such as JSON requests). Exclusive of `body_binary`. Not accepted for
1093+
`GET` requests.
10941094
type: string
10951095
body_binary:
10961096
description: >
@@ -1101,7 +1101,7 @@ paths:
11011101
allowing you to POST to endpoints that require binary data (such as file
11021102
uploads). Note that when using the Content-Type header defaults to
11031103
`application/octet-stream`.
1104-
1104+
11051105
Note that when including a signature in the `X-SOGS-Signature` header, the
11061106
signature must use the decoded byte value of the body, *not* the encoded
11071107
base64 value.

contrib/auth-example.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Example script for demonstrating X-SOGS-* authentication calculation.
2+
3+
from nacl.bindings import crypto_scalarmult
4+
from nacl.public import PrivateKey, PublicKey
5+
from hashlib import blake2b
6+
from base64 import b64encode
7+
8+
# import time
9+
# import nacl.utils
10+
11+
# We're going to make a request for:
12+
method = 'GET'
13+
path = '/room/the-best-room/messages/recent?limit=25'
14+
# Should use current integer unix time:
15+
# ts = int(time.time())
16+
# But for this example I'll use this fixed value:
17+
ts = 1642472103
18+
19+
# Server pubkey:
20+
B = PublicKey(bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d'))
21+
22+
# Don't worry, this isn't an actually used session private key. Also
23+
# note that this is the x25519 priv key, *not* the ed25519 priv key.
24+
a = PrivateKey(bytes.fromhex('881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4'))
25+
A = a.public_key
26+
27+
# 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d:
28+
session_id = '05' + A.encode().hex()
29+
30+
# We should do something like this here:
31+
# nonce = nacl.utils.random(16)
32+
# but for this example I'll use this random nonce:
33+
nonce = b'\t\xd0y\x9f"\x95\x99\x01\x82\xc3\xab4\x06\xfb\xfc['
34+
35+
# Shared key calculation:
36+
q = crypto_scalarmult(a.encode(), B.encode()) + A.encode() + B.encode()
37+
r = blake2b(q, digest_size=42, salt=nonce, person=b'sogs.shared_keys').digest()
38+
39+
# Final hash calculation; start without the body:
40+
hasher = blake2b(
41+
method.encode() + path.encode() + str(ts).encode(),
42+
digest_size=42,
43+
key=r,
44+
salt=nonce,
45+
person=b'sogs.auth_header',
46+
)
47+
48+
# Now add the body to the hash, if applicable. For this GET request
49+
# there is no body, so this update does nothing. For a POST request this
50+
# this would be the body bytes. (By using a separate update call I avoid
51+
# having to copy the body again, which is good if the body is large).
52+
hasher.update(b'')
53+
54+
h = hasher.digest()
55+
56+
headers = {
57+
'X-SOGS-Pubkey': session_id,
58+
'X-SOGS-Timestamp': str(ts),
59+
'X-SOGS-Nonce': b64encode(nonce).decode(),
60+
'X-SOGS-Hash': b64encode(h).decode(),
61+
}
62+
63+
for h, v in headers.items():
64+
print(f"{h}: {v}")
65+
66+
# Prints:
67+
# X-SOGS-Pubkey: 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d
68+
# X-SOGS-Timestamp: 1642472103
69+
# X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww==
70+
# X-SOGS-Hash: 0wToLPfUpUSGHGT8n9VIJev5SJ97hUvQTRqBowpnWTqfGb+ldTRa9mU1

sogs/cleanup.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ def cleanup():
1717
msg_hist = prune_message_history()
1818
room_act = prune_room_activity()
1919
perm_upd = apply_permission_updates()
20+
exp_nonces = expire_nonce_history()
2021
app.logger.debug(
21-
"Pruned {} files, {} msg hist, {} room activity, {} perm updates".format(
22-
files, msg_hist, room_act, perm_upd
23-
)
22+
f"Pruned {files} files, {msg_hist} msg hist, {room_act} room activity, "
23+
f"{exp_nonces} nonces; applied {perm_upd} perm updates."
2424
)
25-
return (files, msg_hist, room_act, perm_upd)
25+
return (files, msg_hist, room_act, perm_upd, exp_nonces)
2626
except Exception as e:
2727
app.logger.warning(f"Periodic database cleanup failed: {e}")
2828
return None
@@ -87,6 +87,10 @@ def prune_room_activity():
8787
return count
8888

8989

90+
def expire_nonce_history():
91+
return query("DELETE FROM user_request_nonces WHERE expiry < :exp", exp=time.time()).rowcount
92+
93+
9094
def apply_permission_updates():
9195
with db.transaction():
9296
now = time.time()

sogs/crypto.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
server_pubkey = _privkey.public_key
2626

27+
server_pubkey_bytes = server_pubkey.encode()
2728
server_pubkey_hex = server_pubkey.encode(HexEncoder).decode('ascii')
2829
server_pubkey_base64 = server_pubkey.encode(Base64Encoder).decode('ascii')
2930

sogs/db.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def database_init():
113113
# Database migrations/updates/etc.
114114
for migrate in (
115115
migrate_v01x,
116+
add_new_tables,
116117
add_new_columns,
117118
update_message_views,
118119
create_message_details_deleter,
@@ -173,6 +174,41 @@ def add_new_columns(conn):
173174
return added
174175

175176

177+
def add_new_tables(conn):
178+
added = False
179+
if 'user_request_nonces' not in metadata.tables:
180+
if engine.name == 'sqlite':
181+
conn.execute(
182+
"""
183+
CREATE TABLE user_request_nonces (
184+
user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
185+
nonce BLOB NOT NULL UNIQUE,
186+
expiry FLOAT NOT NULL DEFAULT ((julianday('now') - 2440587.5 + 1.0)*86400.0) /* now + 24h */
187+
)
188+
"""
189+
)
190+
conn.execute("CREATE INDEX user_request_nonces_expiry ON user_request_nonces(expiry)")
191+
else:
192+
conn.execute(
193+
"""
194+
CREATE TABLE user_request_nonces (
195+
"user" BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
196+
nonce BLOB NOT NULL,
197+
expiry FLOAT NOT NULL DEFAULT (extract(epoch from now() + '24 hours'))
198+
)
199+
"""
200+
)
201+
conn.execute(
202+
"CREATE UNIQUE INDEX user_request_nonces_nonce"
203+
" ON user_request_nonces USING HASH (nonce)"
204+
)
205+
conn.execute("CREATE INDEX user_request_nonces_expiry ON user_request_nonces(expiry)")
206+
207+
added = True
208+
209+
return added
210+
211+
176212
def update_message_views(conn):
177213
if engine.name != "sqlite":
178214
if any(x not in metadata.tables['message_metadata'].c for x in ('whisper_to', 'filtered')):

sogs/hashing.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import nacl.hashlib
2+
3+
4+
def blake2b(
5+
data, *, digest_size: int = 32, key: bytes = b'', salt: bytes = b'', person: bytes = b''
6+
):
7+
"""
8+
Calculates a Blake2B hash.
9+
10+
Parameters:
11+
12+
data -- can be bytes, or an iterable containing bytes or byte-like values. (The latter case is
13+
particularly recommended to avoid needing to concatenate existing, potentially large, byte
14+
values).
15+
16+
digest_size -- the digest size, in bytes, which affects both the resulting length but also the
17+
hash itself (i.e. shorter digest sizes are not substrings of longer hash sizes).
18+
19+
key -- a key, for a keyed hash, which can be up to 64 bytes.
20+
21+
salt -- a salt for generating distinct hashes for the same data. Can be up to 16 bytes; if
22+
shorter than 16 it will be padded with null bytes.
23+
24+
person -- a personalization value, which works essentially like a second salt but is typically a
25+
unique fixed string for a particular hash purpose.
26+
27+
Returns a bytes of length `digest_size`.
28+
"""
29+
30+
hasher = nacl.hashlib.blake2b(digest_size=digest_size, key=key, salt=salt, person=person)
31+
if isinstance(data, bytes):
32+
hasher.update(data)
33+
else:
34+
for part in data:
35+
hasher.update(part)
36+
37+
return hasher.digest()

sogs/http.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
UNAUTHORIZED = 401
77
FORBIDDEN = 403
88
NOT_FOUND = 404
9+
NOT_ACCEPTABLE = 406
910
PRECONDITION_FAILED = 412
1011
PAYLOAD_TOO_LARGE = 413
12+
TOO_EARLY = 425
1113
TOO_MANY_REQUESTS = 429
1214
INTERNAL_SERVER_ERROR = 500
1315
BAD_GATEWAY = 502

sogs/model/room.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -938,12 +938,12 @@ def get_bans(self):
938938
it should only be accessed by moderators/admins.
939939
"""
940940

941-
return [
941+
return sorted(
942942
r[0]
943943
for r in query(
944944
"SELECT session_id FROM user_permissions WHERE room = :r AND banned", r=self.id
945945
)
946-
]
946+
)
947947

948948
def set_permissions(self, user: User, *, mod: User, **perms):
949949
"""

sogs/model/user.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ class User:
1818
created - unix timestamp when the user was created
1919
last_active - unix timestamp when the user was last active
2020
banned - True if the user is (globally) banned
21-
admin - True if the user is a global admin
22-
moderator - True if the user is a global moderator
21+
global_admin - True if the user is a global admin
22+
global_moderator - True if the user is a global moderator
2323
visible_mod - True if the user's admin/moderator status should be visible in rooms
2424
"""
2525

@@ -63,8 +63,7 @@ def __init__(self, row=None, *, id=None, session_id=None, autovivify=True, touch
6363
)
6464

6565
if touch:
66-
with db.transaction():
67-
self._touch()
66+
self._touch()
6867

6968
def __str__(self):
7069
"""Returns string representation of a user: U[050123…cdef], the id prefixed with @ or % if
@@ -96,8 +95,7 @@ def touch(self, force=False):
9695
to True.
9796
"""
9897
if not self._touched or force:
99-
with db.transaction():
100-
self._touch()
98+
self._touch()
10199

102100
def update_room_activity(self, room):
103101
query(
@@ -124,17 +122,16 @@ def set_moderator(self, *, added_by: User, admin=False, visible=False):
124122
)
125123
raise BadPermission()
126124

127-
with db.transaction():
128-
query(
129-
"""
130-
UPDATE users
131-
SET moderator = TRUE, admin = :admin, visible_mod = :visible
132-
WHERE id = :u
133-
""",
134-
admin=admin,
135-
visible=visible,
136-
u=self.id,
137-
)
125+
query(
126+
"""
127+
UPDATE users
128+
SET moderator = TRUE, admin = :admin, visible_mod = :visible
129+
WHERE id = :u
130+
""",
131+
admin=admin,
132+
visible=visible,
133+
u=self.id,
134+
)
138135
self.global_admin = admin
139136
self.global_moderator = True
140137
self.visible_mod = visible
@@ -148,11 +145,38 @@ def remove_moderator(self, *, removed_by: User):
148145
)
149146
raise BadPermission()
150147

151-
with db.transaction():
152-
query("UPDATE users SET moderator = FALSE, admin = FALSE WHERE id = :u", u=self.id)
148+
query("UPDATE users SET moderator = FALSE, admin = FALSE WHERE id = :u", u=self.id)
153149
self.global_admin = False
154150
self.global_moderator = False
155151

152+
def ban(self, *, banned_by: User):
153+
"""Globally bans this user from the server; can only be applied by a global moderator or
154+
global admin, and cannot be applied to another global moderator or admin (to prevent
155+
accidental mod/admin banning; to ban them, first explicitly remove them as moderator/admin
156+
and then ban)."""
157+
158+
if not banned_by.global_moderator:
159+
app.logger.warning(f"Cannot ban {self}: {banned_by} is not a global mod/admin")
160+
raise BadPermission()
161+
162+
if self.global_moderator:
163+
app.logger.warning(f"Cannot ban {self}: user is a global moderator/admin")
164+
raise BadPermission()
165+
166+
query("UPDATE users SET banned = TRUE WHERE id = :u", u=self.id)
167+
app.logger.debug(f"{banned_by} globally banned {self}")
168+
self.banned = True
169+
170+
def unban(self, *, unbanned_by: User):
171+
"""Undoes a global ban. `unbanned_by` must be a global mod/admin."""
172+
if not unbanned_by.global_moderator:
173+
app.logger.warning(f"Cannot unban {self}: {unbanned_by} is not a global mod/admin")
174+
raise BadPermission()
175+
176+
query("UPDATE users SET banned = FALSE WHERE id = :u", u=self.id)
177+
app.logger.debug(f"{unbanned_by} removed global ban on {self}")
178+
self.banned = False
179+
156180
@property
157181
def system_user(self):
158182
"""True iff this is the special SOGS system user created for internal database tasks"""

sogs/routes/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .. import config, crypto, http, utils
44
from ..model.room import get_readable_rooms
55

6-
from . import converters, general, legacy, onion_request # noqa: F401
6+
from . import auth, converters, general, legacy, onion_request # noqa: F401
77

88
from io import BytesIO
99

0 commit comments

Comments
 (0)