ratchetkit 1.2.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.
- ratchetkit/__init__.py +3 -0
- ratchetkit/algorithms/__init__.py +1 -0
- ratchetkit/algorithms/cipher/__init__.py +17 -0
- ratchetkit/algorithms/cipher/aes128.py +15 -0
- ratchetkit/algorithms/cipher/aes256.py +15 -0
- ratchetkit/algorithms/cipher/base.py +65 -0
- ratchetkit/algorithms/cipher/camellia128.py +18 -0
- ratchetkit/algorithms/cipher/camellia256.py +18 -0
- ratchetkit/algorithms/cipher/chacha20.py +17 -0
- ratchetkit/algorithms/hash/__init__.py +17 -0
- ratchetkit/algorithms/hash/base.py +30 -0
- ratchetkit/algorithms/hash/blake2b.py +26 -0
- ratchetkit/algorithms/hash/sha256.py +26 -0
- ratchetkit/algorithms/hash/sha3256.py +26 -0
- ratchetkit/algorithms/hash/sha384.py +26 -0
- ratchetkit/algorithms/hash/sha512.py +26 -0
- ratchetkit/algorithms/kem/__init__.py +12 -0
- ratchetkit/algorithms/kem/base.py +63 -0
- ratchetkit/algorithms/kem/ec_base.py +104 -0
- ratchetkit/algorithms/kem/mlkem1024.py +15 -0
- ratchetkit/algorithms/kem/mlkem768.py +15 -0
- ratchetkit/algorithms/kem/mlkem_base.py +97 -0
- ratchetkit/algorithms/kem/rsa2048.py +10 -0
- ratchetkit/algorithms/kem/rsa4096.py +10 -0
- ratchetkit/algorithms/kem/rsa_base.py +151 -0
- ratchetkit/algorithms/kem/secp256r1.py +12 -0
- ratchetkit/algorithms/kem/secp384r1.py +12 -0
- ratchetkit/algorithms/kem/secp521r1.py +12 -0
- ratchetkit/algorithms/kem/x25519.py +107 -0
- ratchetkit/algorithms/kem/x448.py +107 -0
- ratchetkit/errors.py +61 -0
- ratchetkit/handshake/__init__.py +15 -0
- ratchetkit/handshake/message.py +177 -0
- ratchetkit/handshake/pqxdh.py +251 -0
- ratchetkit/handshake/x3dh.py +301 -0
- ratchetkit/kdf.py +70 -0
- ratchetkit/keys/__init__.py +5 -0
- ratchetkit/keys/chain.py +57 -0
- ratchetkit/keys/identity.py +36 -0
- ratchetkit/keys/keypair.py +98 -0
- ratchetkit/keys/message.py +87 -0
- ratchetkit/keys/prekey.py +372 -0
- ratchetkit/keys/root.py +102 -0
- ratchetkit/packing.py +154 -0
- ratchetkit/py.typed +0 -0
- ratchetkit/ratchet/__init__.py +22 -0
- ratchetkit/ratchet/chains.py +68 -0
- ratchetkit/ratchet/header.py +51 -0
- ratchetkit/ratchet/limits.py +35 -0
- ratchetkit/ratchet/message/__init__.py +11 -0
- ratchetkit/ratchet/message/base.py +44 -0
- ratchetkit/ratchet/message/double.py +65 -0
- ratchetkit/ratchet/message/triple.py +91 -0
- ratchetkit/ratchet/protocol/__init__.py +11 -0
- ratchetkit/ratchet/protocol/base.py +49 -0
- ratchetkit/ratchet/protocol/double.py +504 -0
- ratchetkit/ratchet/protocol/triple.py +258 -0
- ratchetkit/ratchet/skipped.py +123 -0
- ratchetkit/ratchet/spqr.py +555 -0
- ratchetkit/scka/__init__.py +18 -0
- ratchetkit/scka/chunk.py +124 -0
- ratchetkit/scka/kem_scka.py +410 -0
- ratchetkit/session.py +235 -0
- ratchetkit/suite/__init__.py +33 -0
- ratchetkit/suite/algs.py +94 -0
- ratchetkit/suite/double.py +135 -0
- ratchetkit/suite/triple.py +118 -0
- ratchetkit-1.2.0.dist-info/METADATA +268 -0
- ratchetkit-1.2.0.dist-info/RECORD +71 -0
- ratchetkit-1.2.0.dist-info/WHEEL +4 -0
- ratchetkit-1.2.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Shared RSA-OAEP encapsulation + RSA-PSS signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa, utils
|
|
10
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
11
|
+
Encoding,
|
|
12
|
+
NoEncryption,
|
|
13
|
+
PrivateFormat,
|
|
14
|
+
PublicFormat,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ratchetkit.errors import InvalidKey, InvalidSignature
|
|
18
|
+
from ratchetkit.algorithms.kem.base import KemBackend
|
|
19
|
+
from ratchetkit.keys.keypair import (
|
|
20
|
+
KeyPair,
|
|
21
|
+
PrivateKey,
|
|
22
|
+
PublicKey,
|
|
23
|
+
SigningKeyPair,
|
|
24
|
+
SigningPrivateKey,
|
|
25
|
+
SigningPublicKey,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ratchetkit.algorithms.hash.base import HashAlgorithm
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RsaKem(KemBackend):
|
|
33
|
+
"""RSA-OAEP encapsulation + RSA-PSS signatures backend base."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
key_size: int
|
|
37
|
+
supports_dh = False
|
|
38
|
+
secret_size = 32
|
|
39
|
+
|
|
40
|
+
def generate_keypair(self) -> KeyPair:
|
|
41
|
+
private = rsa.generate_private_key(
|
|
42
|
+
public_exponent=65537,
|
|
43
|
+
key_size=self.key_size,
|
|
44
|
+
)
|
|
45
|
+
return KeyPair(
|
|
46
|
+
public=PublicKey(self.encode_public(private.public_key()), self.name),
|
|
47
|
+
private=PrivateKey(self.encode_private(private), self.name),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def encapsulate(self, their_public: PublicKey) -> tuple[bytes, bytes]:
|
|
51
|
+
public = self.decode_public(their_public.data)
|
|
52
|
+
secret = os.urandom(self.secret_size)
|
|
53
|
+
ciphertext = public.encrypt(
|
|
54
|
+
secret,
|
|
55
|
+
padding.OAEP(
|
|
56
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
57
|
+
algorithm=hashes.SHA256(),
|
|
58
|
+
label=None,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
return secret, ciphertext
|
|
62
|
+
|
|
63
|
+
def decapsulate(self, our_private: PrivateKey, ciphertext: bytes) -> bytes:
|
|
64
|
+
private = self.decode_private(our_private.data)
|
|
65
|
+
try:
|
|
66
|
+
return private.decrypt(
|
|
67
|
+
ciphertext,
|
|
68
|
+
padding.OAEP(
|
|
69
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
70
|
+
algorithm=hashes.SHA256(),
|
|
71
|
+
label=None,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
raise InvalidKey("RSA-OAEP decapsulation failed") from exc
|
|
76
|
+
|
|
77
|
+
def generate_signing_keypair(self) -> SigningKeyPair:
|
|
78
|
+
pair = self.generate_keypair()
|
|
79
|
+
return SigningKeyPair(
|
|
80
|
+
public=SigningPublicKey(pair.public.data, self.name),
|
|
81
|
+
private=SigningPrivateKey(pair.private.data, self.name),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def sign(
|
|
85
|
+
self,
|
|
86
|
+
private: SigningPrivateKey,
|
|
87
|
+
message: bytes,
|
|
88
|
+
digest: type[HashAlgorithm],
|
|
89
|
+
) -> bytes:
|
|
90
|
+
key = self.decode_private(private.data)
|
|
91
|
+
algorithm = digest.hazmat()
|
|
92
|
+
hasher = hashes.Hash(algorithm)
|
|
93
|
+
hasher.update(message)
|
|
94
|
+
return key.sign(
|
|
95
|
+
hasher.finalize(),
|
|
96
|
+
padding.PSS(
|
|
97
|
+
mgf=padding.MGF1(algorithm),
|
|
98
|
+
salt_length=padding.PSS.MAX_LENGTH,
|
|
99
|
+
),
|
|
100
|
+
utils.Prehashed(algorithm),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def verify(
|
|
104
|
+
self,
|
|
105
|
+
public: SigningPublicKey,
|
|
106
|
+
message: bytes,
|
|
107
|
+
signature: bytes,
|
|
108
|
+
digest: type[HashAlgorithm],
|
|
109
|
+
) -> None:
|
|
110
|
+
key = self.decode_public(public.data)
|
|
111
|
+
algorithm = digest.hazmat()
|
|
112
|
+
hasher = hashes.Hash(algorithm)
|
|
113
|
+
hasher.update(message)
|
|
114
|
+
try:
|
|
115
|
+
key.verify(
|
|
116
|
+
signature,
|
|
117
|
+
hasher.finalize(),
|
|
118
|
+
padding.PSS(
|
|
119
|
+
mgf=padding.MGF1(algorithm),
|
|
120
|
+
salt_length=padding.PSS.MAX_LENGTH,
|
|
121
|
+
),
|
|
122
|
+
utils.Prehashed(algorithm),
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise InvalidSignature("RSA-PSS verification failed") from exc
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def encode_public(key: rsa.RSAPublicKey) -> bytes:
|
|
129
|
+
return key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def encode_private(key: rsa.RSAPrivateKey) -> bytes:
|
|
133
|
+
return key.private_bytes(
|
|
134
|
+
Encoding.DER,
|
|
135
|
+
PrivateFormat.PKCS8,
|
|
136
|
+
NoEncryption(),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def decode_public(data: bytes) -> rsa.RSAPublicKey:
|
|
141
|
+
key = serialization.load_der_public_key(data)
|
|
142
|
+
if not isinstance(key, rsa.RSAPublicKey):
|
|
143
|
+
raise InvalidKey("expected RSA public key")
|
|
144
|
+
return key
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def decode_private(data: bytes) -> rsa.RSAPrivateKey:
|
|
148
|
+
key = serialization.load_der_private_key(data, password=None)
|
|
149
|
+
if not isinstance(key, rsa.RSAPrivateKey):
|
|
150
|
+
raise InvalidKey("expected RSA private key")
|
|
151
|
+
return key
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""SECP256R1 ECDH-as-KEM + ECDSA signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
6
|
+
|
|
7
|
+
from ratchetkit.algorithms.kem.ec_base import EcKem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Secp256r1(EcKem):
|
|
11
|
+
name = "secp256r1"
|
|
12
|
+
curve = ec.SECP256R1()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""SECP384R1 ECDH-as-KEM + ECDSA signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
6
|
+
|
|
7
|
+
from ratchetkit.algorithms.kem.ec_base import EcKem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Secp384r1(EcKem):
|
|
11
|
+
name = "secp384r1"
|
|
12
|
+
curve = ec.SECP384R1()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""SECP521R1 ECDH-as-KEM + ECDSA signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
6
|
+
|
|
7
|
+
from ratchetkit.algorithms.kem.ec_base import EcKem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Secp521r1(EcKem):
|
|
11
|
+
name = "secp521r1"
|
|
12
|
+
curve = ec.SECP521R1()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""X25519 DH-as-KEM + Ed25519 signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
|
8
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
9
|
+
Encoding,
|
|
10
|
+
NoEncryption,
|
|
11
|
+
PrivateFormat,
|
|
12
|
+
PublicFormat,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from ratchetkit.errors import InvalidKey, InvalidSignature
|
|
16
|
+
from ratchetkit.algorithms.kem.base import KemBackend
|
|
17
|
+
from ratchetkit.keys.keypair import (
|
|
18
|
+
KeyPair,
|
|
19
|
+
PrivateKey,
|
|
20
|
+
PublicKey,
|
|
21
|
+
SigningKeyPair,
|
|
22
|
+
SigningPrivateKey,
|
|
23
|
+
SigningPublicKey,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ratchetkit.algorithms.hash.base import HashAlgorithm
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class X25519(KemBackend):
|
|
31
|
+
name = "x25519"
|
|
32
|
+
supports_dh = True
|
|
33
|
+
|
|
34
|
+
def generate_keypair(self) -> KeyPair:
|
|
35
|
+
private = x25519.X25519PrivateKey.generate()
|
|
36
|
+
return KeyPair(
|
|
37
|
+
public=PublicKey(
|
|
38
|
+
private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw),
|
|
39
|
+
self.name,
|
|
40
|
+
),
|
|
41
|
+
private=PrivateKey(
|
|
42
|
+
private.private_bytes(
|
|
43
|
+
Encoding.Raw,
|
|
44
|
+
PrivateFormat.Raw,
|
|
45
|
+
NoEncryption(),
|
|
46
|
+
),
|
|
47
|
+
self.name,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def encapsulate(self, their_public: PublicKey) -> tuple[bytes, bytes]:
|
|
52
|
+
ephemeral = self.generate_keypair()
|
|
53
|
+
secret = self.dh(ephemeral.private, their_public)
|
|
54
|
+
return secret, ephemeral.public.data
|
|
55
|
+
|
|
56
|
+
def decapsulate(self, our_private: PrivateKey, ciphertext: bytes) -> bytes:
|
|
57
|
+
return self.dh(our_private, PublicKey(ciphertext, self.name))
|
|
58
|
+
|
|
59
|
+
def dh(self, our_private: PrivateKey, their_public: PublicKey) -> bytes:
|
|
60
|
+
try:
|
|
61
|
+
private = x25519.X25519PrivateKey.from_private_bytes(our_private.data)
|
|
62
|
+
public = x25519.X25519PublicKey.from_public_bytes(their_public.data)
|
|
63
|
+
shared = private.exchange(public)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
raise InvalidKey("X25519 DH failed (invalid or low-order public key)") from exc
|
|
66
|
+
if not any(shared):
|
|
67
|
+
raise InvalidKey("X25519 DH produced an all-zero shared secret")
|
|
68
|
+
return shared
|
|
69
|
+
|
|
70
|
+
def generate_signing_keypair(self) -> SigningKeyPair:
|
|
71
|
+
private = ed25519.Ed25519PrivateKey.generate()
|
|
72
|
+
return SigningKeyPair(
|
|
73
|
+
public=SigningPublicKey(
|
|
74
|
+
private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw),
|
|
75
|
+
self.name,
|
|
76
|
+
),
|
|
77
|
+
private=SigningPrivateKey(
|
|
78
|
+
private.private_bytes(
|
|
79
|
+
Encoding.Raw,
|
|
80
|
+
PrivateFormat.Raw,
|
|
81
|
+
NoEncryption(),
|
|
82
|
+
),
|
|
83
|
+
self.name,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def sign(
|
|
88
|
+
self,
|
|
89
|
+
private: SigningPrivateKey,
|
|
90
|
+
message: bytes,
|
|
91
|
+
digest: type[HashAlgorithm],
|
|
92
|
+
) -> bytes:
|
|
93
|
+
key = ed25519.Ed25519PrivateKey.from_private_bytes(private.data)
|
|
94
|
+
return key.sign(message)
|
|
95
|
+
|
|
96
|
+
def verify(
|
|
97
|
+
self,
|
|
98
|
+
public: SigningPublicKey,
|
|
99
|
+
message: bytes,
|
|
100
|
+
signature: bytes,
|
|
101
|
+
digest: type[HashAlgorithm],
|
|
102
|
+
) -> None:
|
|
103
|
+
key = ed25519.Ed25519PublicKey.from_public_bytes(public.data)
|
|
104
|
+
try:
|
|
105
|
+
key.verify(signature, message)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise InvalidSignature("Ed25519 verification failed") from exc
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""X448 DH-as-KEM + Ed448 signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import ed448, x448
|
|
8
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
9
|
+
Encoding,
|
|
10
|
+
NoEncryption,
|
|
11
|
+
PrivateFormat,
|
|
12
|
+
PublicFormat,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from ratchetkit.errors import InvalidKey, InvalidSignature
|
|
16
|
+
from ratchetkit.algorithms.kem.base import KemBackend
|
|
17
|
+
from ratchetkit.keys.keypair import (
|
|
18
|
+
KeyPair,
|
|
19
|
+
PrivateKey,
|
|
20
|
+
PublicKey,
|
|
21
|
+
SigningKeyPair,
|
|
22
|
+
SigningPrivateKey,
|
|
23
|
+
SigningPublicKey,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ratchetkit.algorithms.hash.base import HashAlgorithm
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class X448(KemBackend):
|
|
31
|
+
name = "x448"
|
|
32
|
+
supports_dh = True
|
|
33
|
+
|
|
34
|
+
def generate_keypair(self) -> KeyPair:
|
|
35
|
+
private = x448.X448PrivateKey.generate()
|
|
36
|
+
return KeyPair(
|
|
37
|
+
public=PublicKey(
|
|
38
|
+
private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw),
|
|
39
|
+
self.name,
|
|
40
|
+
),
|
|
41
|
+
private=PrivateKey(
|
|
42
|
+
private.private_bytes(
|
|
43
|
+
Encoding.Raw,
|
|
44
|
+
PrivateFormat.Raw,
|
|
45
|
+
NoEncryption(),
|
|
46
|
+
),
|
|
47
|
+
self.name,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def encapsulate(self, their_public: PublicKey) -> tuple[bytes, bytes]:
|
|
52
|
+
ephemeral = self.generate_keypair()
|
|
53
|
+
secret = self.dh(ephemeral.private, their_public)
|
|
54
|
+
return secret, ephemeral.public.data
|
|
55
|
+
|
|
56
|
+
def decapsulate(self, our_private: PrivateKey, ciphertext: bytes) -> bytes:
|
|
57
|
+
return self.dh(our_private, PublicKey(ciphertext, self.name))
|
|
58
|
+
|
|
59
|
+
def dh(self, our_private: PrivateKey, their_public: PublicKey) -> bytes:
|
|
60
|
+
try:
|
|
61
|
+
private = x448.X448PrivateKey.from_private_bytes(our_private.data)
|
|
62
|
+
public = x448.X448PublicKey.from_public_bytes(their_public.data)
|
|
63
|
+
shared = private.exchange(public)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
raise InvalidKey("X448 DH failed (invalid or low-order public key)") from exc
|
|
66
|
+
if not any(shared):
|
|
67
|
+
raise InvalidKey("X448 DH produced an all-zero shared secret")
|
|
68
|
+
return shared
|
|
69
|
+
|
|
70
|
+
def generate_signing_keypair(self) -> SigningKeyPair:
|
|
71
|
+
private = ed448.Ed448PrivateKey.generate()
|
|
72
|
+
return SigningKeyPair(
|
|
73
|
+
public=SigningPublicKey(
|
|
74
|
+
private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw),
|
|
75
|
+
self.name,
|
|
76
|
+
),
|
|
77
|
+
private=SigningPrivateKey(
|
|
78
|
+
private.private_bytes(
|
|
79
|
+
Encoding.Raw,
|
|
80
|
+
PrivateFormat.Raw,
|
|
81
|
+
NoEncryption(),
|
|
82
|
+
),
|
|
83
|
+
self.name,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def sign(
|
|
88
|
+
self,
|
|
89
|
+
private: SigningPrivateKey,
|
|
90
|
+
message: bytes,
|
|
91
|
+
digest: type[HashAlgorithm],
|
|
92
|
+
) -> bytes:
|
|
93
|
+
key = ed448.Ed448PrivateKey.from_private_bytes(private.data)
|
|
94
|
+
return key.sign(message)
|
|
95
|
+
|
|
96
|
+
def verify(
|
|
97
|
+
self,
|
|
98
|
+
public: SigningPublicKey,
|
|
99
|
+
message: bytes,
|
|
100
|
+
signature: bytes,
|
|
101
|
+
digest: type[HashAlgorithm],
|
|
102
|
+
) -> None:
|
|
103
|
+
key = ed448.Ed448PublicKey.from_public_bytes(public.data)
|
|
104
|
+
try:
|
|
105
|
+
key.verify(signature, message)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise InvalidSignature("Ed448 verification failed") from exc
|
ratchetkit/errors.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Protocol and cryptographic error types."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RatchetError(Exception):
|
|
5
|
+
"""Base error for ratchetkit."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidMessage(RatchetError):
|
|
9
|
+
"""Ciphertext is malformed, truncated, or fails authentication."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DuplicateMessage(RatchetError):
|
|
13
|
+
"""Message key was already consumed (replay or already-delivered)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, chain_index: int, counter: int):
|
|
16
|
+
self.chain_index = chain_index
|
|
17
|
+
self.counter = counter
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"duplicate message: chain_index={chain_index}, counter={counter}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TooFarInFuture(RatchetError):
|
|
24
|
+
"""Message counter exceeds the allowed forward-jump limit."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, chain_index: int, counter: int, limit: int):
|
|
27
|
+
self.chain_index = chain_index
|
|
28
|
+
self.counter = counter
|
|
29
|
+
self.limit = limit
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"message too far ahead: index={chain_index}, "
|
|
32
|
+
f"counter={counter}, limit={limit}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TooManySkippedKeys(RatchetError):
|
|
37
|
+
"""Skipped-message cache would exceed its hard size cap.
|
|
38
|
+
|
|
39
|
+
Raising (instead of silently dropping keys) avoids permanent loss of
|
|
40
|
+
delayed messages and matches Double Ratchet §3.1 / §8.4 guidance that
|
|
41
|
+
MKSKIPPED must bound stored elements.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, size: int, limit: int):
|
|
45
|
+
self.size = size
|
|
46
|
+
self.limit = limit
|
|
47
|
+
super().__init__(
|
|
48
|
+
f"too many skipped message keys: size={size}, limit={limit}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidKey(RatchetError):
|
|
53
|
+
"""Key material is missing, wrong length, or fails verification."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InvalidSignature(RatchetError):
|
|
57
|
+
"""Signed prekey or initiator signature failed verification."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SerializationError(RatchetError):
|
|
61
|
+
"""Blob is truncated, version-mismatched, or otherwise unreadable."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""X3DH and PQXDH key agreement."""
|
|
2
|
+
|
|
3
|
+
from ratchetkit.handshake.message import InitialMessage
|
|
4
|
+
from ratchetkit.handshake.pqxdh import PostQuantumExtendedDiffieHellman
|
|
5
|
+
from ratchetkit.handshake.x3dh import (
|
|
6
|
+
ExtendedTripleDiffieHellman,
|
|
7
|
+
ExtendedTripleDiffieHellmanResult,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"InitialMessage",
|
|
12
|
+
"ExtendedTripleDiffieHellman",
|
|
13
|
+
"ExtendedTripleDiffieHellmanResult",
|
|
14
|
+
"PostQuantumExtendedDiffieHellman",
|
|
15
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Initial handshake message carried from initiator to responder (X3DH / PQXDH)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ratchetkit.errors import SerializationError
|
|
9
|
+
from ratchetkit.keys.keypair import PublicKey, SigningPublicKey
|
|
10
|
+
from ratchetkit.packing import (
|
|
11
|
+
ConstructorId,
|
|
12
|
+
expect_constructor,
|
|
13
|
+
pack_constructor,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class InitialMessage:
|
|
19
|
+
"""First message of an X3DH / PQXDH handshake (public half only)."""
|
|
20
|
+
|
|
21
|
+
identity_signing: SigningPublicKey
|
|
22
|
+
identity_agreement: PublicKey | None
|
|
23
|
+
ephemeral_public: PublicKey
|
|
24
|
+
# For KEM-only suites: encapsulations to SPK / OPK (optional trailing).
|
|
25
|
+
kem_ciphertexts: tuple[bytes, ...]
|
|
26
|
+
signed_prekey_id: int
|
|
27
|
+
one_time_prekey_id: int | None
|
|
28
|
+
signature: bytes
|
|
29
|
+
# Optional first ratchet public used to seed the Double Ratchet.
|
|
30
|
+
ratchet_public: bytes | None = None
|
|
31
|
+
# PQXDH: encapsulation to the peer's signed PQ prekey.
|
|
32
|
+
pq_prekey_id: int | None = None
|
|
33
|
+
pq_ciphertext: bytes | None = None
|
|
34
|
+
|
|
35
|
+
CONSTRUCTOR_ID = ConstructorId.INITIAL_MESSAGE
|
|
36
|
+
|
|
37
|
+
def to_bytes(self) -> bytes:
|
|
38
|
+
parts = [
|
|
39
|
+
pack_constructor(self.CONSTRUCTOR_ID),
|
|
40
|
+
self.encode_field(self.identity_signing.data),
|
|
41
|
+
self.encode_field(self.identity_signing.algorithm.encode("ascii")),
|
|
42
|
+
]
|
|
43
|
+
if self.identity_agreement is not None:
|
|
44
|
+
parts.extend(
|
|
45
|
+
[
|
|
46
|
+
b"\x01",
|
|
47
|
+
self.encode_field(self.identity_agreement.data),
|
|
48
|
+
self.encode_field(
|
|
49
|
+
self.identity_agreement.algorithm.encode("ascii")
|
|
50
|
+
),
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
parts.append(b"\x00")
|
|
55
|
+
parts.extend(
|
|
56
|
+
[
|
|
57
|
+
self.encode_field(self.ephemeral_public.data),
|
|
58
|
+
self.encode_field(self.ephemeral_public.algorithm.encode("ascii")),
|
|
59
|
+
bytes([len(self.kem_ciphertexts)]),
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
for ct in self.kem_ciphertexts:
|
|
63
|
+
parts.append(self.encode_field(ct))
|
|
64
|
+
parts.append(struct.pack(">I", self.signed_prekey_id))
|
|
65
|
+
if self.one_time_prekey_id is None:
|
|
66
|
+
parts.append(b"\x00")
|
|
67
|
+
else:
|
|
68
|
+
parts.extend([b"\x01", struct.pack(">I", self.one_time_prekey_id)])
|
|
69
|
+
parts.append(self.encode_field(self.signature))
|
|
70
|
+
if self.ratchet_public is None:
|
|
71
|
+
parts.append(b"\x00")
|
|
72
|
+
else:
|
|
73
|
+
parts.extend([b"\x01", self.encode_field(self.ratchet_public)])
|
|
74
|
+
if self.pq_ciphertext is None:
|
|
75
|
+
parts.append(b"\x00")
|
|
76
|
+
else:
|
|
77
|
+
parts.extend(
|
|
78
|
+
[
|
|
79
|
+
b"\x01",
|
|
80
|
+
struct.pack(">I", self.pq_prekey_id or 0),
|
|
81
|
+
self.encode_field(self.pq_ciphertext),
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
return b"".join(parts)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_bytes(cls, data: bytes) -> InitialMessage:
|
|
88
|
+
view = memoryview(data)
|
|
89
|
+
if not view:
|
|
90
|
+
raise SerializationError("empty initial message")
|
|
91
|
+
offset = expect_constructor(view, cls.CONSTRUCTOR_ID)
|
|
92
|
+
|
|
93
|
+
identity_data, offset = cls.read_field(view, offset)
|
|
94
|
+
identity_alg, offset = cls.read_field(view, offset)
|
|
95
|
+
identity_alg = identity_alg.decode("ascii")
|
|
96
|
+
has_agreement = view[offset]
|
|
97
|
+
offset += 1
|
|
98
|
+
identity_agreement = None
|
|
99
|
+
if has_agreement:
|
|
100
|
+
agreement_data, offset = cls.read_field(view, offset)
|
|
101
|
+
agreement_alg, offset = cls.read_field(view, offset)
|
|
102
|
+
identity_agreement = PublicKey(
|
|
103
|
+
agreement_data,
|
|
104
|
+
agreement_alg.decode("ascii"),
|
|
105
|
+
)
|
|
106
|
+
ephemeral_data, offset = cls.read_field(view, offset)
|
|
107
|
+
ephemeral_alg, offset = cls.read_field(view, offset)
|
|
108
|
+
ephemeral_alg = ephemeral_alg.decode("ascii")
|
|
109
|
+
ct_count = view[offset]
|
|
110
|
+
offset += 1
|
|
111
|
+
ciphertexts = []
|
|
112
|
+
for index in range(ct_count):
|
|
113
|
+
ct, offset = cls.read_field(view, offset)
|
|
114
|
+
ciphertexts.append(ct)
|
|
115
|
+
if offset + 4 > len(view):
|
|
116
|
+
raise SerializationError("truncated signed prekey id")
|
|
117
|
+
signed_prekey_id = struct.unpack_from(">I", view, offset)[0]
|
|
118
|
+
offset += 4
|
|
119
|
+
has_opk = view[offset]
|
|
120
|
+
offset += 1
|
|
121
|
+
one_time_prekey_id = None
|
|
122
|
+
if has_opk:
|
|
123
|
+
if offset + 4 > len(view):
|
|
124
|
+
raise SerializationError("truncated one-time prekey id")
|
|
125
|
+
one_time_prekey_id = struct.unpack_from(">I", view, offset)[0]
|
|
126
|
+
offset += 4
|
|
127
|
+
signature, offset = cls.read_field(view, offset)
|
|
128
|
+
if offset >= len(view):
|
|
129
|
+
raise SerializationError("truncated initial message ratchet flag")
|
|
130
|
+
has_ratchet = view[offset]
|
|
131
|
+
offset += 1
|
|
132
|
+
ratchet_public = None
|
|
133
|
+
if has_ratchet:
|
|
134
|
+
ratchet_public, offset = cls.read_field(view, offset)
|
|
135
|
+
pq_prekey_id = None
|
|
136
|
+
pq_ciphertext = None
|
|
137
|
+
# Older initial messages omit the PQ flag entirely.
|
|
138
|
+
if offset < len(view):
|
|
139
|
+
has_pq = view[offset]
|
|
140
|
+
offset += 1
|
|
141
|
+
if has_pq:
|
|
142
|
+
if offset + 4 > len(view):
|
|
143
|
+
raise SerializationError("truncated PQ prekey id")
|
|
144
|
+
pq_prekey_id = struct.unpack_from(">I", view, offset)[0]
|
|
145
|
+
offset += 4
|
|
146
|
+
pq_ciphertext, offset = cls.read_field(view, offset)
|
|
147
|
+
if offset != len(view):
|
|
148
|
+
raise SerializationError("trailing bytes in initial message")
|
|
149
|
+
return cls(
|
|
150
|
+
identity_signing=SigningPublicKey(identity_data, identity_alg),
|
|
151
|
+
identity_agreement=identity_agreement,
|
|
152
|
+
ephemeral_public=PublicKey(ephemeral_data, ephemeral_alg),
|
|
153
|
+
kem_ciphertexts=tuple(ciphertexts),
|
|
154
|
+
signed_prekey_id=signed_prekey_id,
|
|
155
|
+
one_time_prekey_id=one_time_prekey_id,
|
|
156
|
+
signature=signature,
|
|
157
|
+
ratchet_public=ratchet_public,
|
|
158
|
+
pq_prekey_id=pq_prekey_id,
|
|
159
|
+
pq_ciphertext=pq_ciphertext,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def encode_field(value: bytes) -> bytes:
|
|
164
|
+
if len(value) > 0xFFFF:
|
|
165
|
+
raise SerializationError("field too large")
|
|
166
|
+
return struct.pack(">H", len(value)) + value
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def read_field(view: memoryview, offset: int) -> tuple[bytes, int]:
|
|
170
|
+
if offset + 2 > len(view):
|
|
171
|
+
raise SerializationError("truncated initial message field")
|
|
172
|
+
length = struct.unpack_from(">H", view, offset)[0]
|
|
173
|
+
offset += 2
|
|
174
|
+
if offset + length > len(view):
|
|
175
|
+
raise SerializationError("truncated initial message field body")
|
|
176
|
+
value = bytes(view[offset:offset + length])
|
|
177
|
+
return value, offset + length
|