alphaidea-shield 0.1.0__tar.gz
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.
- alphaidea_shield-0.1.0/LICENSE +21 -0
- alphaidea_shield-0.1.0/PKG-INFO +35 -0
- alphaidea_shield-0.1.0/README.md +21 -0
- alphaidea_shield-0.1.0/pyproject.toml +24 -0
- alphaidea_shield-0.1.0/pyproject.toml.orig +26 -0
- alphaidea_shield-0.1.0/src/shield/__init__.py +51 -0
- alphaidea_shield-0.1.0/src/shield/_encoding.py +17 -0
- alphaidea_shield-0.1.0/src/shield/_utilities.py +62 -0
- alphaidea_shield-0.1.0/src/shield/aes.py +47 -0
- alphaidea_shield-0.1.0/src/shield/argon2id.py +84 -0
- alphaidea_shield-0.1.0/src/shield/py.typed +0 -0
- alphaidea_shield-0.1.0/src/shield/rsa.py +132 -0
- alphaidea_shield-0.1.0/src/shield/totp.py +106 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vincent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alphaidea-shield
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight encryption and decryption library for Python.
|
|
5
|
+
Author: Vincent
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: cryptography>=44
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Project-URL: Homepage, https://github.com/alphaidea/shield-python
|
|
11
|
+
Project-URL: Repository, https://github.com/alphaidea/shield-python
|
|
12
|
+
Project-URL: Issues, https://github.com/alphaidea/shield-python/issues
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Shield
|
|
16
|
+
|
|
17
|
+
A cryptography library for Python 3.11 and later that interoperates with [Go Shield](https://github.com/alphaidea/shield).
|
|
18
|
+
|
|
19
|
+
Install with `uv add alphaidea-shield`.
|
|
20
|
+
|
|
21
|
+
- AES-GCM and RSA-OAEP encryption, with hybrid encryption for larger messages.
|
|
22
|
+
- RSA-PSS signing and verification.
|
|
23
|
+
- Argon2id password hashing and TOTP authentication.
|
|
24
|
+
- SHA-256 checksums.
|
|
25
|
+
- HMAC-SHA256 computation and verification for bytes, plus stream computation.
|
|
26
|
+
- Cryptographically secure random bytes and URL-safe tokens.
|
|
27
|
+
- HKDF-SHA256 key derivation.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import shield
|
|
31
|
+
|
|
32
|
+
key = shield.random_bytes(32)
|
|
33
|
+
ciphertext = shield.aes_encrypt(key, b"hello")
|
|
34
|
+
plaintext = shield.aes_decrypt(key, ciphertext)
|
|
35
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Shield
|
|
2
|
+
|
|
3
|
+
A cryptography library for Python 3.11 and later that interoperates with [Go Shield](https://github.com/alphaidea/shield).
|
|
4
|
+
|
|
5
|
+
Install with `uv add alphaidea-shield`.
|
|
6
|
+
|
|
7
|
+
- AES-GCM and RSA-OAEP encryption, with hybrid encryption for larger messages.
|
|
8
|
+
- RSA-PSS signing and verification.
|
|
9
|
+
- Argon2id password hashing and TOTP authentication.
|
|
10
|
+
- SHA-256 checksums.
|
|
11
|
+
- HMAC-SHA256 computation and verification for bytes, plus stream computation.
|
|
12
|
+
- Cryptographically secure random bytes and URL-safe tokens.
|
|
13
|
+
- HKDF-SHA256 key derivation.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import shield
|
|
17
|
+
|
|
18
|
+
key = shield.random_bytes(32)
|
|
19
|
+
ciphertext = shield.aes_encrypt(key, b"hello")
|
|
20
|
+
plaintext = shield.aes_decrypt(key, ciphertext)
|
|
21
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alphaidea-shield"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A lightweight encryption and decryption library for Python."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = ["cryptography>=44"]
|
|
10
|
+
|
|
11
|
+
[[project.authors]]
|
|
12
|
+
name = "Vincent"
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/alphaidea/shield-python"
|
|
16
|
+
Repository = "https://github.com/alphaidea/shield-python"
|
|
17
|
+
Issues = "https://github.com/alphaidea/shield-python/issues"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[tool.uv.build-backend]
|
|
24
|
+
module-name = "shield"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alphaidea-shield"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A lightweight encryption and decryption library for Python."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Vincent" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"cryptography>=44",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/alphaidea/shield-python"
|
|
18
|
+
Repository = "https://github.com/alphaidea/shield-python"
|
|
19
|
+
Issues = "https://github.com/alphaidea/shield-python/issues"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "shield"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Python APIs compatible with the Go Shield library."""
|
|
2
|
+
|
|
3
|
+
from . import aes as aes, argon2id as argon2id, rsa as rsa, totp as totp
|
|
4
|
+
from ._utilities import (
|
|
5
|
+
hkdf_sha256 as hkdf_sha256,
|
|
6
|
+
hmac_sha256 as hmac_sha256,
|
|
7
|
+
hmac_sha256_stream as hmac_sha256_stream,
|
|
8
|
+
hmac_sha256_verify as hmac_sha256_verify,
|
|
9
|
+
random_bytes as random_bytes,
|
|
10
|
+
random_token as random_token,
|
|
11
|
+
sha256 as sha256,
|
|
12
|
+
sha256_stream as sha256_stream,
|
|
13
|
+
)
|
|
14
|
+
from .aes import (
|
|
15
|
+
decrypt as aes_decrypt,
|
|
16
|
+
decrypt_string as aes_decrypt_string,
|
|
17
|
+
encrypt as aes_encrypt,
|
|
18
|
+
encrypt_string as aes_encrypt_string,
|
|
19
|
+
gcm_decrypt as gcm_decrypt,
|
|
20
|
+
gcm_decrypt_string as gcm_decrypt_string,
|
|
21
|
+
gcm_encrypt as gcm_encrypt,
|
|
22
|
+
gcm_encrypt_string as gcm_encrypt_string,
|
|
23
|
+
)
|
|
24
|
+
from .argon2id import (
|
|
25
|
+
Options as Argon2idOptions,
|
|
26
|
+
hash as argon2id_hash,
|
|
27
|
+
verify as argon2id_verify,
|
|
28
|
+
)
|
|
29
|
+
from .rsa import (
|
|
30
|
+
decrypt_hybrid as rsa_decrypt_hybrid,
|
|
31
|
+
decrypt_hybrid_string as rsa_decrypt_hybrid_string,
|
|
32
|
+
decrypt_string as rsa_decrypt_string,
|
|
33
|
+
encrypt_hybrid as rsa_encrypt_hybrid,
|
|
34
|
+
encrypt_hybrid_string as rsa_encrypt_hybrid_string,
|
|
35
|
+
encrypt_string as rsa_encrypt_string,
|
|
36
|
+
generate_key as rsa_generate_key,
|
|
37
|
+
parse_private_key as rsa_parse_private_key,
|
|
38
|
+
parse_public_key as rsa_parse_public_key,
|
|
39
|
+
sign as rsa_sign,
|
|
40
|
+
sign_string as rsa_sign_string,
|
|
41
|
+
verify as rsa_verify,
|
|
42
|
+
verify_string as rsa_verify_string,
|
|
43
|
+
)
|
|
44
|
+
from .totp import (
|
|
45
|
+
Algorithm as TOTPAlgorithm,
|
|
46
|
+
Options as TOTPOptions,
|
|
47
|
+
generate as totp_generate,
|
|
48
|
+
generate_secret as totp_generate_secret,
|
|
49
|
+
uri as totp_uri,
|
|
50
|
+
verify as totp_verify,
|
|
51
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def encode_base64(data: bytes) -> str:
|
|
6
|
+
return base64.b64encode(data).decode("ascii")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def decode_base64(data: str, prefix: str = "") -> bytes:
|
|
10
|
+
if not data.startswith(prefix):
|
|
11
|
+
raise ValueError("unsupported ciphertext version")
|
|
12
|
+
encoded = data[len(prefix):].replace("\r", "").replace("\n", "")
|
|
13
|
+
return base64.b64decode(encoded, validate=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decode_hex(data: str) -> bytes:
|
|
17
|
+
return binascii.unhexlify(data)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import BinaryIO, Protocol
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives import hashes
|
|
7
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
8
|
+
|
|
9
|
+
from ._encoding import decode_hex
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Digest(Protocol):
|
|
13
|
+
def update(self, data: bytes, /) -> None: ...
|
|
14
|
+
def hexdigest(self) -> str: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _digest_stream(reader: BinaryIO, digest: _Digest) -> str:
|
|
18
|
+
while chunk := reader.read(65536):
|
|
19
|
+
digest.update(chunk)
|
|
20
|
+
return digest.hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sha256(data: bytes) -> str:
|
|
24
|
+
return hashlib.sha256(data).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sha256_stream(reader: BinaryIO) -> str:
|
|
28
|
+
return _digest_stream(reader, hashlib.sha256())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hmac_sha256(key: bytes, data: bytes) -> str:
|
|
32
|
+
return hmac.digest(key, data, "sha256").hex()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hmac_sha256_verify(key: bytes, data: bytes, encoded: str) -> bool:
|
|
36
|
+
if len(encoded) != 64:
|
|
37
|
+
raise ValueError("HMAC-SHA256 must contain 64 hexadecimal characters")
|
|
38
|
+
return hmac.compare_digest(hmac.digest(key, data, "sha256"), decode_hex(encoded))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hmac_sha256_stream(key: bytes, reader: BinaryIO) -> str:
|
|
42
|
+
return _digest_stream(reader, hmac.new(key, digestmod="sha256"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def random_bytes(length: int) -> bytes:
|
|
46
|
+
if length <= 0:
|
|
47
|
+
raise ValueError("random byte length must be positive")
|
|
48
|
+
return secrets.token_bytes(length)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def random_token(length: int) -> str:
|
|
52
|
+
if length <= 0:
|
|
53
|
+
raise ValueError("random byte length must be positive")
|
|
54
|
+
return secrets.token_urlsafe(length)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hkdf_sha256(
|
|
58
|
+
secret: bytes, salt: bytes | None = None, info: str = "", length: int = 32
|
|
59
|
+
) -> bytes:
|
|
60
|
+
if not 1 <= length <= 8160:
|
|
61
|
+
raise ValueError("HKDF-SHA256 length must be between 1 and 8160 bytes")
|
|
62
|
+
return HKDF(hashes.SHA256(), length, salt, info.encode()).derive(secret)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Authenticated AES-GCM."""
|
|
2
|
+
|
|
3
|
+
from secrets import token_bytes
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
6
|
+
|
|
7
|
+
from ._encoding import decode_base64, encode_base64
|
|
8
|
+
|
|
9
|
+
KEY_SIZE_128 = 16
|
|
10
|
+
KEY_SIZE_192 = 24
|
|
11
|
+
KEY_SIZE_256 = 32
|
|
12
|
+
_NONCE_SIZE = 12
|
|
13
|
+
_TAG_SIZE = 16
|
|
14
|
+
_PREFIX = "v1:"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _seal(key: bytes, data: bytes, aad: bytes | None = None) -> bytes:
|
|
18
|
+
nonce = token_bytes(_NONCE_SIZE)
|
|
19
|
+
return nonce + AESGCM(key).encrypt(nonce, data, aad)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _open(key: bytes, data: bytes, aad: bytes | None = None) -> bytes:
|
|
23
|
+
if len(data) < _NONCE_SIZE + _TAG_SIZE:
|
|
24
|
+
raise ValueError("invalid GCM ciphertext length")
|
|
25
|
+
return AESGCM(key).decrypt(data[:_NONCE_SIZE], data[_NONCE_SIZE:], aad)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def gcm_encrypt(key: bytes, data: bytes) -> bytes:
|
|
29
|
+
return _seal(key, data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def gcm_decrypt(key: bytes, data: bytes) -> bytes:
|
|
33
|
+
return _open(key, data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gcm_encrypt_string(key: str, data: str) -> str:
|
|
37
|
+
return _PREFIX + encode_base64(gcm_encrypt(key.encode(), data.encode()))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def gcm_decrypt_string(key: str, data: str) -> str:
|
|
41
|
+
return gcm_decrypt(key.encode(), decode_base64(data, _PREFIX)).decode()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
encrypt = gcm_encrypt
|
|
45
|
+
decrypt = gcm_decrypt
|
|
46
|
+
encrypt_string = gcm_encrypt_string
|
|
47
|
+
decrypt_string = gcm_decrypt_string
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Peppered Argon2id password hashes in Shield's PHC format."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from secrets import token_bytes
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
|
|
9
|
+
|
|
10
|
+
from ._encoding import decode_base64, encode_base64
|
|
11
|
+
|
|
12
|
+
DEFAULT_MEMORY = 19456
|
|
13
|
+
DEFAULT_ITERATIONS = 2
|
|
14
|
+
DEFAULT_PARALLELISM = 1
|
|
15
|
+
DEFAULT_SALT_LENGTH = 16
|
|
16
|
+
DEFAULT_HASH_LENGTH = 32
|
|
17
|
+
_PHC = re.compile(
|
|
18
|
+
r"\$argon2id\$v=19\$m=(0|[1-9][0-9]*),t=(0|[1-9][0-9]*),p=(0|[1-9][0-9]*)"
|
|
19
|
+
r"\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Options:
|
|
25
|
+
memory: int = DEFAULT_MEMORY
|
|
26
|
+
iterations: int = DEFAULT_ITERATIONS
|
|
27
|
+
parallelism: int = DEFAULT_PARALLELISM
|
|
28
|
+
salt_length: int = DEFAULT_SALT_LENGTH
|
|
29
|
+
hash_length: int = DEFAULT_HASH_LENGTH
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not (
|
|
33
|
+
1 <= self.parallelism <= 16
|
|
34
|
+
and 8 * self.parallelism <= self.memory <= 262144
|
|
35
|
+
and 1 <= self.iterations <= 10
|
|
36
|
+
and 16 <= self.salt_length <= 64
|
|
37
|
+
and 16 <= self.hash_length <= 64
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("password hash parameters out of range")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prehash(pepper: str, password: str) -> bytes:
|
|
43
|
+
if not pepper:
|
|
44
|
+
raise ValueError("password pepper is empty")
|
|
45
|
+
return hmac.digest(pepper.encode(), password.encode(), "sha256")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _derive(prehash: bytes, salt: bytes, options: Options) -> bytes:
|
|
49
|
+
return Argon2id(
|
|
50
|
+
salt=salt, length=options.hash_length, iterations=options.iterations,
|
|
51
|
+
lanes=options.parallelism, memory_cost=options.memory,
|
|
52
|
+
).derive(prehash)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _encode(data: bytes) -> str:
|
|
56
|
+
return encode_base64(data).rstrip("=")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _decode(encoded: str) -> bytes:
|
|
60
|
+
data = decode_base64(encoded + "=" * (-len(encoded) % 4))
|
|
61
|
+
if _encode(data) != encoded:
|
|
62
|
+
raise ValueError("non-canonical password hash Base64")
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def hash(pepper: str, password: str, options: Options = Options()) -> str:
|
|
67
|
+
prehash = _prehash(pepper, password)
|
|
68
|
+
salt = token_bytes(options.salt_length)
|
|
69
|
+
digest = _derive(prehash, salt, options)
|
|
70
|
+
return (
|
|
71
|
+
f"$argon2id$v=19$m={options.memory},t={options.iterations},p={options.parallelism}"
|
|
72
|
+
f"${_encode(salt)}${_encode(digest)}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def verify(pepper: str, password: str, encoded: str) -> bool:
|
|
77
|
+
prehash = _prehash(pepper, password)
|
|
78
|
+
match = _PHC.fullmatch(encoded) if len(encoded) <= 256 else None
|
|
79
|
+
if match is None:
|
|
80
|
+
raise ValueError("invalid password hash format")
|
|
81
|
+
memory, iterations, parallelism = map(int, match.groups()[:3])
|
|
82
|
+
salt, digest = map(_decode, match.groups()[3:])
|
|
83
|
+
options = Options(memory, iterations, parallelism, len(salt), len(digest))
|
|
84
|
+
return hmac.compare_digest(_derive(prehash, salt, options), digest)
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""RSA-OAEP, RSA-PSS and the Shield hybrid ciphertext format."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from secrets import token_bytes
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric import rsa as backend
|
|
9
|
+
|
|
10
|
+
from ._encoding import decode_base64, encode_base64
|
|
11
|
+
from .aes import _open, _seal
|
|
12
|
+
|
|
13
|
+
_HEADER = b"SHRSA\x01"
|
|
14
|
+
_PREFIX = "hybrid:v1:"
|
|
15
|
+
_MINIMUM_BITS = 2048
|
|
16
|
+
_PEM = re.compile(
|
|
17
|
+
rb"-----BEGIN ([A-Z ]+)-----\r?\n([A-Za-z0-9+/=\r\n]+)"
|
|
18
|
+
rb"-----END \1-----"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_pem(data: bytes, label: bytes) -> bytes:
|
|
23
|
+
data = data.strip()
|
|
24
|
+
match = _PEM.fullmatch(data)
|
|
25
|
+
if match is None or match[1] != label:
|
|
26
|
+
raise ValueError("invalid RSA key PEM")
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_private_key(data: bytes) -> backend.RSAPrivateKey:
|
|
31
|
+
key = serialization.load_pem_private_key(_parse_pem(data, b"PRIVATE KEY"), password=None)
|
|
32
|
+
if not isinstance(key, backend.RSAPrivateKey):
|
|
33
|
+
raise ValueError("private key is not RSA")
|
|
34
|
+
return key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_public_key(data: bytes) -> backend.RSAPublicKey:
|
|
38
|
+
key = serialization.load_pem_public_key(_parse_pem(data, b"PUBLIC KEY"))
|
|
39
|
+
if not isinstance(key, backend.RSAPublicKey):
|
|
40
|
+
raise ValueError("public key is not RSA")
|
|
41
|
+
return key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_size(bits: int) -> None:
|
|
45
|
+
if bits < _MINIMUM_BITS:
|
|
46
|
+
raise ValueError("RSA key must be at least 2048 bits")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_key(bits: int = _MINIMUM_BITS) -> tuple[str, str]:
|
|
50
|
+
_require_size(bits)
|
|
51
|
+
private = backend.generate_private_key(public_exponent=65537, key_size=bits)
|
|
52
|
+
private_pem = private.private_bytes(
|
|
53
|
+
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
|
|
54
|
+
)
|
|
55
|
+
public_pem = private.public_key().public_bytes(
|
|
56
|
+
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
|
|
57
|
+
)
|
|
58
|
+
return private_pem.decode("ascii"), public_pem.decode("ascii")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _oaep(label: bytes | None = None) -> padding.OAEP:
|
|
62
|
+
return padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), label)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encrypt(key: bytes, data: bytes) -> bytes:
|
|
66
|
+
public = parse_public_key(key)
|
|
67
|
+
_require_size(public.key_size)
|
|
68
|
+
return public.encrypt(data, _oaep())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def decrypt(key: bytes, data: bytes) -> bytes:
|
|
72
|
+
private = parse_private_key(key)
|
|
73
|
+
_require_size(private.key_size)
|
|
74
|
+
return private.decrypt(data, _oaep())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encrypt_string(key: str, data: str) -> str:
|
|
78
|
+
return encode_base64(encrypt(key.encode(), data.encode()))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def decrypt_string(key: str, data: str) -> str:
|
|
82
|
+
return decrypt(key.encode(), decode_base64(data)).decode()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def encrypt_hybrid(key: bytes, data: bytes) -> bytes:
|
|
86
|
+
public = parse_public_key(key)
|
|
87
|
+
_require_size(public.key_size)
|
|
88
|
+
secret = token_bytes(32)
|
|
89
|
+
header = _HEADER + public.encrypt(secret, _oaep(_HEADER))
|
|
90
|
+
return header + _seal(secret, data, header)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def decrypt_hybrid(key: bytes, data: bytes) -> bytes:
|
|
94
|
+
private = parse_private_key(key)
|
|
95
|
+
_require_size(private.key_size)
|
|
96
|
+
boundary = len(_HEADER) + (private.key_size + 7) // 8
|
|
97
|
+
if len(data) < boundary + 28 or not data.startswith(_HEADER):
|
|
98
|
+
raise ValueError("invalid hybrid ciphertext")
|
|
99
|
+
secret = private.decrypt(data[len(_HEADER):boundary], _oaep(_HEADER))
|
|
100
|
+
if len(secret) != 32:
|
|
101
|
+
raise ValueError("invalid hybrid key length")
|
|
102
|
+
return _open(secret, data[boundary:], data[:boundary])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def encrypt_hybrid_string(key: str, data: str) -> str:
|
|
106
|
+
return _PREFIX + encode_base64(encrypt_hybrid(key.encode(), data.encode()))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def decrypt_hybrid_string(key: str, data: str) -> str:
|
|
110
|
+
return decrypt_hybrid(key.encode(), decode_base64(data, _PREFIX)).decode()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sign(key: bytes, data: bytes) -> bytes:
|
|
114
|
+
private = parse_private_key(key)
|
|
115
|
+
_require_size(private.key_size)
|
|
116
|
+
algorithm = hashes.SHA256()
|
|
117
|
+
return private.sign(data, padding.PSS(padding.MGF1(algorithm), algorithm.digest_size), algorithm)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def verify(key: bytes, data: bytes, signature: bytes) -> None:
|
|
121
|
+
public = parse_public_key(key)
|
|
122
|
+
_require_size(public.key_size)
|
|
123
|
+
algorithm = hashes.SHA256()
|
|
124
|
+
public.verify(signature, data, padding.PSS(padding.MGF1(algorithm), algorithm.digest_size), algorithm)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sign_string(key: str, data: str) -> str:
|
|
128
|
+
return encode_base64(sign(key.encode(), data.encode()))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify_string(key: str, data: str, signature: str) -> None:
|
|
132
|
+
verify(key.encode(), data.encode(), decode_base64(signature))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""RFC 6238 TOTP codes and authenticator provisioning URIs."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from secrets import token_bytes
|
|
9
|
+
from typing import Literal
|
|
10
|
+
from urllib.parse import quote, urlencode
|
|
11
|
+
|
|
12
|
+
Algorithm = Literal["SHA1", "SHA256", "SHA512"]
|
|
13
|
+
DEFAULT_ALGORITHM: Algorithm = "SHA1"
|
|
14
|
+
DEFAULT_DIGITS = 6
|
|
15
|
+
DEFAULT_PERIOD = 30
|
|
16
|
+
DEFAULT_SKEW = 1
|
|
17
|
+
DEFAULT_SECRET_LENGTH = 20
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Options:
|
|
22
|
+
algorithm: Algorithm = DEFAULT_ALGORITHM
|
|
23
|
+
digits: int = DEFAULT_DIGITS
|
|
24
|
+
period: int = DEFAULT_PERIOD
|
|
25
|
+
skew: int = DEFAULT_SKEW
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not (
|
|
29
|
+
self.algorithm in ("SHA1", "SHA256", "SHA512")
|
|
30
|
+
and self.digits in (6, 8)
|
|
31
|
+
and 1 <= self.period <= 86400
|
|
32
|
+
and 0 <= self.skew <= 10
|
|
33
|
+
):
|
|
34
|
+
raise ValueError("invalid TOTP options")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _encode(key: bytes) -> str:
|
|
38
|
+
return base64.b32encode(key).decode("ascii").rstrip("=")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decode(secret: str) -> bytes:
|
|
42
|
+
if not secret.isascii() or len(secret) > 205:
|
|
43
|
+
raise ValueError("invalid TOTP secret")
|
|
44
|
+
encoded = secret.upper()
|
|
45
|
+
key = base64.b32decode(encoded + "=" * (-len(encoded) % 8))
|
|
46
|
+
if not 16 <= len(key) <= 128 or _encode(key) != encoded:
|
|
47
|
+
raise ValueError("TOTP secret must be canonical unpadded Base32 of 16..128 bytes")
|
|
48
|
+
return key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _counter(at: datetime | int | float | None, period: int) -> int:
|
|
52
|
+
if isinstance(at, datetime):
|
|
53
|
+
if at.utcoffset() is None:
|
|
54
|
+
raise ValueError("TOTP datetime must be timezone-aware")
|
|
55
|
+
at = at.timestamp()
|
|
56
|
+
if at is None:
|
|
57
|
+
at = time.time()
|
|
58
|
+
if not 0 <= at < 2**63:
|
|
59
|
+
raise ValueError("invalid TOTP Unix timestamp")
|
|
60
|
+
return int(at) // period
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _code(key: bytes, counter: int, options: Options) -> str:
|
|
64
|
+
digest = hmac.digest(key, counter.to_bytes(8, "big"), options.algorithm.lower())
|
|
65
|
+
offset = digest[-1] & 15
|
|
66
|
+
value = int.from_bytes(digest[offset:offset + 4], "big") & 0x7FFFFFFF
|
|
67
|
+
return f"{value % 10**options.digits:0{options.digits}d}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def generate_secret() -> str:
|
|
71
|
+
return _encode(token_bytes(DEFAULT_SECRET_LENGTH))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate(
|
|
75
|
+
secret: str, at: datetime | int | float | None = None, *, options: Options = Options()
|
|
76
|
+
) -> str:
|
|
77
|
+
return _code(_decode(secret), _counter(at, options.period), options)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def verify(
|
|
81
|
+
secret: str, code: str, at: datetime | int | float | None = None, *,
|
|
82
|
+
options: Options = Options(),
|
|
83
|
+
) -> bool:
|
|
84
|
+
key = _decode(secret)
|
|
85
|
+
counter = _counter(at, options.period)
|
|
86
|
+
if len(code) != options.digits or not code.isascii() or not code.isdigit():
|
|
87
|
+
return False
|
|
88
|
+
matches = False
|
|
89
|
+
for candidate in range(max(0, counter - options.skew), counter + options.skew + 1):
|
|
90
|
+
matches |= hmac.compare_digest(code, _code(key, candidate, options))
|
|
91
|
+
return matches
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def uri(secret: str, issuer: str, account: str, *, options: Options = Options()) -> str:
|
|
95
|
+
key = _decode(secret)
|
|
96
|
+
if not issuer.strip() or not account.strip() or ":" in issuer + account:
|
|
97
|
+
raise ValueError("TOTP issuer and account must be non-empty and contain no colon")
|
|
98
|
+
label = quote(issuer, safe="$&+@=") + ":" + quote(account, safe="$&+@=")
|
|
99
|
+
query = urlencode({
|
|
100
|
+
"algorithm": options.algorithm,
|
|
101
|
+
"digits": options.digits,
|
|
102
|
+
"issuer": issuer,
|
|
103
|
+
"period": options.period,
|
|
104
|
+
"secret": _encode(key),
|
|
105
|
+
})
|
|
106
|
+
return f"otpauth://totp/{label}?{query}"
|