voltkit-rtc-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voltkit_rtc/server/__init__.py +114 -0
- voltkit_rtc/server/_redaction.py +333 -0
- voltkit_rtc/server/_version.py +3 -0
- voltkit_rtc/server/client.py +846 -0
- voltkit_rtc/server/errors.py +278 -0
- voltkit_rtc/server/py.typed +0 -0
- voltkit_rtc/server/transport.py +208 -0
- voltkit_rtc/server/types.py +448 -0
- voltkit_rtc/server/webhooks.py +184 -0
- voltkit_rtc_server-0.1.0.dist-info/METADATA +877 -0
- voltkit_rtc_server-0.1.0.dist-info/RECORD +13 -0
- voltkit_rtc_server-0.1.0.dist-info/WHEEL +4 -0
- voltkit_rtc_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The shapes of what the calls return, as the wire sends them: plain dicts and
|
|
3
|
+
lists with camelCase keys. These TypedDicts are for editors and type checkers;
|
|
4
|
+
nothing is converted, and a field a newer controller adds comes through as is.
|
|
5
|
+
|
|
6
|
+
Money is credit units (cu): 1 credit = 6,000,000 cu. Every cu amount is a
|
|
7
|
+
decimal string, never a number: a JSON number is a float, and a float rounds a
|
|
8
|
+
large balance without telling you. Do arithmetic with int(amount).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Literal, Optional, TypedDict
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Allowance",
|
|
17
|
+
"BroadcastStream",
|
|
18
|
+
"BroadcastStreamWithPublish",
|
|
19
|
+
"BurnView",
|
|
20
|
+
"ChatInfo",
|
|
21
|
+
"ChatMessage",
|
|
22
|
+
"ChatToken",
|
|
23
|
+
"DailyUsage",
|
|
24
|
+
"LegacyFunds",
|
|
25
|
+
"Participant",
|
|
26
|
+
"PlaybackPolicy",
|
|
27
|
+
"PlaybackUrls",
|
|
28
|
+
"ProjectBalance",
|
|
29
|
+
"PublishGrant",
|
|
30
|
+
"Role",
|
|
31
|
+
"Room",
|
|
32
|
+
"RoomList",
|
|
33
|
+
"RoomToken",
|
|
34
|
+
"RoomView",
|
|
35
|
+
"RoomWithHostToken",
|
|
36
|
+
"RtcConversion",
|
|
37
|
+
"RtcMode",
|
|
38
|
+
"RtcRates",
|
|
39
|
+
"RtcSurcharge",
|
|
40
|
+
"RtcTierRate",
|
|
41
|
+
"RtcUnit",
|
|
42
|
+
"StreamAction",
|
|
43
|
+
"StreamEvent",
|
|
44
|
+
"StreamToken",
|
|
45
|
+
"Usage",
|
|
46
|
+
"UsageSummary",
|
|
47
|
+
"WalletFunds",
|
|
48
|
+
"WalletLowState",
|
|
49
|
+
"WalletScopeAmount",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
Role = Literal["host", "cohost", "moderator", "viewer"]
|
|
53
|
+
PlaybackPolicy = Literal["public", "token"]
|
|
54
|
+
StreamAction = Literal["play", "publish"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- rooms --------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _RoomRequired(TypedDict):
|
|
61
|
+
roomId: str
|
|
62
|
+
tenantId: str
|
|
63
|
+
originNode: str
|
|
64
|
+
state: str
|
|
65
|
+
hostIdentity: str
|
|
66
|
+
#: Unix milliseconds.
|
|
67
|
+
createdAt: int
|
|
68
|
+
nodes: list[str]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Room(_RoomRequired, total=False):
|
|
72
|
+
participants: int
|
|
73
|
+
demo: bool
|
|
74
|
+
metadata: Any
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _RoomWithHostTokenRequired(_RoomRequired):
|
|
78
|
+
hostToken: str
|
|
79
|
+
wsUrl: str
|
|
80
|
+
#: Unix seconds.
|
|
81
|
+
expiresAt: int
|
|
82
|
+
#: True when the room already existed and was returned rather than created.
|
|
83
|
+
existed: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RoomWithHostToken(_RoomWithHostTokenRequired, total=False):
|
|
87
|
+
participants: int
|
|
88
|
+
demo: bool
|
|
89
|
+
metadata: Any
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RoomToken(TypedDict):
|
|
93
|
+
token: str
|
|
94
|
+
wsUrl: str
|
|
95
|
+
role: Role
|
|
96
|
+
identity: str
|
|
97
|
+
roomId: str
|
|
98
|
+
#: Unix seconds.
|
|
99
|
+
expiresAt: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _ParticipantRequired(TypedDict):
|
|
103
|
+
identity: str
|
|
104
|
+
role: Role
|
|
105
|
+
nodeId: str
|
|
106
|
+
handRaised: bool
|
|
107
|
+
muted: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Participant(_ParticipantRequired, total=False):
|
|
111
|
+
handMetadata: Any
|
|
112
|
+
kicked: bool
|
|
113
|
+
#: A moderator has switched this participant's camera off.
|
|
114
|
+
videoBlocked: bool
|
|
115
|
+
#: When they joined, in unix milliseconds.
|
|
116
|
+
joinedAt: int
|
|
117
|
+
metadata: Any
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RoomView(TypedDict, total=False):
|
|
121
|
+
room: Room
|
|
122
|
+
participants: list[Participant]
|
|
123
|
+
producers: list[dict[str, Any]]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RoomList(TypedDict):
|
|
127
|
+
rooms: list[Room]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- chat ---------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ChatToken(TypedDict):
|
|
134
|
+
token: str
|
|
135
|
+
#: Where a chat-only client connects. Ends in /ws/chat.
|
|
136
|
+
wsUrl: str
|
|
137
|
+
role: Role
|
|
138
|
+
identity: str
|
|
139
|
+
channel: str
|
|
140
|
+
expiresAt: int
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ChatInfo(TypedDict):
|
|
144
|
+
channel: str
|
|
145
|
+
members: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _ChatMessageRequired(TypedDict):
|
|
149
|
+
#: Monotonic within a channel: the oldest id you hold is a paging cursor.
|
|
150
|
+
id: str
|
|
151
|
+
channel: str
|
|
152
|
+
#: Who it is from; "system" for a backend message that named nobody.
|
|
153
|
+
from_: str # noqa: F841 - the wire key is "from"; see ChatMessage below.
|
|
154
|
+
type: str
|
|
155
|
+
#: Unix milliseconds.
|
|
156
|
+
ts: int
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# "from" is a keyword, so the wire key cannot be a class attribute: this is the
|
|
160
|
+
# functional form, with the same fields as above plus the optional ones.
|
|
161
|
+
ChatMessage = TypedDict(
|
|
162
|
+
"ChatMessage",
|
|
163
|
+
{
|
|
164
|
+
"id": str,
|
|
165
|
+
"channel": str,
|
|
166
|
+
"from": str,
|
|
167
|
+
"to": str,
|
|
168
|
+
"type": str,
|
|
169
|
+
"text": str,
|
|
170
|
+
"metadata": Any,
|
|
171
|
+
"clientMsgId": str,
|
|
172
|
+
"ts": int,
|
|
173
|
+
},
|
|
174
|
+
total=False,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- broadcast ----------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PlaybackUrls(TypedDict, total=False):
|
|
182
|
+
llhls: str
|
|
183
|
+
webrtc: str
|
|
184
|
+
token: str
|
|
185
|
+
expiresAt: int
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PublishGrant(TypedDict):
|
|
189
|
+
#: Complete: hand it to the encoder as it is. It carries the token.
|
|
190
|
+
whipUrl: str
|
|
191
|
+
token: str
|
|
192
|
+
expiresAt: int
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _BroadcastStreamRequired(TypedDict):
|
|
196
|
+
streamId: str
|
|
197
|
+
tenantId: str
|
|
198
|
+
#: The name the media server knows the stream by. Random, so a playback
|
|
199
|
+
#: URL cannot be derived from a stream id.
|
|
200
|
+
streamKey: str
|
|
201
|
+
state: Literal["idle", "live"]
|
|
202
|
+
playback: PlaybackPolicy
|
|
203
|
+
createdAt: int
|
|
204
|
+
#: Playback sessions the media server serves itself; an audience behind a CDN is not counted.
|
|
205
|
+
viewers: int
|
|
206
|
+
peakViewers: int
|
|
207
|
+
playbackUrls: PlaybackUrls
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class BroadcastStream(_BroadcastStreamRequired, total=False):
|
|
211
|
+
startedAt: int
|
|
212
|
+
endedAt: int
|
|
213
|
+
metadata: Any
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _BroadcastStreamWithPublishRequired(_BroadcastStreamRequired):
|
|
217
|
+
#: Only ever returned by create_stream.
|
|
218
|
+
publish: PublishGrant
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BroadcastStreamWithPublish(_BroadcastStreamWithPublishRequired, total=False):
|
|
222
|
+
startedAt: int
|
|
223
|
+
endedAt: int
|
|
224
|
+
metadata: Any
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _StreamTokenRequired(TypedDict):
|
|
228
|
+
streamId: str
|
|
229
|
+
action: StreamAction
|
|
230
|
+
token: str
|
|
231
|
+
expiresAt: int
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class StreamToken(_StreamTokenRequired, total=False):
|
|
235
|
+
#: Present when action is "publish".
|
|
236
|
+
whipUrl: str
|
|
237
|
+
#: Present when action is "play".
|
|
238
|
+
llhls: str
|
|
239
|
+
webrtc: str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- usage --------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _UsageRequired(TypedDict):
|
|
246
|
+
participantSeconds: int
|
|
247
|
+
publisherSeconds: int
|
|
248
|
+
roomSeconds: int
|
|
249
|
+
roomsCreated: int
|
|
250
|
+
peakParticipants: int
|
|
251
|
+
chatMessages: int
|
|
252
|
+
chatConnectionSeconds: int
|
|
253
|
+
broadcastSeconds: int
|
|
254
|
+
broadcastViewerSeconds: int
|
|
255
|
+
streamsStarted: int
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Usage(_UsageRequired, total=False):
|
|
259
|
+
#: Everything that counts against an allowance: room plus broadcast seconds.
|
|
260
|
+
billableSeconds: int
|
|
261
|
+
#: The part of billableSeconds a free allowance covered.
|
|
262
|
+
freeSeconds: int
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class DailyUsage(Usage, total=False):
|
|
266
|
+
#: UTC day, YYYY-MM-DD.
|
|
267
|
+
day: str
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Allowance(TypedDict, total=False):
|
|
271
|
+
plan: Literal["metered", "unlimited"]
|
|
272
|
+
freeDailyMinutes: int
|
|
273
|
+
freeMonthlyMinutes: int
|
|
274
|
+
dailyCapMinutes: int
|
|
275
|
+
monthlyCapMinutes: int
|
|
276
|
+
usedTodaySeconds: int
|
|
277
|
+
usedMonthSeconds: int
|
|
278
|
+
freeUsedTodaySeconds: int
|
|
279
|
+
freeUsedMonthSeconds: int
|
|
280
|
+
freeRemainingSeconds: int
|
|
281
|
+
paidRemainingSeconds: int
|
|
282
|
+
remainingSeconds: int
|
|
283
|
+
#: Why the account cannot be used right now, if it cannot.
|
|
284
|
+
blocked: str
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class _UsageSummaryRequired(TypedDict):
|
|
288
|
+
tenantId: str
|
|
289
|
+
plan: Literal["metered", "unlimited"]
|
|
290
|
+
status: Literal["active", "suspended"]
|
|
291
|
+
minutesLimit: int
|
|
292
|
+
usedMinutes: int
|
|
293
|
+
#: -1 on the unlimited plan.
|
|
294
|
+
minutesRemaining: int
|
|
295
|
+
today: Usage
|
|
296
|
+
month: Usage
|
|
297
|
+
lifetime: Usage
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class UsageSummary(_UsageSummaryRequired, total=False):
|
|
301
|
+
sharedUsedMinutes: int
|
|
302
|
+
allowance: Allowance
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --- billing ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class LegacyFunds(TypedDict):
|
|
309
|
+
#: A plan whose own minutes never run out; ownSeconds and totalSeconds are then -1.
|
|
310
|
+
unlimited: bool
|
|
311
|
+
freeSeconds: int
|
|
312
|
+
ownSeconds: int
|
|
313
|
+
sharedSeconds: int
|
|
314
|
+
totalSeconds: int
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class WalletScopeAmount(TypedDict):
|
|
318
|
+
paidCu: str
|
|
319
|
+
promoCu: str
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class WalletFunds(TypedDict):
|
|
323
|
+
#: In the order they are spent: project:<tenantId>, product:rtc, unallocated.
|
|
324
|
+
spendOrder: list[str]
|
|
325
|
+
scopes: dict[str, WalletScopeAmount]
|
|
326
|
+
paidCu: str
|
|
327
|
+
promoCu: str
|
|
328
|
+
#: paidCu + promoCu: everything this project may spend from the wallet now.
|
|
329
|
+
availableCu: str
|
|
330
|
+
#: availableCu in credits, at most four decimals, rounded down. For display only.
|
|
331
|
+
availableCredits: str
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class BurnView(TypedDict):
|
|
335
|
+
liveParticipants: int
|
|
336
|
+
projectCuPerSecond: str
|
|
337
|
+
walletCuPerSecond: str
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class WalletLowState(TypedDict):
|
|
341
|
+
active: bool
|
|
342
|
+
graceSeconds: int
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class ProjectBalance(TypedDict):
|
|
346
|
+
tenantId: str
|
|
347
|
+
billingMode: Literal["legacy", "wallet"]
|
|
348
|
+
#: Whether a newcomer would be admitted now.
|
|
349
|
+
canJoin: bool
|
|
350
|
+
#: When canJoin is false, the code a join or a token mint is refused with.
|
|
351
|
+
blocked: Optional[str]
|
|
352
|
+
legacy: LegacyFunds
|
|
353
|
+
#: None unless the project is billed from its wallet.
|
|
354
|
+
wallet: Optional[WalletFunds]
|
|
355
|
+
burn: BurnView
|
|
356
|
+
#: How long the funds last at the current burn, in seconds; -1 when nothing limits it.
|
|
357
|
+
remainingSeconds: int
|
|
358
|
+
walletLow: WalletLowState
|
|
359
|
+
pricingVersion: Optional[int]
|
|
360
|
+
#: Unix milliseconds.
|
|
361
|
+
updatedAt: int
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# --- rates --------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class RtcUnit(TypedDict):
|
|
368
|
+
price: str
|
|
369
|
+
rate: str
|
|
370
|
+
cuPerCredit: str
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class RtcTierRate(TypedDict):
|
|
374
|
+
id: str
|
|
375
|
+
rank: int
|
|
376
|
+
label: str
|
|
377
|
+
media: str
|
|
378
|
+
#: Credits per 1,000 participant-minutes, e.g. "99.00".
|
|
379
|
+
credits: str
|
|
380
|
+
#: The same price as an integer: cu per participant-second, e.g. "9900".
|
|
381
|
+
cuPerParticipantSecond: str
|
|
382
|
+
base: str
|
|
383
|
+
surcharge: Optional[str]
|
|
384
|
+
available: bool
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class RtcSurcharge(TypedDict):
|
|
388
|
+
tier: str
|
|
389
|
+
height: int
|
|
390
|
+
fps: int
|
|
391
|
+
credits: str
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class RtcMode(TypedDict, total=False):
|
|
395
|
+
profileId: str
|
|
396
|
+
label: str
|
|
397
|
+
tier: str
|
|
398
|
+
tierLabel: str
|
|
399
|
+
enabled: bool
|
|
400
|
+
codecs: list[str]
|
|
401
|
+
receiveKbps: dict[str, int]
|
|
402
|
+
kind: Literal["audio", "video"]
|
|
403
|
+
channels: int
|
|
404
|
+
width: int
|
|
405
|
+
height: int
|
|
406
|
+
fps: int
|
|
407
|
+
layers: int
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class RtcConversion(TypedDict):
|
|
411
|
+
creditsPerRupee: str
|
|
412
|
+
numerator: str
|
|
413
|
+
denominator: str
|
|
414
|
+
inrPerCredit: str
|
|
415
|
+
version: Optional[int]
|
|
416
|
+
effectiveAt: Optional[str]
|
|
417
|
+
source: str
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class RtcRates(TypedDict):
|
|
421
|
+
version: int
|
|
422
|
+
kind: Literal["standard", "contract"]
|
|
423
|
+
effectiveAt: Optional[str]
|
|
424
|
+
unit: RtcUnit
|
|
425
|
+
tiers: list[RtcTierRate]
|
|
426
|
+
surcharges: list[RtcSurcharge]
|
|
427
|
+
idleTier: str
|
|
428
|
+
modes: list[RtcMode]
|
|
429
|
+
conversion: RtcConversion
|
|
430
|
+
notes: list[str]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# --- webhooks -----------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class _StreamEventRequired(TypedDict):
|
|
437
|
+
id: str
|
|
438
|
+
type: str
|
|
439
|
+
tenantId: str
|
|
440
|
+
#: Unix milliseconds.
|
|
441
|
+
createdAt: int
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class StreamEvent(_StreamEventRequired, total=False):
|
|
445
|
+
"""One delivery. `data` varies by `type`: https://voltkit.in/docs/webhooks."""
|
|
446
|
+
|
|
447
|
+
roomId: str
|
|
448
|
+
data: dict[str, Any]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Verified webhooks: the part of an integration that fails silently.
|
|
3
|
+
|
|
4
|
+
The controller signs every delivery as ``X-Stream-Signature:
|
|
5
|
+
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">``, with one ``v1``
|
|
6
|
+
per secret during a rotation's overlap. The timestamp is inside the signed
|
|
7
|
+
payload, so a captured request cannot be replayed later against a receiver
|
|
8
|
+
that checks it; signing the body alone would leave that door open.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Optional, Union, cast
|
|
21
|
+
|
|
22
|
+
from .errors import StreamError
|
|
23
|
+
from .types import StreamEvent
|
|
24
|
+
|
|
25
|
+
__all__ = ["DEFAULT_TOLERANCE_SECONDS", "MAX_HEADER_LENGTH", "parse_webhook", "verify_webhook"]
|
|
26
|
+
|
|
27
|
+
#: The raw request body: bytes as received, or a str the framework decoded as UTF-8.
|
|
28
|
+
RawBody = Union[bytes, bytearray, memoryview, str]
|
|
29
|
+
#: The signature header as a framework hands it over: the string, a list of them, or nothing.
|
|
30
|
+
SignatureHeader = Union[str, Sequence[str], None]
|
|
31
|
+
|
|
32
|
+
#: How far out of date a timestamp may be, in seconds, when nothing else is said.
|
|
33
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
34
|
+
#: A header longer than this is refused before it is read.
|
|
35
|
+
MAX_HEADER_LENGTH = 4096
|
|
36
|
+
|
|
37
|
+
_TIMESTAMP = re.compile(r"[0-9]{1,15}")
|
|
38
|
+
_SIGNATURE = re.compile(r"[0-9a-f]{64}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verify_webhook(
|
|
42
|
+
raw_body: RawBody,
|
|
43
|
+
signature_header: SignatureHeader,
|
|
44
|
+
secrets: Union[str, Sequence[str]],
|
|
45
|
+
*,
|
|
46
|
+
tolerance_seconds: Optional[float] = DEFAULT_TOLERANCE_SECONDS,
|
|
47
|
+
now: Optional[float] = None,
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Whether a delivery's signature verifies.
|
|
51
|
+
|
|
52
|
+
``raw_body`` must be the bytes as received (Flask ``request.get_data()``,
|
|
53
|
+
FastAPI ``await request.body()``, Django ``request.body``). A parsed
|
|
54
|
+
object, or JSON that has been through ``json.loads`` and back, changes the
|
|
55
|
+
bytes and no signature could ever match, so that is a TypeError rather
|
|
56
|
+
than a False that looks like an attack. ``secrets`` may be a list: during a
|
|
57
|
+
rotation, give the old and the new secret and a delivery signed with
|
|
58
|
+
either verifies. ``tolerance_seconds`` (default 300) is how far from now,
|
|
59
|
+
in either direction, the timestamp may be; ``now`` replaces the clock, for
|
|
60
|
+
tests.
|
|
61
|
+
"""
|
|
62
|
+
body = _raw_bytes(raw_body)
|
|
63
|
+
keys = _secret_list(secrets)
|
|
64
|
+
tolerance = _check_tolerance(tolerance_seconds)
|
|
65
|
+
|
|
66
|
+
parsed = _parse_header(signature_header)
|
|
67
|
+
if parsed is None:
|
|
68
|
+
return False
|
|
69
|
+
timestamp, signatures = parsed
|
|
70
|
+
|
|
71
|
+
moment = time.time() if now is None else float(now)
|
|
72
|
+
if abs(moment - int(timestamp)) > tolerance:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Every secret against every signature, each compared in constant time.
|
|
76
|
+
# Both lists are short (two at most in practice), and nothing returns
|
|
77
|
+
# early on a match.
|
|
78
|
+
ok = False
|
|
79
|
+
for key in keys:
|
|
80
|
+
expected = hmac.new(key.encode("utf-8"), timestamp.encode("ascii") + b"." + body, hashlib.sha256)
|
|
81
|
+
digest = expected.hexdigest()
|
|
82
|
+
for signature in signatures:
|
|
83
|
+
if hmac.compare_digest(digest, signature):
|
|
84
|
+
ok = True
|
|
85
|
+
return ok
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_webhook(
|
|
89
|
+
raw_body: RawBody,
|
|
90
|
+
signature_header: SignatureHeader,
|
|
91
|
+
secrets: Union[str, Sequence[str]],
|
|
92
|
+
*,
|
|
93
|
+
tolerance_seconds: Optional[float] = DEFAULT_TOLERANCE_SECONDS,
|
|
94
|
+
now: Optional[float] = None,
|
|
95
|
+
) -> StreamEvent:
|
|
96
|
+
"""
|
|
97
|
+
Verify and decode in one step. Raises ``StreamError("bad-signature")``
|
|
98
|
+
(status 401) rather than returning None, so a handler cannot forget to
|
|
99
|
+
check. The event is ``{"id", "type", "tenantId", "roomId"?, "createdAt",
|
|
100
|
+
"data"?}``; ``data`` varies by ``type``.
|
|
101
|
+
"""
|
|
102
|
+
if not verify_webhook(raw_body, signature_header, secrets, tolerance_seconds=tolerance_seconds, now=now):
|
|
103
|
+
raise StreamError("bad-signature", 401, "webhook signature did not verify", False)
|
|
104
|
+
return cast(StreamEvent, json.loads(_raw_bytes(raw_body).decode("utf-8")))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _raw_bytes(raw_body: object) -> bytes:
|
|
108
|
+
if isinstance(raw_body, str):
|
|
109
|
+
return raw_body.encode("utf-8")
|
|
110
|
+
if isinstance(raw_body, (bytes, bytearray, memoryview)):
|
|
111
|
+
return bytes(raw_body)
|
|
112
|
+
raise TypeError(
|
|
113
|
+
"verify_webhook needs the RAW request body (bytes or str), not a parsed object. "
|
|
114
|
+
"Flask: request.get_data(); FastAPI/Starlette: await request.body(); Django: request.body."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _secret_list(secrets: object) -> list[str]:
|
|
119
|
+
given: list[object]
|
|
120
|
+
if isinstance(secrets, str):
|
|
121
|
+
given = [secrets]
|
|
122
|
+
elif isinstance(secrets, (list, tuple)):
|
|
123
|
+
given = list(secrets)
|
|
124
|
+
else:
|
|
125
|
+
given = [secrets]
|
|
126
|
+
kept = [secret for secret in given if isinstance(secret, str) and secret]
|
|
127
|
+
if not kept:
|
|
128
|
+
raise TypeError("verify_webhook needs the signing secret")
|
|
129
|
+
return kept
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_tolerance(tolerance: object) -> float:
|
|
133
|
+
"""
|
|
134
|
+
``tolerance_seconds`` as a number, or an error now. A NaN switched the age
|
|
135
|
+
check off once, since every comparison with NaN is false, so a captured
|
|
136
|
+
delivery verified forever; a negative refused every delivery.
|
|
137
|
+
"""
|
|
138
|
+
if tolerance is None:
|
|
139
|
+
return float(DEFAULT_TOLERANCE_SECONDS)
|
|
140
|
+
if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)):
|
|
141
|
+
raise TypeError("tolerance_seconds must be a number of 0 or more, got %r" % (tolerance,))
|
|
142
|
+
value = float(tolerance)
|
|
143
|
+
if math.isnan(value) or value < 0:
|
|
144
|
+
raise ValueError("tolerance_seconds must be a number of 0 or more, got %r" % (tolerance,))
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_header(header: object) -> Optional[tuple[str, list[str]]]:
|
|
149
|
+
"""
|
|
150
|
+
Split ``t=<ts>,v1=<hex>[,v1=<hex>...]`` into its timestamp and signatures.
|
|
151
|
+
|
|
152
|
+
Schemes other than ``v1`` are skipped, so a later scheme can be added
|
|
153
|
+
beside it without breaking this. Anything malformed, or with more than one
|
|
154
|
+
timestamp, is refused.
|
|
155
|
+
"""
|
|
156
|
+
if header is None:
|
|
157
|
+
value = ""
|
|
158
|
+
elif isinstance(header, str):
|
|
159
|
+
value = header
|
|
160
|
+
elif isinstance(header, (list, tuple)) and all(isinstance(part, str) for part in header):
|
|
161
|
+
value = ",".join(header)
|
|
162
|
+
else:
|
|
163
|
+
return None
|
|
164
|
+
if len(value) > MAX_HEADER_LENGTH:
|
|
165
|
+
return None
|
|
166
|
+
timestamp: Optional[str] = None
|
|
167
|
+
signatures: list[str] = []
|
|
168
|
+
for part in value.split(","):
|
|
169
|
+
item = part.strip()
|
|
170
|
+
eq = item.find("=")
|
|
171
|
+
if eq <= 0:
|
|
172
|
+
return None
|
|
173
|
+
key, val = item[:eq], item[eq + 1 :]
|
|
174
|
+
if key == "t":
|
|
175
|
+
if timestamp is not None or _TIMESTAMP.fullmatch(val) is None:
|
|
176
|
+
return None
|
|
177
|
+
timestamp = val
|
|
178
|
+
elif key == "v1":
|
|
179
|
+
if _SIGNATURE.fullmatch(val) is None:
|
|
180
|
+
return None
|
|
181
|
+
signatures.append(val)
|
|
182
|
+
if timestamp is None or not signatures:
|
|
183
|
+
return None
|
|
184
|
+
return timestamp, signatures
|