pycrossauth 0.0.2__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.
- crossauth_backend/__init__.py +59 -0
- crossauth_backend/auth.py +132 -0
- crossauth_backend/common/__init__.py +2 -0
- crossauth_backend/common/error.py +498 -0
- crossauth_backend/common/interfaces.py +287 -0
- crossauth_backend/common/jwt.py +41 -0
- crossauth_backend/common/logger.py +202 -0
- crossauth_backend/cookieauth.py +575 -0
- crossauth_backend/crypto.py +457 -0
- crossauth_backend/oauth/__init__.py +2 -0
- crossauth_backend/oauth/client.py +1063 -0
- crossauth_backend/oauth/clientmanager.py +157 -0
- crossauth_backend/oauth/resserver.py +34 -0
- crossauth_backend/oauth/tokenconsumer.py +412 -0
- crossauth_backend/oauth/wellknown.py +108 -0
- crossauth_backend/session.py +450 -0
- crossauth_backend/storage.py +626 -0
- crossauth_backend/storageimpl/__init__.py +2 -0
- crossauth_backend/storageimpl/inmemorystorage.py +132 -0
- crossauth_backend/storageimpl/sqlalchemystorage.py +294 -0
- crossauth_backend/utils.py +150 -0
- crossauth_fastapi/__init__.py +23 -0
- crossauth_fastapi/fastapioauthclient.py +2185 -0
- crossauth_fastapi/fastapiresserver.py +300 -0
- crossauth_fastapi/fastapiserver.py +437 -0
- crossauth_fastapi/fastapiserverbase.py +62 -0
- crossauth_fastapi/fastapisession.py +654 -0
- crossauth_fastapi/fastapisessionadapter.py +94 -0
- pycrossauth-0.0.2.dist-info/LICENSE +203 -0
- pycrossauth-0.0.2.dist-info/METADATA +257 -0
- pycrossauth-0.0.2.dist-info/NOTICE +5 -0
- pycrossauth-0.0.2.dist-info/RECORD +34 -0
- pycrossauth-0.0.2.dist-info/WHEEL +5 -0
- pycrossauth-0.0.2.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
|
|
2
|
+
import os
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import secrets
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
from Crypto.Cipher import AES
|
|
11
|
+
from Crypto.Util.Padding import pad, unpad
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
from crossauth_backend.common.error import ErrorCode, CrossauthError
|
|
15
|
+
from crossauth_backend.utils import MapGetter
|
|
16
|
+
|
|
17
|
+
PBKDF2_DIGEST = os.getenv("PBKDF2_DIGEST", "sha256")
|
|
18
|
+
PBKDF2_ITERATIONS = int(os.getenv("PBKDF2_ITERATIONS", 600_000))
|
|
19
|
+
PBKDF2_KEYLENGTH = int(os.getenv("PBKDF2_KEYLENGTH", 32)) # in bytes, before base64
|
|
20
|
+
PBKDF2_SALTLENGTH = int(os.getenv("PBKDF2_SALTLENGTH", 16)) # in bytes, before base64
|
|
21
|
+
|
|
22
|
+
SIGN_DIGEST = "sha256"
|
|
23
|
+
|
|
24
|
+
class PasswordHash(TypedDict):
|
|
25
|
+
"""
|
|
26
|
+
An object that contains all components of a hashed password. Hashing is done with PBKDF2
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
hashed_password : str
|
|
30
|
+
""" The actual hashed password in Base64 format """
|
|
31
|
+
|
|
32
|
+
salt : str
|
|
33
|
+
""" The random salt used to create the hashed password """
|
|
34
|
+
|
|
35
|
+
iterations : int
|
|
36
|
+
""" Number of iterations for PBKDF2 """
|
|
37
|
+
|
|
38
|
+
use_secret : bool
|
|
39
|
+
""" If true, secret (application secret) is also used to hash the password"""
|
|
40
|
+
|
|
41
|
+
key_len : int
|
|
42
|
+
""" The key length parameter passed to PBKDF2 - hash will be this number of characters long """
|
|
43
|
+
|
|
44
|
+
digest : str
|
|
45
|
+
""" The digest algorithm to use, eg `sha512` """
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HashOptions(TypedDict, total=False):
|
|
49
|
+
"""
|
|
50
|
+
Option parameters for :class:`Crypto.passwordHash`
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
salt : str
|
|
54
|
+
""" A salt to prepend to the message before hashing """
|
|
55
|
+
|
|
56
|
+
encode : bool
|
|
57
|
+
""" Whether to Base64-URL-encode the result """
|
|
58
|
+
|
|
59
|
+
secret : str
|
|
60
|
+
""" A secret to append to the salt when hashing, or undefined for no secret """
|
|
61
|
+
|
|
62
|
+
iterations : int
|
|
63
|
+
""" Number of PBKDF2 iterations """
|
|
64
|
+
|
|
65
|
+
key_len : int
|
|
66
|
+
""" Length (before Base64-encoding) of the PBKDF2 key being generated """
|
|
67
|
+
|
|
68
|
+
digest : str
|
|
69
|
+
""" PBKDF2 digest method """
|
|
70
|
+
|
|
71
|
+
class Crypto:
|
|
72
|
+
""" Provides cryptographic functions """
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
async def passwords_equal(plaintext: str, encoded_hash: str, secret: Optional[str] = None) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Returns true if the plaintext password, when hashed, equals the one in the hash, using
|
|
78
|
+
it's hasher settings
|
|
79
|
+
:param str plaintext: the plaintext password
|
|
80
|
+
:param str encoded_hash: the previously-hashed version
|
|
81
|
+
:param str|None secret: if `useHash`in `encodedHash` is true, uses as a pepper for the hasher
|
|
82
|
+
|
|
83
|
+
:return: true if they are equal, false otherwise
|
|
84
|
+
"""
|
|
85
|
+
hash = Crypto.decode_password_hash(encoded_hash)
|
|
86
|
+
secret1 : str|None = None
|
|
87
|
+
if hash["use_secret"]:
|
|
88
|
+
secret1 = secret
|
|
89
|
+
options : HashOptions = {}
|
|
90
|
+
options["salt"] = MapGetter[str].get_or_raise(hash, "salt")
|
|
91
|
+
options["encode"] = False
|
|
92
|
+
if (secret1 is not None): options["secret"] = secret1
|
|
93
|
+
options["iterations"] = MapGetter[int].get_or_raise(hash, "iterations")
|
|
94
|
+
options["key_len"] = MapGetter[int].get_or_raise(hash, "key_len")
|
|
95
|
+
|
|
96
|
+
new_hash = await Crypto.password_hash(plaintext, options)
|
|
97
|
+
if len(new_hash) != len(hash["hashed_password"]):
|
|
98
|
+
raise CrossauthError(ErrorCode.PasswordInvalid)
|
|
99
|
+
return hmac.compare_digest(new_hash, hash["hashed_password"])
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def base64_decode(encoded: str) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Decodes a string from base64 to UTF-8
|
|
105
|
+
:param str encoded: base64-encoded text
|
|
106
|
+
|
|
107
|
+
:return: URF-8 text
|
|
108
|
+
"""
|
|
109
|
+
return base64.urlsafe_b64decode(Crypto.base64_pad(encoded)).decode('utf-8')
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def base64_encode(text: str) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Base64-encodes UTF-8 text
|
|
115
|
+
:param str: text UTF-8 text
|
|
116
|
+
|
|
117
|
+
:return: Base64 text
|
|
118
|
+
"""
|
|
119
|
+
return base64.urlsafe_b64encode(text.encode('utf-8')).decode('utf-8')
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def decode_password_hash(hash: str) -> PasswordHash:
|
|
123
|
+
"""
|
|
124
|
+
Splits a hashed password into its component parts. Return it as a :class:`PasswordHash`.
|
|
125
|
+
|
|
126
|
+
The format of the hash should be
|
|
127
|
+
```
|
|
128
|
+
digest:int key_len:iterations:useSecret:salt:hashedPassword
|
|
129
|
+
```
|
|
130
|
+
The hashed password part is the Base64 encoding of the PBKDF2 password.
|
|
131
|
+
:param str hash: the hassed password to decode. See above for format
|
|
132
|
+
|
|
133
|
+
:return: :class:`PasswordHash` object containing the deecoded hash components
|
|
134
|
+
"""
|
|
135
|
+
parts = hash.split(':')
|
|
136
|
+
if len(parts) != 7:
|
|
137
|
+
raise CrossauthError(ErrorCode.InvalidHash)
|
|
138
|
+
if parts[0] != "pbkdf2":
|
|
139
|
+
raise CrossauthError(ErrorCode.UnsupportedAlgorithm)
|
|
140
|
+
try:
|
|
141
|
+
return {
|
|
142
|
+
"hashed_password": parts[6],
|
|
143
|
+
"salt": parts[5],
|
|
144
|
+
"use_secret": parts[4] != "0",
|
|
145
|
+
"iterations": int(parts[3]),
|
|
146
|
+
"key_len": int(parts[2]),
|
|
147
|
+
"digest": parts[1]
|
|
148
|
+
}
|
|
149
|
+
except Exception:
|
|
150
|
+
raise CrossauthError(ErrorCode.InvalidHash)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def encode_password_hash(hashed_password: str, salt: str, use_secret: bool, iterations: int, key_len: int, digest: str) -> str:
|
|
154
|
+
"""
|
|
155
|
+
Encodes a hashed password into the string format it is stored as.
|
|
156
|
+
|
|
157
|
+
See :func:`decodePasswordHash` for the format it is stored in.
|
|
158
|
+
|
|
159
|
+
:param str hashed_password: the Base64-encoded PBKDF2 hash of the password
|
|
160
|
+
:param str salt: the salt used for the password.
|
|
161
|
+
:param bool use_secret: whether or not to use the application secret as part
|
|
162
|
+
of the hash.
|
|
163
|
+
:param int iterations: the number of PBKDF2 iterations
|
|
164
|
+
:param int key_len: the key length PBKDF2 parameter - results in a hashed password this length, before Base64,
|
|
165
|
+
:param str digest: The digest algorithm, eg `pbkdf2`
|
|
166
|
+
|
|
167
|
+
:return: a string encode the above parameters.
|
|
168
|
+
"""
|
|
169
|
+
return f"pbkdf2:{digest}:{key_len}:{iterations}:{1 if use_secret else 0}:{salt}:{hashed_password}"
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def random_salt() -> str:
|
|
173
|
+
"""
|
|
174
|
+
Creates a random salt
|
|
175
|
+
|
|
176
|
+
:return: random salt as a base64 encoded string
|
|
177
|
+
"""
|
|
178
|
+
return Crypto.random_value(PBKDF2_SALTLENGTH)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def random_value(length: int) -> str:
|
|
182
|
+
"""
|
|
183
|
+
Creates a random string encoded as in base64url
|
|
184
|
+
:param int length: length of the string to create
|
|
185
|
+
|
|
186
|
+
:return: the random value as a string. Number of bytes will be greater as it is base64 encoded.
|
|
187
|
+
"""
|
|
188
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(length)).decode('utf-8').replace("=", "")
|
|
189
|
+
|
|
190
|
+
Base32 = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def random_base32(length: int, dash_every: Optional[int] = None) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Creates a random base-23 string
|
|
196
|
+
:param int length: length of the string to create
|
|
197
|
+
|
|
198
|
+
:return: the random value as a string. Number of bytes will be greater as it is base64 encoded.
|
|
199
|
+
"""
|
|
200
|
+
bytes = secrets.token_bytes(length)
|
|
201
|
+
str_value = ''.join(Crypto.Base32[i % 32] for i in bytes)
|
|
202
|
+
if dash_every:
|
|
203
|
+
return '-'.join(str_value[i:i + dash_every] for i in range(0, len(str_value), dash_every))
|
|
204
|
+
return str_value
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def uuid() -> str:
|
|
208
|
+
""" Creates a UUID """
|
|
209
|
+
return str(secrets.token_hex(16))
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def hash(plaintext: str) -> str:
|
|
213
|
+
"""
|
|
214
|
+
Standard hash using SHA256 (not PBKDF2 or HMAC)
|
|
215
|
+
|
|
216
|
+
:param :str plaintext: text to hash
|
|
217
|
+
|
|
218
|
+
:return: the string containing the hash
|
|
219
|
+
"""
|
|
220
|
+
return Crypto.sha256(plaintext)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def base64url_to_base64(s : str) -> str:
|
|
224
|
+
s = s.translate(dict(zip(map(ord, u'-_'), u'+/')))
|
|
225
|
+
match (len(s) % 4):
|
|
226
|
+
case 0:
|
|
227
|
+
return s
|
|
228
|
+
case 1:
|
|
229
|
+
return s + "==="
|
|
230
|
+
case 2:
|
|
231
|
+
return s + "=="
|
|
232
|
+
case 3:
|
|
233
|
+
return "="
|
|
234
|
+
case _:
|
|
235
|
+
return s
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def base64_pad(s : str) -> str:
|
|
239
|
+
match (len(s) % 4):
|
|
240
|
+
case 0:
|
|
241
|
+
return s
|
|
242
|
+
case 1:
|
|
243
|
+
return s + "==="
|
|
244
|
+
case 2:
|
|
245
|
+
return s + "=="
|
|
246
|
+
case 3:
|
|
247
|
+
return s + "="
|
|
248
|
+
case _:
|
|
249
|
+
return s
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def base64_to_base64url(s : str) -> str:
|
|
253
|
+
return s.translate(dict(zip(map(ord, u'+/'), u'-_'))).replace("=","")
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def str_to_base64url(s : str) -> str:
|
|
257
|
+
return base64.urlsafe_b64encode(s.encode('utf-8')).decode().replace("=", "")
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def base64url_to_str(s: str) -> str:
|
|
261
|
+
return base64.urlsafe_b64decode(Crypto.base64_pad(s)).decode()
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def sha256(plaintext: str) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Standard hash using SHA256 (not PBKDF2 or HMAC)
|
|
267
|
+
|
|
268
|
+
:param str plaintext: text to hash
|
|
269
|
+
:return: the string containing the hash
|
|
270
|
+
"""
|
|
271
|
+
d = hashlib.sha256(plaintext.encode()).digest()
|
|
272
|
+
return base64.urlsafe_b64encode(d).decode().replace("=", "")
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
async def password_hash(plaintext: str, options: HashOptions = {}) -> str:
|
|
276
|
+
"""
|
|
277
|
+
Hashes a password and returns it as a base64 or base64url encoded string
|
|
278
|
+
:param str plaintext: password to hash
|
|
279
|
+
:param HashOptions options:
|
|
280
|
+
- `salt`: salt to use. Make a random one if not passed
|
|
281
|
+
- `secret`: optional application secret password to apply as a pepper
|
|
282
|
+
- `encode`: if true, returns the full string as it should be stored in the database.
|
|
283
|
+
|
|
284
|
+
:returns: the string containing the hash and the values to decode it
|
|
285
|
+
"""
|
|
286
|
+
salt = MapGetter[str].get_or_none(options, "salt") or Crypto.random_salt()
|
|
287
|
+
use_secret = MapGetter[bool].get(options, "use_secret", False)
|
|
288
|
+
secret = MapGetter[str].get(options, "sedcret", "")
|
|
289
|
+
salt_and_secret = f"{salt}!{secret}" if use_secret else salt
|
|
290
|
+
|
|
291
|
+
iterations = MapGetter[int].get(options, "iterations", PBKDF2_ITERATIONS)
|
|
292
|
+
key_len = MapGetter[int].get(options, "key_len", PBKDF2_KEYLENGTH)
|
|
293
|
+
digest = MapGetter[str].get(options, "digest", PBKDF2_DIGEST)
|
|
294
|
+
|
|
295
|
+
hash_bytes = hashlib.pbkdf2_hmac(digest, plaintext.encode(), salt_and_secret.encode(), iterations, dklen=key_len)
|
|
296
|
+
password_hash = base64.urlsafe_b64encode(hash_bytes).decode('utf-8')
|
|
297
|
+
if MapGetter[int].get(options, "encode", False):
|
|
298
|
+
password_hash = Crypto.encode_password_hash(password_hash, salt, use_secret, iterations, key_len, digest)
|
|
299
|
+
return password_hash
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def signable_token(payload: Dict[str, Any], salt: Optional[str] = None, timestamp: Optional[int] = None) -> str:
|
|
303
|
+
"""
|
|
304
|
+
hash is of a JSON containing the payload, timestamp and optionally
|
|
305
|
+
a salt.
|
|
306
|
+
:param Dict[str, Any]: payload the payload to hash
|
|
307
|
+
:param str|None salt: optional salt (use if the payload is small)
|
|
308
|
+
:param int|None timestamp: time the token will expire
|
|
309
|
+
|
|
310
|
+
:return: a Base64-URL-encoded string that can be hashed.
|
|
311
|
+
"""
|
|
312
|
+
if salt is None:
|
|
313
|
+
salt = Crypto.random_salt()
|
|
314
|
+
if timestamp is None:
|
|
315
|
+
timestamp = int(datetime.now().timestamp()*1000)
|
|
316
|
+
return base64.urlsafe_b64encode(json.dumps({**payload, 't': timestamp, 's': salt}).encode()).decode()
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def sign(payload: Dict[str, Any], secret: str, salt: Optional[str] = None, timestamp: Optional[int] = None) -> str:
|
|
320
|
+
"""
|
|
321
|
+
Signs a JSON payload by creating a hash, using a secret and
|
|
322
|
+
optionally also a salt and timestamp
|
|
323
|
+
|
|
324
|
+
:param Dict[str, Any] payload: object to sign (will be stringified as a JSON)
|
|
325
|
+
:param str secret: secret key, which must be a string
|
|
326
|
+
:param str|None salt: optionally, a salt to concatenate with the payload (must be a string)
|
|
327
|
+
:paramint|None timestamp: optionally, a timestamp to include in the signed date as a Unix date
|
|
328
|
+
|
|
329
|
+
:return: Base64-url encoded hash
|
|
330
|
+
"""
|
|
331
|
+
payloadStr = Crypto.signable_token(payload, salt, timestamp)
|
|
332
|
+
hmac_signature = hmac.new(secret.encode(), payloadStr.encode(), hashlib.sha256).hexdigest()
|
|
333
|
+
return f"{payloadStr}.{hmac_signature}"
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def sign_secure_token(payload: str, secret: str) -> str:
|
|
337
|
+
"""
|
|
338
|
+
This can be called for a string payload that is a cryptographically
|
|
339
|
+
secure random string. No salt is added and the token is assumed to
|
|
340
|
+
be Base64Url already
|
|
341
|
+
|
|
342
|
+
:param str payload: string to sign
|
|
343
|
+
:param str secret: the secret to sign with
|
|
344
|
+
|
|
345
|
+
:return: Base64-url encoded hash
|
|
346
|
+
"""
|
|
347
|
+
hmac_signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
348
|
+
return f"{payload}.{hmac_signature}"
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def unsign(signed_message: str, secret: str, expiry: Optional[int] = None) -> Dict[str, Any]:
|
|
352
|
+
"""
|
|
353
|
+
Validates a signature and, if valid, return the unstringified payload
|
|
354
|
+
:param str signed_message: signed message (base64-url encoded)
|
|
355
|
+
:param str secret: secret key, which must be a string
|
|
356
|
+
:param int|None expiry: if set, validation will fail if the timestamp in the payload is after this date
|
|
357
|
+
|
|
358
|
+
:return: if signature is valid, the payload as an object
|
|
359
|
+
|
|
360
|
+
:raises :class:`crossauth_backend.CrossauthError`: with
|
|
361
|
+
:class:`ErrorCode` of `InvalidKey` if signature
|
|
362
|
+
is invalid or has expired.
|
|
363
|
+
"""
|
|
364
|
+
parts = signed_message.split(".")
|
|
365
|
+
if len(parts) != 2:
|
|
366
|
+
raise CrossauthError(ErrorCode.InvalidKey)
|
|
367
|
+
msg = parts[0]
|
|
368
|
+
sig = parts[1]
|
|
369
|
+
payload = json.loads(base64.urlsafe_b64decode(msg).decode())
|
|
370
|
+
if expiry:
|
|
371
|
+
expire_time = payload['t'] + expiry * 1000
|
|
372
|
+
if expire_time > datetime.now().timestamp():
|
|
373
|
+
raise CrossauthError(ErrorCode.Expired)
|
|
374
|
+
new_sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
|
375
|
+
if new_sig != sig:
|
|
376
|
+
raise CrossauthError(ErrorCode.InvalidKey, "Signature does not match payload")
|
|
377
|
+
return payload
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def unsign_secure_token(signed_message: str, secret: str, expiry: Optional[int] = None) -> str:
|
|
381
|
+
"""
|
|
382
|
+
Validates a signature signed with `signSecureToken` and, if valid,
|
|
383
|
+
return the unstringified payload
|
|
384
|
+
:param str signed_message: signed message (base64-url encoded)
|
|
385
|
+
:param str secret: secret key, which must be a string
|
|
386
|
+
|
|
387
|
+
:return: if signature is valid, the payload as a string
|
|
388
|
+
|
|
389
|
+
:raises :class:`crossauth_backend.CrossauthError`: with
|
|
390
|
+
{:class:`ErrorCode` of `InvalidKey` if signature
|
|
391
|
+
is invalid or has expired.
|
|
392
|
+
"""
|
|
393
|
+
parts = signed_message.split(".")
|
|
394
|
+
if len(parts) != 2:
|
|
395
|
+
raise CrossauthError(ErrorCode.InvalidKey)
|
|
396
|
+
msg = parts[0]
|
|
397
|
+
sig = parts[1]
|
|
398
|
+
payload = msg
|
|
399
|
+
new_sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
|
|
400
|
+
if new_sig != sig:
|
|
401
|
+
raise CrossauthError(ErrorCode.InvalidKey, "Signature does not match payload")
|
|
402
|
+
return payload
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def xor(value: str, mask: str) -> str:
|
|
406
|
+
"""
|
|
407
|
+
XOR's two arrays of base64url-encoded strings
|
|
408
|
+
:param str value: to XOR
|
|
409
|
+
:param str mask: mask to XOR it with
|
|
410
|
+
|
|
411
|
+
:return: an XOR'r string
|
|
412
|
+
"""
|
|
413
|
+
value_array = base64.urlsafe_b64decode(Crypto.base64_pad(value))
|
|
414
|
+
mask_array = base64.urlsafe_b64decode(Crypto.base64_pad(mask))
|
|
415
|
+
result_array = bytes(b ^ m for b, m in zip(value_array, mask_array))
|
|
416
|
+
return base64.urlsafe_b64encode(result_array).decode().replace("=", "")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def symmetric_encrypt(plaintext: str, key_string: str, iv : bytes|None = None) -> str:
|
|
421
|
+
"""
|
|
422
|
+
Symmetric encryption using a key that must be a string
|
|
423
|
+
|
|
424
|
+
:param str plaintext: Text to encrypt
|
|
425
|
+
:param str key_string: the symmetric key
|
|
426
|
+
:param iv bytes|None: the iv value. In None, a random one is created
|
|
427
|
+
|
|
428
|
+
:return: Encrypted text Base64-url encoded.
|
|
429
|
+
"""
|
|
430
|
+
if (iv is None): iv = secrets.token_bytes(16)
|
|
431
|
+
|
|
432
|
+
key = base64.urlsafe_b64decode(Crypto.base64_pad(key_string))
|
|
433
|
+
cipher = AES.new(key, AES.MODE_CBC, iv=iv, use_aesni=False) # type: ignore
|
|
434
|
+
padded_plaintext = pad(plaintext.encode(), AES.block_size)
|
|
435
|
+
encrypted = cipher.encrypt(padded_plaintext)
|
|
436
|
+
|
|
437
|
+
return f"{base64.urlsafe_b64encode(iv).decode().replace("=", "")}.{base64.urlsafe_b64encode(encrypted).decode().replace("=", "")}"
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def symmetric_decrypt(ciphertext: str, key_string: str) -> str:
|
|
441
|
+
"""
|
|
442
|
+
Symmetric decryption using a key that must be a string
|
|
443
|
+
|
|
444
|
+
:param str ciphertext: Base64-url encoded ciphertext
|
|
445
|
+
:param str key_string: the symmetric key
|
|
446
|
+
|
|
447
|
+
:return: Decrypted text
|
|
448
|
+
"""
|
|
449
|
+
key = base64.urlsafe_b64decode(Crypto.base64_pad(key_string))
|
|
450
|
+
parts = ciphertext.split(".")
|
|
451
|
+
if len(parts) != 2:
|
|
452
|
+
raise CrossauthError(ErrorCode.InvalidHash, "Not AES-256-CBC ciphertext")
|
|
453
|
+
iv = base64.urlsafe_b64decode(Crypto.base64_pad(parts[0]))
|
|
454
|
+
encrypted_text = base64.urlsafe_b64decode(Crypto.base64_pad(parts[1]))
|
|
455
|
+
cipher = AES.new(key, AES.MODE_CBC, iv=iv) # type: ignore
|
|
456
|
+
decrypted = unpad(cipher.decrypt(encrypted_text), AES.block_size)
|
|
457
|
+
return decrypted.decode()
|