Skip to content

Commit d3464ab

Browse files
authored
Merge pull request #50 from majestrate/whisper-endpoints-2022-01-28
- user inbox endpoints - blinding
2 parents 943a7e7 + 71f36f7 commit d3464ab

22 files changed

+1071
-295
lines changed

.drone.jsonnet

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ local debian_pipeline(name,
5656
'eatmydata ' + apt_get_quiet + ' dist-upgrade -y',
5757
'eatmydata ' + apt_get_quiet + ' install --no-install-recommends -y ' + std.join(' ', deps),
5858
] + before_pytest + [
59-
'PYTHONPATH=. python3 -mpytest -vv --color=yes --sql-tracing ' + pytest_opts,
59+
'PYTHONPATH=. python3 -mpytest -vv --color=yes ' + pytest_opts,
6060
]
6161
+ extra_cmds,
6262
},
@@ -130,7 +130,7 @@ local debian_pg_pipeline(name, image, pg_tag='bullseye') = debian_pipeline(
130130
name: '🐍 pytest',
131131
commands: [
132132
'echo "Running on ${DRONE_STAGE_MACHINE}"',
133-
'PYTHONPATH=. /opt/local/bin/python3 -mpytest -vv --color=yes --sql-tracing',
133+
'PYTHONPATH=. /opt/local/bin/python3 -mpytest -vv --color=yes',
134134
],
135135
},
136136
],

api.yaml

Lines changed: 136 additions & 13 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
@@ -1722,37 +1826,56 @@ components:
17221826
type: apiKey
17231827
name: X-SOGS-Pubkey
17241828
in: header
1725-
description: "The Session ID of the requestor"
1829+
description: >
1830+
The Ed25519 public key of the request. For non-blinded requests this is the root session
1831+
Ed25519 pubkey with '00' prefixed; for blinded requests this begins with '15' and follows
1832+
the blinding procedure detailed elsewhere.
17261833
nonce:
17271834
type: apiKey
17281835
name: X-SOGS-Nonce
17291836
in: header
17301837
description: >
1731-
A unique nonce string, in base64, of exactly 16 base64 characters (96 bits). This must be
1732-
unique for every request from this pubkey within the last 24 hours; nonce reuse will result
1733-
in failed requests. It is typically sufficient to generate 96 bits (12 bytes) or random
1734-
data for each request, but clients are free to use other nonce generation mechanisms if
1735-
desired.
1838+
A unique, random nonce string of exactly 16 source bytes encoded in base64 (i.e. 24 base64
1839+
characters, including two trailing padding characters). This must be unique for every
1840+
request from this pubkey within the last 24 hours; nonce reuse will result in failed
1841+
requests.
17361842
timestamp:
17371843
type: apiKey
17381844
name: X-SOGS-Timestamp
17391845
in: header
17401846
description: >
1741-
Unix timestamp integer (expressed as a string) of the time when the request was initiated
1742-
(to help avoid replay attacks). This timestamp must be within ±24 hours of the server's
1743-
time when the request is received.
1847+
Unix timestamp integer (expressed as a string) of the time when the request was initiated to
1848+
help avoid replay attacks. This timestamp must be within ±24 hours of the server's time
1849+
when the request is received.
17441850
signature:
17451851
type: apiKey
17461852
name: X-SOGS-Signature
17471853
in: header
17481854
description: >
1749-
XEd25519 signature of
1855+
Ed25519 signature of
1856+
1857+
1858+
`SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY`
1859+
1860+
1861+
signed using the client's blinded or unblinded pubkey (from the `X-SOGS-Pubkey` header),
1862+
encoded using base64 (with or without padding).
1863+
1864+
1865+
SERVER_PUBKEY and NONCE are 32- and 16-byte values, respectively (i.e. the nonce here is the
1866+
*decoded* value of the X-SOGS-Nonce header).
1867+
1868+
1869+
TIMESTAMP is the timestamp expressed as a decimal string, encoded in ascii bytes.
1870+
1871+
1872+
METHOD is the ascii request method (`GET`, `POST`, etc.)
17501873
17511874
1752-
`METHOD || PATH || NONCE || TIMESTAMP || SERVER_PUBKEY || BODY`
1875+
PATH is in utf-8 encoded bytes.
17531876
17541877
1755-
signed using the client's Session ID pubkey, using base64 encoding (with or without
1756-
padding).
1878+
HBODY is an empty string (i.e. omitted from the signature) if the request has no body, or
1879+
has an empty body. Otherwise it must be a 64-byte BLAKE2b hash of the request body.
17571880
17581881
# vim:sw=2:et:tw=100

contrib/auth-example.py

Lines changed: 115 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,132 @@
11
# Example script for demonstrating X-SOGS-* authentication calculation.
22

3-
from nacl.bindings import crypto_scalarmult
4-
from nacl.public import PrivateKey, PublicKey
5-
from hashlib import blake2b
3+
import nacl.bindings as salt
4+
from nacl.signing import SigningKey
5+
from hashlib import blake2b, sha512
66
from base64 import b64encode
77

88
# import time
99
# import nacl.utils
1010

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
1811

