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 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
+
@@ -0,0 +1,5 @@
1
+ """Client-side cryptographic execution matrix."""
2
+
3
+ from cyphra.crypto.engine import CryptoEngine
4
+
5
+ __all__ = ["CryptoEngine"]
@@ -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
+