Skip to content

Commit e3375dd

Browse files
author
Jeff
committed
user inbox endpoint
1 parent 943a7e7 commit e3375dd

File tree

16 files changed

+477
-14
lines changed

16 files changed

+477
-14
lines changed

api.yaml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,74 @@ paths:
755755
$ref: "#/paths/~1room~1%7BroomToken%7D~1file~1%7BfileId%7D/get/responses/403"
756756
404:
757757
$ref: "#/paths/~1room~1%7BroomToken%7D~1file~1%7BfileId%7D/get/responses/404"
758+
/inbox:
759+
get:
760+
tags: [Users]
761+
summary: get all of the current user's inbox messages
762+
parameters:
763+
- $ref: "#/components/parameters/queryMessagesLimit"
764+
responses:
765+
200:
766+
description: successful operation, returns all of the user's direct messages. Returns an empty array if there are none.
767+
content:
768+
application/json:
769+
schema:
770+
type: array
771+
items:
772+
$ref: "#/components/schemas/DirectMessage"
773+
774+
/inbox/since/{messageId}:
775+
get:
776+
tags: [Users]
777+
summary: Poll the current user's inbox for messages sent since the given message id.
778+
parameters:
779+
- $ref: "#/components/parameters/pathMessageId"
780+
- $ref: "#/components/parameters/queryMessagesLimit"
781+
responses:
782+
200:
783+
description: One or more new messages found.
784+
content:
785+
application/json:
786+
schema:
787+
type: array
788+
items:
789+
$ref: "#/components/schemas/DirectMessage"
790+
304:
791+
description: No direct messages received since the given message id.
792+
793+
/inbox/{sessionId}:
794+
post:
795+
tags: [Users]
796+
summary: Submit a direct message to another user
797+
parameters:
798+
- $ref: "#/components/parameters/pathSessionId"
799+
requestBody:
800+
required: true
801+
content:
802+
application/json:
803+
schema:
804+
type: object
805+
required: [message, signature]
806+
properties:
807+
message:
808+
type: string
809+
format: byte
810+
description: "Base64-encoded message data."
811+
signature:
812+
type: string
813+
format: byte
814+
description: >
815+
Base64-encoded message data XEd25519 signature, signed by the poster's X25519
816+
key contained in the session ID.
817+
responses:
818+
201:
819+
description: Message was accepted for the given user
820+
400:
821+
description: Invalid request (i.e. missing or malformed message or signature parameters).
822+
404:
823+
description: The given session id is not a user known to this sogs (or has been globally banned from the server).
824+
406:
825+
description: Message signature verification failed.
758826

759827
/user/{sessionId}/ban:
760828
post:
@@ -1675,6 +1743,42 @@ components:
16751743
An XEd25519 signature of the data contained in `data`, signed using the X25519 pubkey
16761744
contained in the user's Session ID. This field is omitted when `data` is omitted (i.e.
16771745
for deleted messages.)
1746+
1747+
DirectMessage:
1748+
title: The content of a direct message sent through this server
1749+
type: object
1750+
properties:
1751+
id:
1752+
type: integer
1753+
format: int64
1754+
description: The numeric message id.
1755+
data:
1756+
type: string
1757+
format: byte
1758+
description: >
1759+
The direct message data, encoded in base64.
1760+
signature:
1761+
type: string
1762+
format: byte
1763+
description: >
1764+
An XEd25519 signature of the data contained in `data`, signed using the X25519 pubkey
1765+
contained in the user's Session ID.
1766+
expires_at:
1767+
type: number
1768+
format: double
1769+
description: >
1770+
Unix timestamp of when the message is scheduled to expire from the server.
1771+
sender:
1772+
allOf:
1773+
- $ref: "#/components/schemas/SessionID"
1774+
- type: object
1775+
description: "The session ID of the user who sent this message."
1776+
recipient:
1777+
allOf:
1778+
- $ref: "#/components/schemas/SessionID"
1779+
- type: object
1780+
description: "The session ID to which this message was sent."
1781+
16781782
parameters:
16791783
pathRoomToken:
16801784
name: roomToken

sogs/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
OMQ_LISTEN = 'tcp://*:22028'
1919
OMQ_INTERNAL = 'ipc://./omq.sock'
2020
LOG_LEVEL = 'WARNING'
21+
DM_EXPIRY_DAYS = 15
2122
UPLOAD_DEFAULT_EXPIRY_DAYS = 15
2223
UPLOAD_FILENAME_MAX = 60
2324
UPLOAD_FILENAME_KEEP_PREFIX = 40
@@ -31,6 +32,7 @@
3132
PROFANITY_FILTER = False
3233
PROFANITY_SILENT = True
3334
PROFANITY_CUSTOM = None
35+
REQUIRE_BLIND_KEYS = False
3436
TEMPLATE_PATH = 'templates'
3537
STATIC_PATH = 'static'
3638