19-
# Server pubkey:
20-
B = PublicKey(bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d'))
12+
def sha512_multipart(*message_parts):
13+
"""Given any number of arguments, returns the SHA512 hash of them concatenated together. This
14+
also does one level of flatting if any of the given parts are a list or tuple."""
15+
hasher = sha512()
16+
for m in message_parts:
17+
if isinstance(m, list) or isinstance(m, tuple):
18+
for mi in m:
19+
hasher.update(mi)
20+
else:
21+
hasher.update(m)
22+
return hasher.digest()
23+
24+
25+
def blinded_ed25519_signature(message_parts, s: SigningKey, ka: bytes, kA: bytes):
26+
"""
27+
Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with
28+
one tweak to the construction: we add kA into the hashed value that yields r so that we have
29+
domain separation for different blinded pubkeys. (This doesn't affect verification at all).
30+
"""
31+
H_rh = sha512(s.encode()).digest()[32:]
32+
r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts))
33+
sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r)
34+
HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts))
35+
sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka))
36+
return sig_R + sig_s
37+
38+
39+
def get_signing_headers(
40+
s: SigningKey,
41+
server_pk: bytes,
42+
nonce: bytes,
43+
method: str,
44+
path: str,
45+
timestamp: int,
46+
body,
47+
blinded: bool = True,
48+
):
49+
50+
assert len(server_pk) == 32
51+
assert len(nonce) == 16
52+
53+
if blinded:
54+
# 64-byte blake2b hash then reduce to get the blinding factor:
55+
k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
56+
57+
# Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to
58+
# convert to an *x* secret key, which seems wrong--but isn't because converted keys use the
59+
# same secret scalar secret. (And so this is just the most convenient way to get 'a' out of
60+
# a sodium Ed25519 secret key).
61+
a = s.to_curve25519_private_key().encode()
62+
63+
# Our blinded keypair:
64+
ka = salt.crypto_core_ed25519_scalar_mul(k, a)
65+
kA = salt.crypto_scalarmult_ed25519_base_noclamp(ka)
66+
67+
# Blinded session id:
68+
pubkey = '15' + kA.hex()
69+
70+
else:
71+
# For unblinded auth we send our *ed25519* master pubkey in the X-SOGS-Pubkey header with a
72+
# '00' prefix to disambiguate it; the SOGS server will convert it to X25519 to derive our
73+
# X25519 session id.
74+
pubkey = '00' + s.verify_key.encode().hex()
2175

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
76+
# We need to sign:
77+
# SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY
78+
# with our blinded key (if blinding) or our Ed25519 master key (if not blinding).
79+
to_sign = [server_pk, nonce, str(ts).encode(), method.encode(), path.encode()]
2680

27-
# 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d:
28-
session_id = '05' + A.encode().hex()
81+
# HBODY may be omitted if the body is empty (e.g. for GET requests), and otherwise will be the
82+
# 64-byte BLAKE2b hash of the request body:
83+
if body is not None:
84+
to_sign.append(blake2b(body, digest_size=64).digest())
2985

30-
# We should do something like this here:
86+
if blinded:
87+
sig = blinded_ed25519_signature(to_sign, s, ka, kA)
88+
else:
89+
sig = s.sign(b''.join(to_sign)).signature
90+
91+
return {
92+
'X-SOGS-Pubkey': pubkey,
93+
'X-SOGS-Timestamp': str(ts),
94+
'X-SOGS-Nonce': b64encode(nonce).decode(),
95+
'X-SOGS-Signature': b64encode(sig).decode(),
96+
}
97+
98+
99+
# Session "master" ed25519 key:
100+
s = SigningKey(bytes.fromhex('c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9'))
101+
# Server pubkey:
102+
B = bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d')
103+
# Random 16-byte nonce
31104
# 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():
105+
nonce = bytes.fromhex('09d0799f2295990182c3ab3406fbfc5b') # fixed for reproducible example
106+
# ts = int(time.time())
107+
ts = 1642472103 # for reproducible example
108+
method, path = 'GET', '/room/the-best-room/messages/recent?limit=25'
109+
110+
print("Unblinded headers:")
111+
sig_headers = get_signing_headers(s, B, nonce, method, path, ts, body=None, blinded=False)
112+
for h, v in sig_headers.items():
64113
print(f"{h}: {v}")
65114

115+
print("\nBlinded headers:")
116+
sig_headers = get_signing_headers(s, B, nonce, method, path, ts, body=None, blinded=True)
117+
for h, v in sig_headers.items():
118+
print(f"{h}: {v}")
119+
120+
66121
# Prints:
67-
# X-SOGS-Pubkey: 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d
122+
# Unblinded headers:
123+
# X-SOGS-Pubkey: 00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc
124+
# X-SOGS-Timestamp: 1642472103
125+
# X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww==
126+
# X-SOGS-Signature: xxLpXHbomAJMB9AtGMyqvBsXrdd2040y+Ol/IKzElWfKJa3EYZRv1GLO6CTLhrDFUwVQe8PPltyGs54Kd7O5Cg== # noqa E501
127+
#
128+
# Blinded headers:
129+
# X-SOGS-Pubkey: 1598932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5
68130
# X-SOGS-Timestamp: 1642472103
69131
# X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww==
70-
# X-SOGS-Hash: 0wToLPfUpUSGHGT8n9VIJev5SJ97hUvQTRqBowpnWTqfGb+ldTRa9mU1
132+
# X-SOGS-Signature: n4HK33v7gkcz/3pZuWvzmOlY+AbzbpEN1K12dtCc8Gw0m4iP5gUddGKKLEbmoWNhqJeY2S81Lm9uK2DBBN8aCg== # noqa E501

0 commit comments

Comments
 (0)