cyphra 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.
- cyphra/__init__.py +15 -0
- cyphra/cli.py +238 -0
- cyphra/client.py +160 -0
- cyphra/constants.py +15 -0
- cyphra/crypto/__init__.py +5 -0
- cyphra/crypto/backend.py +98 -0
- cyphra/crypto/engine.py +349 -0
- cyphra/crypto_engine.py +5 -0
- cyphra/database_manager.py +5 -0
- cyphra/exceptions.py +50 -0
- cyphra/models.py +310 -0
- cyphra/network/__init__.py +14 -0
- cyphra/network/client.py +219 -0
- cyphra/network/http_client.py +119 -0
- cyphra/network/protocol.py +65 -0
- cyphra/network/websocket_broker.py +265 -0
- cyphra/storage/__init__.py +5 -0
- cyphra/storage/database.py +363 -0
- cyphra/websocket_broker.py +5 -0
- cyphra-0.1.0.dist-info/METADATA +156 -0
- cyphra-0.1.0.dist-info/RECORD +24 -0
- cyphra-0.1.0.dist-info/WHEEL +5 -0
- cyphra-0.1.0.dist-info/entry_points.txt +3 -0
- cyphra-0.1.0.dist-info/top_level.txt +1 -0
cyphra/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Cyphra: a hybrid post-quantum, zero-knowledge messaging platform."""
|
|
2
|
+
|
|
3
|
+
from cyphra.crypto.engine import CryptoEngine
|
|
4
|
+
from cyphra.client import CyphraClient
|
|
5
|
+
from cyphra.models import EncryptedEnvelope, PreKeyBundle, PrivateIdentityMaterial
|
|
6
|
+
from cyphra.storage.database import DatabaseManager
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CryptoEngine",
|
|
10
|
+
"CyphraClient",
|
|
11
|
+
"DatabaseManager",
|
|
12
|
+
"EncryptedEnvelope",
|
|
13
|
+
"PreKeyBundle",
|
|
14
|
+
"PrivateIdentityMaterial",
|
|
15
|
+
]
|
cyphra/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Operational entry point for the relay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import getpass
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import ssl
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cyphra.client import CyphraClient
|
|
16
|
+
from cyphra.constants import DEFAULT_BROKER_URL
|
|
17
|
+
from cyphra.crypto.engine import CryptoEngine
|
|
18
|
+
from cyphra.exceptions import BrokerError, ConfigurationError, CyphraError
|
|
19
|
+
from cyphra.network.client import BrokerTransport
|
|
20
|
+
from cyphra.network.http_client import HttpBrokerClient
|
|
21
|
+
from cyphra.network.websocket_broker import WebSocketBroker
|
|
22
|
+
from cyphra.storage.database import DatabaseManager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def server_main(argv: list[str] | None = None) -> None:
|
|
26
|
+
parser = argparse.ArgumentParser(description="Run the Cyphra zero-knowledge relay")
|
|
27
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
28
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
29
|
+
parser.add_argument("--cert", help="PEM server certificate")
|
|
30
|
+
parser.add_argument("--key", dest="key_file", help="PEM private key")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--insecure-dev",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="allow ws:// for local development; never use this in production",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
logging.basicConfig(level=getattr(logging, args.log_level))
|
|
39
|
+
|
|
40
|
+
if bool(args.cert) != bool(args.key_file):
|
|
41
|
+
parser.error("--cert and --key must be supplied together")
|
|
42
|
+
if not args.insecure_dev and not args.cert:
|
|
43
|
+
parser.error("TLS is required; provide --cert/--key or explicitly pass --insecure-dev")
|
|
44
|
+
|
|
45
|
+
tls_context: ssl.SSLContext | None = None
|
|
46
|
+
if args.cert:
|
|
47
|
+
tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
48
|
+
tls_context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
49
|
+
tls_context.load_cert_chain(args.cert, args.key_file)
|
|
50
|
+
|
|
51
|
+
broker = WebSocketBroker(
|
|
52
|
+
args.host,
|
|
53
|
+
args.port,
|
|
54
|
+
ssl_context=tls_context,
|
|
55
|
+
allow_insecure_dev=args.insecure_dev,
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
asyncio.run(broker.serve_forever())
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> None:
|
|
64
|
+
"""Interactive two-user terminal chat entry point."""
|
|
65
|
+
|
|
66
|
+
parser = argparse.ArgumentParser(prog="cyphra", description="Cyphra encrypted terminal chat")
|
|
67
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
68
|
+
chat_parser = subparsers.add_parser("chat", help="join a two-person realtime chat")
|
|
69
|
+
chat_parser.add_argument("--name", required=True, help="local display name and default database name")
|
|
70
|
+
chat_parser.add_argument("--db", type=Path, help="encrypted identity database path")
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
asyncio.run(_chat(args))
|
|
75
|
+
except KeyboardInterrupt:
|
|
76
|
+
print("\nChat ended.")
|
|
77
|
+
except EOFError:
|
|
78
|
+
print("\nChat ended.")
|
|
79
|
+
except (CyphraError, OSError, ValueError) as exc:
|
|
80
|
+
print(f"cyphra: {exc}", file=sys.stderr)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _chat(args: argparse.Namespace) -> None:
|
|
84
|
+
name = args.name.strip() or "user"
|
|
85
|
+
safe_name = re.sub(r"[^A-Za-z0-9_-]+", "_", name).strip("_") or "user"
|
|
86
|
+
database_path = args.db or (Path.home() / ".cyphra" / f"{safe_name}.db")
|
|
87
|
+
passphrase = _prompt_database_passphrase(database_path)
|
|
88
|
+
database = DatabaseManager.from_passphrase(database_path, passphrase)
|
|
89
|
+
relay: WebSocketBroker | None = None
|
|
90
|
+
client: CyphraClient | None = None
|
|
91
|
+
receiver_task: asyncio.Task[None] | None = None
|
|
92
|
+
try:
|
|
93
|
+
engine = CryptoEngine()
|
|
94
|
+
try:
|
|
95
|
+
material = database.load_identity()
|
|
96
|
+
except ConfigurationError as exc:
|
|
97
|
+
if str(exc) != "no matching local identity is stored":
|
|
98
|
+
raise
|
|
99
|
+
CyphraClient.provision_identity(engine, database)
|
|
100
|
+
material = database.load_identity()
|
|
101
|
+
|
|
102
|
+
transport = HttpBrokerClient(DEFAULT_BROKER_URL)
|
|
103
|
+
client = CyphraClient(engine, database, transport)
|
|
104
|
+
await client.connect()
|
|
105
|
+
own_fingerprint = engine.identity_fingerprint(material.bundle)
|
|
106
|
+
print(f"Connected as {name}")
|
|
107
|
+
print(f"Your identity ID: {material.identity_id}")
|
|
108
|
+
print(f"Your fingerprint: {own_fingerprint}")
|
|
109
|
+
print("Connected people:")
|
|
110
|
+
|
|
111
|
+
peer_id = await _select_peer(transport, material.identity_id)
|
|
112
|
+
if peer_id is None:
|
|
113
|
+
return
|
|
114
|
+
peer_bundle, fingerprint = await client.discover_contact(peer_id)
|
|
115
|
+
print(f"Peer identity ID: {peer_id}")
|
|
116
|
+
print(f"Peer fingerprint: {fingerprint}")
|
|
117
|
+
print("Compare this fingerprint with your peer through a trusted channel before approving.")
|
|
118
|
+
try:
|
|
119
|
+
approval = await asyncio.to_thread(input, "Approve this peer? Type yes to continue: ")
|
|
120
|
+
except EOFError:
|
|
121
|
+
print("\nChat closed before peer approval.")
|
|
122
|
+
return
|
|
123
|
+
if approval.strip().lower() != "yes":
|
|
124
|
+
print("Peer was not approved. Chat closed.")
|
|
125
|
+
return
|
|
126
|
+
client.approve_contact(peer_bundle, fingerprint)
|
|
127
|
+
|
|
128
|
+
conversation_id = hashlib.sha256(
|
|
129
|
+
"\0".join(sorted((material.identity_id, peer_id))).encode("ascii")
|
|
130
|
+
).hexdigest()
|
|
131
|
+
print(f"Chat with {peer_id[:12]} is ready. Type /quit to leave.")
|
|
132
|
+
receiver_task = asyncio.create_task(
|
|
133
|
+
_receive_chat_messages(client, peer_id),
|
|
134
|
+
name="cyphra-chat-receiver",
|
|
135
|
+
)
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
line = await asyncio.to_thread(input, f"{name}> ")
|
|
139
|
+
except EOFError:
|
|
140
|
+
break
|
|
141
|
+
if line.strip() == "/quit":
|
|
142
|
+
break
|
|
143
|
+
if not line:
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
await client.send_message(peer_id, line.encode("utf-8"), conversation_id=conversation_id)
|
|
147
|
+
print(f"you> {line}")
|
|
148
|
+
except (CyphraError, ValueError) as exc:
|
|
149
|
+
print(f"Message not sent: {exc}")
|
|
150
|
+
finally:
|
|
151
|
+
if receiver_task is not None:
|
|
152
|
+
receiver_task.cancel()
|
|
153
|
+
try:
|
|
154
|
+
await receiver_task
|
|
155
|
+
except asyncio.CancelledError:
|
|
156
|
+
pass
|
|
157
|
+
if client is not None:
|
|
158
|
+
await client.close()
|
|
159
|
+
database.close()
|
|
160
|
+
if relay is not None:
|
|
161
|
+
await relay.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _select_peer(
|
|
165
|
+
transport: BrokerTransport,
|
|
166
|
+
own_identity_id: str,
|
|
167
|
+
) -> str | None:
|
|
168
|
+
while True:
|
|
169
|
+
peers = await transport.list_peers()
|
|
170
|
+
peers = tuple(peer_id for peer_id in peers if peer_id != own_identity_id)
|
|
171
|
+
print("\nConnected people")
|
|
172
|
+
print("----------------")
|
|
173
|
+
if not peers:
|
|
174
|
+
print("No one is connected yet.")
|
|
175
|
+
choice = await asyncio.to_thread(input, "[r]efresh or [q]uit: ")
|
|
176
|
+
if choice.strip().lower() == "q":
|
|
177
|
+
return None
|
|
178
|
+
continue
|
|
179
|
+
for index, peer_id in enumerate(peers, start=1):
|
|
180
|
+
print(f"{index}. {peer_id}")
|
|
181
|
+
choice = await asyncio.to_thread(input, "Select a person, [r]efresh, or [q]uit: ")
|
|
182
|
+
normalized = choice.strip().lower()
|
|
183
|
+
if normalized == "q":
|
|
184
|
+
return None
|
|
185
|
+
if normalized == "r" or not normalized:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
selected = peers[int(normalized) - 1]
|
|
189
|
+
except (ValueError, IndexError):
|
|
190
|
+
print("Choose a listed number, r, or q.")
|
|
191
|
+
continue
|
|
192
|
+
return selected
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _receive_chat_messages(client: CyphraClient, peer_id: str) -> None:
|
|
196
|
+
while True:
|
|
197
|
+
try:
|
|
198
|
+
received = await client.receive_message()
|
|
199
|
+
except asyncio.CancelledError:
|
|
200
|
+
raise
|
|
201
|
+
except BrokerError as exc:
|
|
202
|
+
print(f"\nRelay connection ended: {exc}")
|
|
203
|
+
return
|
|
204
|
+
except CyphraError as exc:
|
|
205
|
+
print(f"\nCould not accept a message: {exc}")
|
|
206
|
+
continue
|
|
207
|
+
message = received.plaintext.decode("utf-8", errors="replace")
|
|
208
|
+
print(f"\n{peer_id[:12]}> {message}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _prompt_database_passphrase(path: Path) -> str:
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
is_existing = path.exists()
|
|
214
|
+
while True:
|
|
215
|
+
prompt = "Database passphrase: " if is_existing else "Create a database passphrase (12+ characters): "
|
|
216
|
+
passphrase = getpass.getpass(prompt)
|
|
217
|
+
if len(passphrase.encode("utf-8")) < 12:
|
|
218
|
+
print("Use at least 12 characters.")
|
|
219
|
+
continue
|
|
220
|
+
if is_existing:
|
|
221
|
+
return passphrase
|
|
222
|
+
confirmation = getpass.getpass("Confirm passphrase: ")
|
|
223
|
+
if passphrase == confirmation:
|
|
224
|
+
return passphrase
|
|
225
|
+
print("Passphrases did not match.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_loopback_host(host: str | None) -> bool:
|
|
229
|
+
if host is None:
|
|
230
|
+
return False
|
|
231
|
+
if host.lower() == "localhost":
|
|
232
|
+
return True
|
|
233
|
+
try:
|
|
234
|
+
import ipaddress
|
|
235
|
+
|
|
236
|
+
return ipaddress.ip_address(host).is_loopback
|
|
237
|
+
except ValueError:
|
|
238
|
+
return False
|
cyphra/client.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""High-level client composition root.
|
|
2
|
+
|
|
3
|
+
This is the only layer that composes the three independent boundaries:
|
|
4
|
+
``CryptoEngine`` (client-only secrets), ``DatabaseManager`` (encrypted local
|
|
5
|
+
state), and ``BrokerTransport`` (opaque network frames).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from cyphra.crypto.engine import CryptoEngine
|
|
14
|
+
from cyphra.exceptions import (
|
|
15
|
+
EnvelopeError,
|
|
16
|
+
ReplayDetectedError,
|
|
17
|
+
SignatureVerificationError,
|
|
18
|
+
TrustRequiredError,
|
|
19
|
+
)
|
|
20
|
+
from cyphra.models import EncryptedEnvelope, PreKeyBundle
|
|
21
|
+
from cyphra.network.client import BrokerTransport
|
|
22
|
+
from cyphra.network.protocol import RelayPacket
|
|
23
|
+
from cyphra.storage.database import DatabaseManager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ReceivedMessage:
|
|
28
|
+
"""A decrypted message returned to application code; never persisted."""
|
|
29
|
+
|
|
30
|
+
envelope: EncryptedEnvelope
|
|
31
|
+
plaintext: bytes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CyphraClient:
|
|
35
|
+
"""Compose crypto, storage, and transport without leaking plaintext."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
engine: CryptoEngine,
|
|
40
|
+
database: DatabaseManager,
|
|
41
|
+
transport: BrokerTransport,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.engine = engine
|
|
44
|
+
self.database = database
|
|
45
|
+
self.transport = transport
|
|
46
|
+
self._pending_packets: deque[RelayPacket] = deque()
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def provision_identity(cls, engine: CryptoEngine, database: DatabaseManager) -> bytes:
|
|
50
|
+
"""Generate and store a new local identity; return its public bundle."""
|
|
51
|
+
|
|
52
|
+
material = engine.generate_identity()
|
|
53
|
+
database.save_identity(material)
|
|
54
|
+
return material.bundle.to_bytes()
|
|
55
|
+
|
|
56
|
+
async def connect(self) -> None:
|
|
57
|
+
material = self.database.load_identity()
|
|
58
|
+
self.engine.validate_identity(material)
|
|
59
|
+
await self.transport.connect(material.identity_id, material.bundle.to_bytes())
|
|
60
|
+
|
|
61
|
+
async def discover_contact(self, identity_id: str) -> tuple[PreKeyBundle, str]:
|
|
62
|
+
"""Fetch a public bundle and return it with a fingerprint for approval."""
|
|
63
|
+
|
|
64
|
+
raw = await self.transport.get_bundle(identity_id)
|
|
65
|
+
bundle = PreKeyBundle.from_bytes(raw)
|
|
66
|
+
self.engine.verify_bundle(bundle)
|
|
67
|
+
if bundle.identity_id != identity_id:
|
|
68
|
+
raise SignatureVerificationError("directory returned a different identity")
|
|
69
|
+
return bundle, self.engine.identity_fingerprint(bundle)
|
|
70
|
+
|
|
71
|
+
def approve_contact(self, bundle: PreKeyBundle, fingerprint: str) -> None:
|
|
72
|
+
"""Persist an explicitly verified contact binding.
|
|
73
|
+
|
|
74
|
+
The fingerprint should have been compared out of band (for example by
|
|
75
|
+
scanning a QR code or reading a verified channel). The relay is not a
|
|
76
|
+
trust anchor.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
self.engine.verify_bundle(bundle)
|
|
80
|
+
expected = self.engine.identity_fingerprint(bundle)
|
|
81
|
+
if fingerprint != expected:
|
|
82
|
+
raise SignatureVerificationError("contact fingerprint does not match the bundle")
|
|
83
|
+
self.database.save_trusted_contact(bundle, fingerprint)
|
|
84
|
+
|
|
85
|
+
async def send_message(
|
|
86
|
+
self,
|
|
87
|
+
recipient_id: str,
|
|
88
|
+
plaintext: bytes,
|
|
89
|
+
*,
|
|
90
|
+
conversation_id: str,
|
|
91
|
+
recipient_bundle: PreKeyBundle | None = None,
|
|
92
|
+
) -> EncryptedEnvelope:
|
|
93
|
+
"""Encrypt locally, persist only ciphertext, and relay the opaque bytes."""
|
|
94
|
+
|
|
95
|
+
if recipient_bundle is None:
|
|
96
|
+
recipient_bundle = self.database.load_trusted_contact(recipient_id)
|
|
97
|
+
if recipient_bundle is None:
|
|
98
|
+
discovered, fingerprint = await self.discover_contact(recipient_id)
|
|
99
|
+
raise TrustRequiredError(
|
|
100
|
+
f"contact is not approved; verify fingerprint {fingerprint} and call approve_contact"
|
|
101
|
+
)
|
|
102
|
+
if recipient_bundle.identity_id != recipient_id:
|
|
103
|
+
raise SignatureVerificationError("recipient bundle identity mismatch")
|
|
104
|
+
self.engine.verify_bundle(recipient_bundle)
|
|
105
|
+
sender = self.database.load_identity()
|
|
106
|
+
envelope = self.engine.encrypt_message(
|
|
107
|
+
sender,
|
|
108
|
+
recipient_bundle,
|
|
109
|
+
plaintext,
|
|
110
|
+
conversation_id=conversation_id,
|
|
111
|
+
)
|
|
112
|
+
self.database.save_envelope(envelope, direction="outgoing", delivered=False)
|
|
113
|
+
await self.transport.send(
|
|
114
|
+
RelayPacket(
|
|
115
|
+
message_id=envelope.message_id,
|
|
116
|
+
sender_id=envelope.sender_id,
|
|
117
|
+
recipient_id=envelope.recipient_id,
|
|
118
|
+
payload=envelope.to_bytes(),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
self.database.mark_delivered(envelope.message_id)
|
|
122
|
+
return envelope
|
|
123
|
+
|
|
124
|
+
async def receive_message(self) -> ReceivedMessage:
|
|
125
|
+
"""Receive, authenticate, decrypt, and return one message in memory."""
|
|
126
|
+
|
|
127
|
+
packet = self._pending_packets.popleft() if self._pending_packets else await self.transport.receive()
|
|
128
|
+
try:
|
|
129
|
+
envelope = EncryptedEnvelope.from_bytes(packet.payload)
|
|
130
|
+
except EnvelopeError:
|
|
131
|
+
raise
|
|
132
|
+
if (
|
|
133
|
+
envelope.message_id != packet.message_id
|
|
134
|
+
or envelope.sender_id != packet.sender_id
|
|
135
|
+
or envelope.recipient_id != packet.recipient_id
|
|
136
|
+
):
|
|
137
|
+
raise EnvelopeError("relay metadata does not match the encrypted envelope")
|
|
138
|
+
if self.database.has_envelope(envelope.message_id):
|
|
139
|
+
raise ReplayDetectedError("message identifier has already been accepted")
|
|
140
|
+
|
|
141
|
+
trusted_sender = self.database.load_trusted_contact(envelope.sender_id)
|
|
142
|
+
if trusted_sender is None:
|
|
143
|
+
self._pending_packets.appendleft(packet)
|
|
144
|
+
_bundle, fingerprint = await self.discover_contact(envelope.sender_id)
|
|
145
|
+
raise TrustRequiredError(
|
|
146
|
+
f"sender is not approved; verify fingerprint {fingerprint} and call approve_contact"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
recipient = self.database.load_identity(envelope.recipient_key_id)
|
|
150
|
+
plaintext = self.engine.decrypt_message(
|
|
151
|
+
recipient,
|
|
152
|
+
envelope,
|
|
153
|
+
expected_sender=trusted_sender,
|
|
154
|
+
)
|
|
155
|
+
self.database.save_envelope(envelope, direction="incoming", delivered=True)
|
|
156
|
+
return ReceivedMessage(envelope=envelope, plaintext=plaintext)
|
|
157
|
+
|
|
158
|
+
async def close(self) -> None:
|
|
159
|
+
await self.transport.close()
|
|
160
|
+
|
cyphra/constants.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Protocol constants shared by the client and the relay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
PROTOCOL_VERSION = 1
|
|
6
|
+
DEFAULT_BROKER_URL = "https://post-q.vercel.app"
|
|
7
|
+
HYBRID_SUITE = "CYPHRA-HYBRID-X25519-MLKEM-768-AES-256-GCM"
|
|
8
|
+
MAX_MESSAGE_BYTES = 1_048_576
|
|
9
|
+
MAX_RELAY_FRAME_BYTES = 2_000_000
|
|
10
|
+
IDENTITY_ID_BYTES = 32
|
|
11
|
+
KEY_ID_BYTES = 16
|
|
12
|
+
NONCE_BYTES = 12
|
|
13
|
+
KDF_SALT_BYTES = 32
|
|
14
|
+
MESSAGE_KEY_BYTES = 32
|
|
15
|
+
|
cyphra/crypto/backend.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""NIST ML-KEM-768 backend adapters.
|
|
2
|
+
|
|
3
|
+
The protocol deliberately has no classical-only fallback. A deployment must
|
|
4
|
+
provide a vetted ML-KEM-768 implementation or fail closed at startup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from cyphra.exceptions import DependencyUnavailableError, InvalidKeyMaterial
|
|
13
|
+
|
|
14
|
+
MLKEM768_PUBLIC_KEY_SIZE: Final = 1184
|
|
15
|
+
MLKEM768_PRIVATE_KEY_SIZE: Final = 2400
|
|
16
|
+
MLKEM768_CIPHERTEXT_SIZE: Final = 1088
|
|
17
|
+
MLKEM768_SHARED_SECRET_SIZE: Final = 32
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MLKEM768Backend(ABC):
|
|
21
|
+
"""Small interface consumed by ``CryptoEngine`` only."""
|
|
22
|
+
|
|
23
|
+
algorithm = "ML-KEM-768"
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def generate_keypair(self) -> tuple[bytes, bytes]:
|
|
27
|
+
"""Return ``(public_key, private_key)`` in the NIST encoding."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def encapsulate(self, public_key: bytes) -> tuple[bytes, bytes]:
|
|
31
|
+
"""Return ``(ciphertext, shared_secret)``."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def decapsulate(self, private_key: bytes, ciphertext: bytes) -> bytes:
|
|
35
|
+
"""Return the shared secret or raise for malformed input."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PqcryptoMLKEM768Backend(MLKEM768Backend):
|
|
39
|
+
"""Adapter for the audited ``pqcrypto`` ML-KEM implementation."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
try:
|
|
43
|
+
from pqcrypto.kem import ml_kem_768
|
|
44
|
+
except ImportError as exc: # pragma: no cover - depends on deployment
|
|
45
|
+
raise DependencyUnavailableError(
|
|
46
|
+
"pqcrypto with ML-KEM-768 support is required; refusing to start without it"
|
|
47
|
+
) from exc
|
|
48
|
+
self._kem = ml_kem_768
|
|
49
|
+
|
|
50
|
+
def generate_keypair(self) -> tuple[bytes, bytes]:
|
|
51
|
+
try:
|
|
52
|
+
public_key, private_key = self._kem.keygen()
|
|
53
|
+
except Exception as exc: # vendor exceptions vary by binding
|
|
54
|
+
raise InvalidKeyMaterial("ML-KEM-768 key generation failed") from exc
|
|
55
|
+
self._validate_public(public_key)
|
|
56
|
+
self._validate_private(private_key)
|
|
57
|
+
return bytes(public_key), bytes(private_key)
|
|
58
|
+
|
|
59
|
+
def encapsulate(self, public_key: bytes) -> tuple[bytes, bytes]:
|
|
60
|
+
self._validate_public(public_key)
|
|
61
|
+
try:
|
|
62
|
+
ciphertext, shared_secret = self._kem.encaps(public_key)
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
raise InvalidKeyMaterial("ML-KEM-768 encapsulation failed") from exc
|
|
65
|
+
if len(ciphertext) != MLKEM768_CIPHERTEXT_SIZE:
|
|
66
|
+
raise InvalidKeyMaterial("ML-KEM-768 returned an invalid ciphertext")
|
|
67
|
+
if len(shared_secret) != MLKEM768_SHARED_SECRET_SIZE:
|
|
68
|
+
raise InvalidKeyMaterial("ML-KEM-768 returned an invalid shared secret")
|
|
69
|
+
return bytes(ciphertext), bytes(shared_secret)
|
|
70
|
+
|
|
71
|
+
def decapsulate(self, private_key: bytes, ciphertext: bytes) -> bytes:
|
|
72
|
+
self._validate_private(private_key)
|
|
73
|
+
if len(ciphertext) != MLKEM768_CIPHERTEXT_SIZE:
|
|
74
|
+
raise InvalidKeyMaterial("invalid ML-KEM-768 ciphertext length")
|
|
75
|
+
try:
|
|
76
|
+
shared_secret = self._kem.decaps(private_key, ciphertext)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise InvalidKeyMaterial("ML-KEM-768 decapsulation failed") from exc
|
|
79
|
+
if len(shared_secret) != MLKEM768_SHARED_SECRET_SIZE:
|
|
80
|
+
raise InvalidKeyMaterial("ML-KEM-768 returned an invalid shared secret")
|
|
81
|
+
return bytes(shared_secret)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _validate_public(public_key: bytes) -> None:
|
|
85
|
+
if not isinstance(public_key, bytes) or len(public_key) != MLKEM768_PUBLIC_KEY_SIZE:
|
|
86
|
+
raise InvalidKeyMaterial("ML-KEM-768 public key must be 1184 bytes")
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _validate_private(private_key: bytes) -> None:
|
|
90
|
+
if not isinstance(private_key, bytes) or len(private_key) != MLKEM768_PRIVATE_KEY_SIZE:
|
|
91
|
+
raise InvalidKeyMaterial("ML-KEM-768 private key must be 2400 bytes")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_mlkem_backend() -> MLKEM768Backend:
|
|
95
|
+
"""Construct the mandatory ML-KEM-768 implementation."""
|
|
96
|
+
|
|
97
|
+
return PqcryptoMLKEM768Backend()
|
|
98
|
+
|