@@ -94,6 +96,8 @@ def load_config():
9496
'active_threshold': ('ROOM_DEFAULT_ACTIVE_THRESHOLD', None, float),
9597
'active_prune_threshold': ('ROOM_ACTIVE_PRUNE_THRESHOLD', None, float),
9698
},
99+
'direct_messages': {'expiry': ('DM_EXPIRY_DAYS', None, float)},
100+
'users': {'require_blind_keys': bool_opt('REQUIRE_BLIND_KEYS')},
97101
'messages': {
98102
'history_prune_threshold': ('MESSAGE_HISTORY_PRUNE_THRESHOLD', None, float),
99103
'profanity_filter': bool_opt('PROFANITY_FILTER'),

sogs/crypto.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,19 @@
55
from nacl.public import PrivateKey
66
from nacl.signing import SigningKey, VerifyKey
77
from nacl.encoding import Base64Encoder, HexEncoder
8+
from nacl.bindings import crypto_scalarmult
9+
810

911
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
1012
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
1113

14+
from .utils import decode_hex_or_b64
15+
from .hashing import blake2b
16+
17+
import binascii
1218
import secrets
1319
import hmac
20+
import functools
1421

1522
import pyonionreq
1623

@@ -25,6 +32,8 @@
2532
server_pubkey = _privkey.public_key
2633

2734
server_pubkey_bytes = server_pubkey.encode()
35+
server_pubkey_hash_bytes = blake2b(server_pubkey_bytes)
36+
2837
server_pubkey_hex = server_pubkey.encode(HexEncoder).decode('ascii')
2938
server_pubkey_base64 = server_pubkey.encode(Base64Encoder).decode('ascii')
3039

@@ -49,3 +58,21 @@ def server_encrypt(pk, data):
4958
sk = X25519PrivateKey.from_private_bytes(_privkey.encode())
5059
secret = hmac.digest(b'LOKI', sk.exchange(pk), 'SHA256')
5160
return nonce + AESGCM(secret).encrypt(nonce, data, None)
61+
62+
63+
xed25519_sign = pyonionreq.xed25519.sign
64+
xed25519_verify = pyonionreq.xed25519.verify
65+
xed25519_pubkey = pyonionreq.xed25519.pubkey
66+
67+
68+
@functools.lru_cache(maxsize=1024)
69+
def compute_derived_key_bytes(pk_bytes):
70+
""" compute derived key as bytes with no prefix """
71+
return crypto_scalarmult(server_pubkey_hash_bytes, pk_bytes)
72+
73+
74+
def compute_derived_id(session_id, prefix='15'):
75+
""" compute derived session """
76+
return prefix + binascii.hexlify(
77+
compute_derived_key_bytes(decode_hex_or_b64(session_id[2:], 32))
78+
).decode('ascii')

sogs/db.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def transaction(dbconn=None):
6767
have_returning = True
6868

6969

70-
def insert_and_get_pk(insert, pk, *, dbconn=None, **params):
70+
def insert_and_get_pk(insert, _pk, *, dbconn=None, **params):
7171
"""
7272
Performs an insert and returns the value of the primary key by appending a RETURNING clause, if
7373
supported, and otherwise falling back to using .lastrowid.
@@ -79,14 +79,35 @@ def insert_and_get_pk(insert, pk, *, dbconn=None, **params):
7979
"""
8080

8181
if have_returning:
82-
insert += f" RETURNING {pk}"
82+
insert += f" RETURNING {_pk}"
8383

8484
result = query(insert, dbconn=dbconn, **params)
8585
if have_returning:
8686
return result.first()[0]
8787
return result.lastrowid
8888

8989

90+
def insert_and_get_row(insert, _table, _pk, *, dbconn=None, **params):
91+
"""
92+
Performs an insert and returned the row by appending a `RETURNING *` clause, if supported, and
93+
otherwise fetching the row immediately after the insertion.
94+
95+
Takes the query, table name, and primary key column name (the latter two are needed for the
96+
SELECT query when the db doesn't support RETURNING), and any parameters to bind.
97+
98+
Can optionally take the database connection by passing as a dbconn parameter (note that you may
99+
not use "dbconn" as a bind parameter). If omitted uses web.appdb.
100+
"""
101+
102+
if have_returning:
103+
insert += " RETURNING *"
104+
return query(insert, dbconn=dbconn, **params).first()
105+
106+
with transaction(dbconn):
107+
pkval = insert_and_get_pk(insert, _pk, dbconn=dbconn, **params)
108+
return query(f"SELECT * FROM {_table} WHERE {_pk} = :pk", pk=pkval).first()
109+
110+
90111
def database_init():
91112
"""
92113
Perform database initialization: constructs the schema, if necessary, and performs any required
@@ -222,7 +243,35 @@ def add_new_tables(conn):
222243
conn.execute("CREATE INDEX user_request_nonces_expiry ON user_request_nonces(expiry)")
223244

224245
added = True
225-
246+
if 'inbox' not in metadata.tables:
247+
if engine.name == 'sqlite':
248+
conn.execute(
249+
"""
250+
CREATE TABLE inbox (
251+
id INTEGER PRIMARY KEY,
252+
recipient INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
253+
sender INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
254+
body BLOB NOT NULL,
255+
posted_at FLOAT DEFAULT ((julianday('now') - 2440587.5)*86400.0),
256+
expiry FLOAT DEFAULT ((julianday('now') - 2440587.5 + 1.0)*86400.0) /* now + 24h */)
257+
"""
258+
)
259+
conn.execute("CREATE INDEX inbox_recipient ON inbox(recipient)")
260+
else:
261+
conn.execute(
262+
"""
263+
CREATE TABLE inbox (
264+
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
265+
recipient BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
266+
sender BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
267+
body BYTEA NOT NULL,
268+
posted_at FLOAT DEFAULT (extract(epoch from now())),
269+
expiry FLOAT DEFAULT (extract(epoch from now() + '15 days'))
270+
)
271+
"""
272+
)
273+
conn.execute("CREATE INDEX inbox_recipient ON inbox(recipient)")
274+
added = True
226275
return added
227276

228277

sogs/http.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
OK = 200
33
CREATED = 201
44

5+
# 3xx codes:
6+
NOT_MODIFIED = 304
7+
58
# error status codes:
69
BAD_REQUEST = 400
710
UNAUTHORIZED = 401

sogs/model/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@
1616
'sogs', # Basic sogs capabilities
1717
# 'newcap', # Add here
1818
}
19+
20+
if config.REQUIRE_BLIND_KEYS:
21+
# indicate blinding required if configured to do so
22+
capabilities.add('blind')

sogs/model/message.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from .. import config
2+
from ..db import insert_and_get_row, query
3+
4+
from .user import User
5+
6+
import time
7+
8+
9+
class Message:
10+
"""Class representing a DM between users
11+
12+
Properties:
13+
sender: sender user of the message
14+
recip: recipant user of the message
15+
data: opaque message data
16+
signature: signature of data
17+
"""
18+
19+
def __init__(self, row=None, *, sender=None, recip=None, data=None):
20+
"""
21+
Constructs a Message from a pre-retrieved row *or* sender recipient and data.
22+
"""
23+
if row is None:
24+
if None in (sender, recip, data):
25+
raise ValueError("Message() error: no row or data provided")
26+
if not all(isinstance(arg, User) for arg in (sender, recip)):
27+
raise ValueError("Message() error: sender or recipient was not a User model")
28+
29+
row = insert_and_get_row(
30+
"""
31+
INSERT INTO inbox (sender, recipient, body, expiry)
32+
VALUES (:sender, :recipient, :data, :expiry)
33+
""",
34+
"inbox",
35+
"id",
36+
sender=sender.id,
37+
recipient=recip.id,
38+
data=data,
39+
expiry=time.time() + config.DM_EXPIRY_DAYS * 86400,
40+
)
41+
# sanity check
42+
assert row is not None
43+
self._row = row
44+
45+
@staticmethod
46+
def to(user, since=None, limit=None):
47+
"""get all message for a user, returns a generator"""
48+
rows = query(
49+
f"""
50+
SELECT * FROM inbox WHERE recipient = :recip
51+
{'AND id > :since_id' if since else ''}
52+
ORDER BY id
53+
{'LIMIT :limit' if limit else ''}
54+
""",
55+
recip=user.id,
56+
since_id=since,
57+
limit=limit,
58+
)
59+
for row in rows:
60+
yield Message(row=row)
61+
62+
@property
63+
def id(self):
64+
return self._row["id"]
65+
66+
@property
67+
def posted_at(self):
68+
return self._row["posted_at"]
69+
70+
@property
71+
def expires_at(self):
72+
return self._row["expiry"]
73+
74+
@property
75+
def data(self):
76+
return self._row['body']
77+
78+
@property
79+
def sender(self):
80+
if not hasattr(self, "_sender"):
81+
self._sender = User(id=self._row['sender'], autovivify=False)
82+
return self._sender
83+
84+
@property
85+
def recipient(self):
86+
if not hasattr(self, "_recip"):
87+
self._recip = User(id=self._row['recipient'], autovivify=False)
88+
return self._recip

0 commit comments

Comments
 (0)