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 +156 -0
- cyphra-0.1.0/README.md +126 -0
- cyphra-0.1.0/cyphra/__init__.py +15 -0
- cyphra-0.1.0/cyphra/cli.py +238 -0
- cyphra-0.1.0/cyphra/client.py +160 -0
- cyphra-0.1.0/cyphra/constants.py +15 -0
- cyphra-0.1.0/cyphra/crypto/__init__.py +5 -0
- cyphra-0.1.0/cyphra/crypto/backend.py +98 -0
- cyphra-0.1.0/cyphra/crypto/engine.py +349 -0
- cyphra-0.1.0/cyphra/crypto_engine.py +5 -0
- cyphra-0.1.0/cyphra/database_manager.py +5 -0
- cyphra-0.1.0/cyphra/exceptions.py +50 -0
- cyphra-0.1.0/cyphra/models.py +310 -0
- cyphra-0.1.0/cyphra/network/__init__.py +14 -0
- cyphra-0.1.0/cyphra/network/client.py +219 -0
- cyphra-0.1.0/cyphra/network/http_client.py +119 -0
- cyphra-0.1.0/cyphra/network/protocol.py +65 -0
- cyphra-0.1.0/cyphra/network/websocket_broker.py +265 -0
- cyphra-0.1.0/cyphra/storage/__init__.py +5 -0
- cyphra-0.1.0/cyphra/storage/database.py +363 -0
- cyphra-0.1.0/cyphra/websocket_broker.py +5 -0
- cyphra-0.1.0/cyphra.egg-info/PKG-INFO +156 -0
- cyphra-0.1.0/cyphra.egg-info/SOURCES.txt +30 -0
- cyphra-0.1.0/cyphra.egg-info/dependency_links.txt +1 -0
- cyphra-0.1.0/cyphra.egg-info/entry_points.txt +3 -0
- cyphra-0.1.0/cyphra.egg-info/requires.txt +11 -0
- cyphra-0.1.0/cyphra.egg-info/top_level.txt +1 -0
- cyphra-0.1.0/pyproject.toml +58 -0
- cyphra-0.1.0/setup.cfg +4 -0
- cyphra-0.1.0/tests/test_broker_and_client.py +94 -0
- cyphra-0.1.0/tests/test_crypto.py +64 -0
- cyphra-0.1.0/tests/test_storage.py +45 -0
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
|
+
|