cyphra 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.
cyphra-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyphra
3
+ Version: 0.1.0
4
+ Summary: Zero-knowledge hybrid E2EE messaging reference platform
5
+ Maintainer: Anupam1707, asmitbaldi
6
+ Project-URL: Homepage, https://github.com/Anupam1707/PostQ
7
+ Project-URL: Repository, https://github.com/Anupam1707/PostQ
8
+ Project-URL: Issues, https://github.com/Anupam1707/PostQ/issues
9
+ Keywords: messaging,end-to-end-encryption,post-quantum,ml-kem,sqlcipher
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications :: Chat
17
+ Classifier: Topic :: Security :: Cryptography
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: argon2-cffi>=23.1.0
21
+ Requires-Dist: cryptography>=42.0.0
22
+ Requires-Dist: pqcrypto>=1.0.0
23
+ Requires-Dist: sqlcipher3>=0.6.2
24
+ Requires-Dist: websockets<17.0,>=14.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: build>=1.2.0; extra == "dev"
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
29
+ Requires-Dist: twine>=5.0; extra == "dev"
30
+
31
+ # Cyphra
32
+
33
+ Cyphra is a Python reference implementation of a zero-knowledge, end-to-end encrypted messaging platform. It supports two modes:
34
+
35
+ 1. A standalone terminal messaging client.
36
+ 2. An importable Python SDK for developer applications.
37
+
38
+ Maintainers: `Anupam1707` and `asmitbaldi`.
39
+
40
+ The mandatory hybrid construction is:
41
+
42
+ `X25519 ephemeral DH || ML-KEM-768 encapsulation -> HKDF-SHA384 -> AES-256-GCM`
43
+
44
+ Every message carries an Ed25519 signature over its canonical envelope.
45
+
46
+ ## Architecture
47
+
48
+ ```text
49
+ untrusted network
50
+ ┌───────────────┐ opaque frames ┌────────────────┐
51
+ │ CyphraClient │◄─────────────────►│ WebSocketBroker│
52
+ └──────┬────────┘ └────────────────┘
53
+ │ composes
54
+ ┌──────▼────────┐ ciphertext-only local state
55
+ │ CryptoEngine │◄──────────────────────────────┐
56
+ └───────────────┘ │
57
+ ┌────────────▼─────────┐
58
+ │ DatabaseManager │
59
+ │ SQLCipher │
60
+ └──────────────────────┘
61
+ ```
62
+
63
+ - `CryptoEngine` owns identity generation, bundle verification, hybrid encryption, signatures, and decryption.
64
+ - `DatabaseManager` owns SQLCipher persistence and stores private identity material, trusted contacts, and encrypted envelopes only.
65
+ - `CyphraClient` composes cryptography, storage, and transport.
66
+ - The fixed Vercel HTTP relay routes public bundles and opaque ciphertext without receiving plaintext or private keys.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ python3 -m venv .venv
72
+ . .venv/bin/activate
73
+ python3 -m pip install -e '.[dev]'
74
+ ```
75
+
76
+ ## Standalone Client
77
+
78
+ The client uses the fixed deployed relay internally. Users do not select a broker URL.
79
+
80
+ ```bash
81
+ cyphra chat --name Alice
82
+ cyphra chat --name Bob
83
+ ```
84
+
85
+ The terminal UI shows connected people, supports `r` to refresh and `q` to quit, and asks the user to approve the peer fingerprint before chatting. Type `/quit` to leave.
86
+
87
+ Each identity uses its own encrypted database under `~/.cyphra/`; use `--db` for a custom path.
88
+
89
+ ## SDK Mode
90
+
91
+ Developer applications can import Cyphra components directly:
92
+
93
+ ```python
94
+ from cyphra import CyphraClient, CryptoEngine, DatabaseManager
95
+ from cyphra.network.http_client import HttpBrokerClient
96
+
97
+ engine = CryptoEngine()
98
+ database = DatabaseManager.from_passphrase("alice.db", "a long local passphrase")
99
+ CyphraClient.provision_identity(engine, database)
100
+ transport = HttpBrokerClient("https://post-q.vercel.app")
101
+ client = CyphraClient(engine, database, transport)
102
+ ```
103
+
104
+ ## Fixed Vercel Relay
105
+
106
+ The deployed relay URL is `https://post-q.vercel.app`.
107
+
108
+ The Vercel handler uses transient process memory for public bundles, presence, and temporary opaque packet forwarding. No external database is configured. Persistent identities, contacts, and encrypted message history remain in each client database.
109
+
110
+ Because serverless memory is not durable or shared across every instance, this relay is suitable for controlled demonstrations and testing, not reliable offline delivery.
111
+
112
+ Deploy through the Vercel GitHub integration by importing `Anupam1707/PostQ`, selecting **Other** as the framework, and deploying the `main` branch.
113
+
114
+ ## Local Relay Development
115
+
116
+ ```bash
117
+ cyphra-server --insecure-dev --host 127.0.0.1 --port 8765
118
+ ```
119
+
120
+ The local WebSocket relay remains available for development and tests, but the normal standalone client uses the fixed Vercel HTTPS relay.
121
+
122
+ ## Security Notes
123
+
124
+ - ML-KEM-768 is mandatory; there is no classical-only downgrade path.
125
+ - Each message gets fresh X25519 ephemeral material, ML-KEM encapsulation, HKDF salt, and AES-GCM nonce.
126
+ - The relay sees identity IDs, public bundles, timing, sizes, and opaque ciphertext.
127
+ - Plaintext, private keys, passphrases, and derived message keys remain client-side.
128
+ - Offline delivery is intentionally not guaranteed by the transient relay.
129
+ - This is a security-focused reference implementation, not a substitute for independent cryptographic review.
130
+
131
+ ## Build and Publish
132
+
133
+ The package provides the `cyphra` and `cyphra-server` console commands.
134
+
135
+ ```bash
136
+ rm -rf dist build *.egg-info
137
+ python3 -m build
138
+ python3 -m twine check dist/*
139
+ python3 -m pip install --force-reinstall dist/cyphra-*.whl
140
+ cyphra --help
141
+ ```
142
+
143
+ Publish after reviewing the artifacts:
144
+
145
+ ```bash
146
+ python3 -m twine upload dist/*
147
+ ```
148
+
149
+ Never commit PyPI credentials. Increment the version in `pyproject.toml` for every release.
150
+
151
+ ## Testing
152
+
153
+ ```bash
154
+ pytest -q
155
+ python3 -m py_compile api/index.py cyphra/**/*.py cyphra/*.py tests/*.py
156
+ ```
cyphra-0.1.0/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # Cyphra
2
+
3
+ Cyphra is a Python reference implementation of a zero-knowledge, end-to-end encrypted messaging platform. It supports two modes:
4
+
5
+ 1. A standalone terminal messaging client.
6
+ 2. An importable Python SDK for developer applications.
7
+
8
+ Maintainers: `Anupam1707` and `asmitbaldi`.
9
+
10
+ The mandatory hybrid construction is:
11
+
12
+ `X25519 ephemeral DH || ML-KEM-768 encapsulation -> HKDF-SHA384 -> AES-256-GCM`
13
+
14
+ Every message carries an Ed25519 signature over its canonical envelope.
15
+
16
+ ## Architecture
17
+
18
+ ```text
19
+ untrusted network
20
+ ┌───────────────┐ opaque frames ┌────────────────┐
21
+ │ CyphraClient │◄─────────────────►│ WebSocketBroker│
22
+ └──────┬────────┘ └────────────────┘
23
+ │ composes
24
+ ┌──────▼────────┐ ciphertext-only local state
25
+ │ CryptoEngine │◄──────────────────────────────┐
26
+ └───────────────┘ │
27
+ ┌────────────▼─────────┐
28
+ │ DatabaseManager │
29
+ │ SQLCipher │
30
+ └──────────────────────┘
31
+ ```
32
+
33
+ - `CryptoEngine` owns identity generation, bundle verification, hybrid encryption, signatures, and decryption.
34
+ - `DatabaseManager` owns SQLCipher persistence and stores private identity material, trusted contacts, and encrypted envelopes only.
35
+ - `CyphraClient` composes cryptography, storage, and transport.
36
+ - The fixed Vercel HTTP relay routes public bundles and opaque ciphertext without receiving plaintext or private keys.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ python3 -m venv .venv
42
+ . .venv/bin/activate
43
+ python3 -m pip install -e '.[dev]'
44
+ ```
45
+
46
+ ## Standalone Client
47
+
48
+ The client uses the fixed deployed relay internally. Users do not select a broker URL.
49
+
50
+ ```bash
51
+ cyphra chat --name Alice
52
+ cyphra chat --name Bob
53
+ ```
54
+
55
+ The terminal UI shows connected people, supports `r` to refresh and `q` to quit, and asks the user to approve the peer fingerprint before chatting. Type `/quit` to leave.
56
+
57
+ Each identity uses its own encrypted database under `~/.cyphra/`; use `--db` for a custom path.
58
+
59
+ ## SDK Mode
60
+
61
+ Developer applications can import Cyphra components directly:
62
+
63
+ ```python
64
+ from cyphra import CyphraClient, CryptoEngine, DatabaseManager
65
+ from cyphra.network.http_client import HttpBrokerClient
66
+
67
+ engine = CryptoEngine()
68
+ database = DatabaseManager.from_passphrase("alice.db", "a long local passphrase")
69
+ CyphraClient.provision_identity(engine, database)
70
+ transport = HttpBrokerClient("https://post-q.vercel.app")
71
+ client = CyphraClient(engine, database, transport)
72
+ ```
73
+
74
+ ## Fixed Vercel Relay
75
+
76
+ The deployed relay URL is `https://post-q.vercel.app`.
77
+
78
+ The Vercel handler uses transient process memory for public bundles, presence, and temporary opaque packet forwarding. No external database is configured. Persistent identities, contacts, and encrypted message history remain in each client database.
79
+
80
+ Because serverless memory is not durable or shared across every instance, this relay is suitable for controlled demonstrations and testing, not reliable offline delivery.
81
+
82
+ Deploy through the Vercel GitHub integration by importing `Anupam1707/PostQ`, selecting **Other** as the framework, and deploying the `main` branch.
83
+
84
+ ## Local Relay Development
85
+
86
+ ```bash
87
+ cyphra-server --insecure-dev --host 127.0.0.1 --port 8765
88
+ ```
89
+
90
+ The local WebSocket relay remains available for development and tests, but the normal standalone client uses the fixed Vercel HTTPS relay.
91
+
92
+ ## Security Notes
93
+
94
+ - ML-KEM-768 is mandatory; there is no classical-only downgrade path.
95
+ - Each message gets fresh X25519 ephemeral material, ML-KEM encapsulation, HKDF salt, and AES-GCM nonce.
96
+ - The relay sees identity IDs, public bundles, timing, sizes, and opaque ciphertext.
97
+ - Plaintext, private keys, passphrases, and derived message keys remain client-side.
98
+ - Offline delivery is intentionally not guaranteed by the transient relay.
99
+ - This is a security-focused reference implementation, not a substitute for independent cryptographic review.
100
+
101
+ ## Build and Publish
102
+
103
+ The package provides the `cyphra` and `cyphra-server` console commands.
104
+
105
+ ```bash
106
+ rm -rf dist build *.egg-info
107
+ python3 -m build
108
+ python3 -m twine check dist/*
109
+ python3 -m pip install --force-reinstall dist/cyphra-*.whl
110
+ cyphra --help
111
+ ```
112
+
113
+ Publish after reviewing the artifacts:
114
+
115
+ ```bash
116
+ python3 -m twine upload dist/*
117
+ ```
118
+
119
+ Never commit PyPI credentials. Increment the version in `pyproject.toml` for every release.
120
+
121
+ ## Testing
122
+
123
+ ```bash
124
+ pytest -q
125
+ python3 -m py_compile api/index.py cyphra/**/*.py cyphra/*.py tests/*.py
126
+ ```
@@ -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
+ ]
@@ -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
@@ -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
+