riptide-transfer 1.3.6__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.
- riptide/__init__.py +28 -0
- riptide/__main__.py +4 -0
- riptide/app.py +45 -0
- riptide/core/__init__.py +1 -0
- riptide/core/config.py +176 -0
- riptide/core/file_select.py +89 -0
- riptide/core/firewall.py +552 -0
- riptide/core/focus.py +82 -0
- riptide/core/identity.py +243 -0
- riptide/core/nat.py +102 -0
- riptide/core/session.py +887 -0
- riptide/core/transfer.py +89 -0
- riptide/screens/__init__.py +1 -0
- riptide/screens/create.py +529 -0
- riptide/screens/join.py +479 -0
- riptide/screens/menu.py +94 -0
- riptide/screens/session_screen.py +1059 -0
- riptide/theme.py +1049 -0
- riptide_transfer-1.3.6.dist-info/METADATA +176 -0
- riptide_transfer-1.3.6.dist-info/RECORD +23 -0
- riptide_transfer-1.3.6.dist-info/WHEEL +5 -0
- riptide_transfer-1.3.6.dist-info/entry_points.txt +2 -0
- riptide_transfer-1.3.6.dist-info/top_level.txt +1 -0
riptide/core/identity.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Self-signed TLS identity for a riptide session.
|
|
2
|
+
|
|
3
|
+
Each machine has one identity, stored under ~/.riptide/ (key file with
|
|
4
|
+
0600 permissions, certificate alongside it). It is NOT stored in
|
|
5
|
+
config.toml. Authentication is fingerprint-based, like SSH host keys:
|
|
6
|
+
on first connect the other side shows the certificate fingerprint and
|
|
7
|
+
the user presses Allow; afterwards the fingerprint is stored as a
|
|
8
|
+
known peer in config.toml and a changed fingerprint aborts.
|
|
9
|
+
|
|
10
|
+
Since v1.1, the host side also authenticates the client. TLS client
|
|
11
|
+
certificates do not work for self-signed identities, because Python
|
|
12
|
+
rejects them even with CERT_OPTIONAL, so the client proves its
|
|
13
|
+
identity with a signed hello challenge instead: the host sends a
|
|
14
|
+
random nonce, the client signs it with its private key, and the
|
|
15
|
+
host verifies the signature with the public key from the client
|
|
16
|
+
certificate presented in the hello message. The fingerprint is
|
|
17
|
+
always computed from that certificate, never trusted from a
|
|
18
|
+
fingerprint string sent by the client.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def identity_dir(home: Path | None = None) -> Path:
|
|
32
|
+
return (home or Path.home()) / ".riptide"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Guards ensure_identity below. The Create screen calls it from two
|
|
36
|
+
# threads at once (one to show My code, one to start the listener),
|
|
37
|
+
# so without a lock both can see the identity missing, generate
|
|
38
|
+
# different keypairs, and interleave their writes, leaving a key
|
|
39
|
+
# from one pair and a certificate from the other on disk. The next
|
|
40
|
+
# load_cert_chain then fails with [X509: KEY_VALUES_MISMATCH].
|
|
41
|
+
_IDENTITY_LOCK = threading.Lock()
|
|
42
|
+
_IDENTITY_LOCKFILE_TIMEOUT = 10.0
|
|
43
|
+
_IDENTITY_LOCKFILE_STALE_AFTER = 60.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextlib.contextmanager
|
|
47
|
+
def _identity_write_lock(directory: Path):
|
|
48
|
+
"""Hold the in-process lock and a lock file while writing the identity.
|
|
49
|
+
|
|
50
|
+
The lock file also serialises two riptide processes started at the
|
|
51
|
+
same time. It is created with O_EXCL, so exactly one holder wins;
|
|
52
|
+
a lock older than a minute is treated as stale (holder killed
|
|
53
|
+
mid-write) and removed. If the lock file cannot be acquired in
|
|
54
|
+
time, the caller still proceeds under the in-process lock, so a
|
|
55
|
+
stuck lock file can never wedge startup.
|
|
56
|
+
"""
|
|
57
|
+
_IDENTITY_LOCK.acquire()
|
|
58
|
+
lock_path = directory / ".identity.lock"
|
|
59
|
+
fd = None
|
|
60
|
+
try:
|
|
61
|
+
deadline = time.monotonic() + _IDENTITY_LOCKFILE_TIMEOUT
|
|
62
|
+
while True:
|
|
63
|
+
try:
|
|
64
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
65
|
+
break
|
|
66
|
+
except FileExistsError:
|
|
67
|
+
try:
|
|
68
|
+
if time.time() - lock_path.stat().st_mtime > _IDENTITY_LOCKFILE_STALE_AFTER:
|
|
69
|
+
lock_path.unlink()
|
|
70
|
+
continue
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
continue
|
|
73
|
+
if time.monotonic() > deadline:
|
|
74
|
+
break
|
|
75
|
+
time.sleep(0.05)
|
|
76
|
+
try:
|
|
77
|
+
yield
|
|
78
|
+
finally:
|
|
79
|
+
if fd is not None:
|
|
80
|
+
os.close(fd)
|
|
81
|
+
try:
|
|
82
|
+
lock_path.unlink()
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
pass
|
|
85
|
+
finally:
|
|
86
|
+
_IDENTITY_LOCK.release()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _pair_matches(key_path: Path, cert_path: Path) -> bool:
|
|
90
|
+
"""True when the key and certificate on disk belong together.
|
|
91
|
+
|
|
92
|
+
Compares the public key in each file, so a pair left mismatched
|
|
93
|
+
by an interrupted write is detected and regenerated instead of
|
|
94
|
+
failing on every launch with KEY_VALUES_MISMATCH.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
from cryptography import x509
|
|
98
|
+
from cryptography.hazmat.primitives import serialization
|
|
99
|
+
|
|
100
|
+
key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
|
101
|
+
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
|
|
102
|
+
public = (
|
|
103
|
+
serialization.Encoding.DER,
|
|
104
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
105
|
+
)
|
|
106
|
+
return key.public_key().public_bytes(*public) == cert.public_key().public_bytes(*public)
|
|
107
|
+
except Exception:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cert_fingerprint(cert_der: bytes) -> str:
|
|
112
|
+
return hashlib.sha256(cert_der).hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fingerprint_code(fingerprint: str) -> str:
|
|
116
|
+
"""6-digit code for a fingerprint, grouped as 123-456.
|
|
117
|
+
|
|
118
|
+
Since v1.3, this is the short form shown on approval popups
|
|
119
|
+
so two people can compare it out of band, by call or message,
|
|
120
|
+
instead of reading a full 64 hex character fingerprint aloud.
|
|
121
|
+
Derivation, exactly as in the security plan: take the first
|
|
122
|
+
3 bytes of the fingerprint, which is itself SHA-256 of the
|
|
123
|
+
certificate DER, convert to an integer, take modulo 1,000,000,
|
|
124
|
+
format with leading zeros, and group as 3 digits, dash, 3 digits.
|
|
125
|
+
The code is a Short Authentication String, not a TOTP code:
|
|
126
|
+
there is no shared secret on first connect, and the code does
|
|
127
|
+
not change over time. It has about 20 bits of entropy, so it
|
|
128
|
+
is much weaker than the full fingerprint, and the full
|
|
129
|
+
fingerprint stays available in the same popup for sensitive
|
|
130
|
+
transfers. An empty or invalid fingerprint returns ------,
|
|
131
|
+
so the UI never shows a fake code for a missing fingerprint.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
fp = (fingerprint or "").strip().lower()
|
|
135
|
+
if len(fp) < 6:
|
|
136
|
+
return "------"
|
|
137
|
+
value = int.from_bytes(bytes.fromhex(fp[:6]), "big") % 1000000
|
|
138
|
+
code = f"{value:06d}"
|
|
139
|
+
return f"{code[:3]}-{code[3:]}"
|
|
140
|
+
except Exception:
|
|
141
|
+
return "------"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fingerprint_code_from_cert_der(cert_der: bytes) -> str:
|
|
145
|
+
"""6-digit code for a certificate, via its fingerprint."""
|
|
146
|
+
return fingerprint_code(cert_fingerprint(cert_der))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cert_der_from_pem(cert_path: Path) -> bytes:
|
|
150
|
+
"""Return the DER bytes for a PEM certificate file."""
|
|
151
|
+
from cryptography import x509
|
|
152
|
+
from cryptography.hazmat.primitives.serialization import Encoding
|
|
153
|
+
|
|
154
|
+
cert = x509.load_pem_x509_certificate(Path(cert_path).read_bytes())
|
|
155
|
+
return cert.public_bytes(Encoding.DER)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def sign_nonce(key_path: Path, nonce: bytes) -> bytes:
|
|
159
|
+
"""Sign a nonce with the private key, RSA PKCS1v15 + SHA-256."""
|
|
160
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
161
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
162
|
+
|
|
163
|
+
key = serialization.load_pem_private_key(Path(key_path).read_bytes(), password=None)
|
|
164
|
+
return key.sign(nonce, padding.PKCS1v15(), hashes.SHA256())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def verify_nonce_signature(cert_der: bytes, nonce: bytes, signature: bytes) -> bool:
|
|
168
|
+
"""Verify a nonce signature against a certificate's public key.
|
|
169
|
+
|
|
170
|
+
Returns True only on a valid signature. Any load, key type, or
|
|
171
|
+
verification failure returns False, it never raises, so the
|
|
172
|
+
caller can abort the session without leaking details.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
from cryptography import x509
|
|
176
|
+
from cryptography.hazmat.primitives import hashes
|
|
177
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
178
|
+
|
|
179
|
+
cert = x509.load_der_x509_certificate(cert_der)
|
|
180
|
+
public_key = cert.public_key()
|
|
181
|
+
public_key.verify(signature, nonce, padding.PKCS1v15(), hashes.SHA256())
|
|
182
|
+
return True
|
|
183
|
+
except Exception:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def ensure_identity(home: Path | None = None) -> tuple[Path, Path]:
|
|
188
|
+
"""Create a self-signed identity if missing, return (key_path, cert_path).
|
|
189
|
+
|
|
190
|
+
Safe to call from several threads or processes at once. The whole
|
|
191
|
+
check-and-generate runs under a lock, so only the first caller
|
|
192
|
+
generates; the rest find the finished pair. The pair is written
|
|
193
|
+
via temp files and os.replace, so a reader never sees a half
|
|
194
|
+
written key or certificate, and a pair left mismatched by an
|
|
195
|
+
interrupted write is detected and regenerated.
|
|
196
|
+
"""
|
|
197
|
+
directory = identity_dir(home)
|
|
198
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
key_path = directory / "identity.key"
|
|
200
|
+
cert_path = directory / "identity.crt"
|
|
201
|
+
with _identity_write_lock(directory):
|
|
202
|
+
if key_path.exists() and cert_path.exists() and _pair_matches(key_path, cert_path):
|
|
203
|
+
return key_path, cert_path
|
|
204
|
+
try:
|
|
205
|
+
from cryptography import x509
|
|
206
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
207
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
208
|
+
from cryptography.x509.oid import NameOID
|
|
209
|
+
except ImportError as exc:
|
|
210
|
+
raise RuntimeError("cryptography is required for riptide sessions") from exc
|
|
211
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
212
|
+
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "riptide")])
|
|
213
|
+
now = datetime.now(timezone.utc)
|
|
214
|
+
cert = (
|
|
215
|
+
x509.CertificateBuilder()
|
|
216
|
+
.subject_name(subject)
|
|
217
|
+
.issuer_name(subject)
|
|
218
|
+
.public_key(key.public_key())
|
|
219
|
+
.serial_number(x509.random_serial_number())
|
|
220
|
+
.not_valid_before(now)
|
|
221
|
+
.not_valid_after(now + timedelta(days=3650))
|
|
222
|
+
.sign(key, hashes.SHA256())
|
|
223
|
+
)
|
|
224
|
+
key_bytes = key.private_bytes(
|
|
225
|
+
serialization.Encoding.PEM,
|
|
226
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
227
|
+
serialization.NoEncryption(),
|
|
228
|
+
)
|
|
229
|
+
cert_bytes = cert.public_bytes(serialization.Encoding.PEM)
|
|
230
|
+
suffix = f"{os.getpid()}-{threading.get_ident()}"
|
|
231
|
+
key_tmp = directory / f".identity.key.tmp-{suffix}"
|
|
232
|
+
cert_tmp = directory / f".identity.crt.tmp-{suffix}"
|
|
233
|
+
key_tmp.write_bytes(key_bytes)
|
|
234
|
+
try:
|
|
235
|
+
key_tmp.chmod(0o600)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
cert_tmp.write_bytes(cert_bytes)
|
|
239
|
+
os.replace(key_tmp, key_path)
|
|
240
|
+
os.replace(cert_tmp, cert_path)
|
|
241
|
+
return key_path, cert_path
|
|
242
|
+
|
|
243
|
+
|
riptide/core/nat.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Public IP helpers. User-facing text never contains a port number."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ipaddress
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _xor_mapped_address(data: bytes, transaction_id: bytes) -> str | None:
|
|
10
|
+
"""Decode XOR-MAPPED-ADDRESS (0x0020) from a STUN Binding Response.
|
|
11
|
+
|
|
12
|
+
Returns the address string, IPv4 for an 8-byte value and IPv6
|
|
13
|
+
for a 20-byte value, or None when the attribute is absent.
|
|
14
|
+
The address bytes are XORed with the magic cookie followed by
|
|
15
|
+
the transaction ID.
|
|
16
|
+
"""
|
|
17
|
+
if len(data) < 20:
|
|
18
|
+
return None
|
|
19
|
+
key = struct.pack(">I", 0x2112A442) + transaction_id
|
|
20
|
+
pos = 20
|
|
21
|
+
while pos + 4 <= len(data):
|
|
22
|
+
atype, alen = struct.unpack(">HH", data[pos:pos + 4])
|
|
23
|
+
val = data[pos + 4:pos + 4 + alen]
|
|
24
|
+
if atype == 0x0020:
|
|
25
|
+
if len(val) == 8: # IPv4
|
|
26
|
+
xaddr = struct.unpack(">I", val[4:8])[0] ^ 0x2112A442
|
|
27
|
+
return socket.inet_ntoa(struct.pack(">I", xaddr))
|
|
28
|
+
if len(val) == 20: # IPv6
|
|
29
|
+
xbytes = bytes(b ^ key[i] for i, b in enumerate(val[4:20]))
|
|
30
|
+
return socket.inet_ntop(socket.AF_INET6, xbytes)
|
|
31
|
+
pos += 4 + ((alen + 3) // 4 * 4)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stun_public_ip(timeout: float = 2.0) -> str | None:
|
|
36
|
+
"""Public address as seen from the internet, via a STUN binding.
|
|
37
|
+
|
|
38
|
+
Sends a minimal RFC 5389 Binding Request to a public STUN
|
|
39
|
+
server over UDP and parses XOR-MAPPED-ADDRESS. Returns None
|
|
40
|
+
on any failure, including no internet or a blocked UDP port.
|
|
41
|
+
"""
|
|
42
|
+
import struct
|
|
43
|
+
try:
|
|
44
|
+
transaction_id = bytes(12)
|
|
45
|
+
req = struct.pack(">HHI", 0x0001, 0, 0x2112A442) + transaction_id
|
|
46
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
47
|
+
sock.settimeout(timeout)
|
|
48
|
+
sock.sendto(req, ("stun.l.google.com", 19302))
|
|
49
|
+
data, _addr = sock.recvfrom(2048)
|
|
50
|
+
return _xor_mapped_address(data, transaction_id)
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def public_ipv6() -> str | None:
|
|
57
|
+
"""Current public IPv6 address, via HTTPS lookup.
|
|
58
|
+
|
|
59
|
+
Returns None when the host has no IPv6. There is no STUN step
|
|
60
|
+
here: the public STUN servers are IPv4-only (stun.l.google.com
|
|
61
|
+
publishes no IPv6 address), so the lookup goes to IPv6-only
|
|
62
|
+
echo services instead. Both only answer over IPv6, so a host
|
|
63
|
+
without it fails fast and the row stays hidden.
|
|
64
|
+
"""
|
|
65
|
+
import urllib.request
|
|
66
|
+
for url in ("https://api6.ipify.org", "https://ipv6.icanhazip.com"):
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(url, timeout=2.0) as resp:
|
|
69
|
+
candidate = resp.read().decode(errors="ignore").strip()
|
|
70
|
+
addr = ipaddress.ip_address(candidate)
|
|
71
|
+
if addr.version == 6:
|
|
72
|
+
return candidate
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def public_ip() -> str | None:
|
|
80
|
+
"""Current public address, STUN first, HTTPS fallback.
|
|
81
|
+
|
|
82
|
+
Returns None if neither works. This is the address the Create
|
|
83
|
+
Server page shows, so the client knows what to dial. Behind
|
|
84
|
+
CGNAT it is the shared CGNAT address, not a directly reachable
|
|
85
|
+
one.
|
|
86
|
+
"""
|
|
87
|
+
ip = stun_public_ip()
|
|
88
|
+
if ip:
|
|
89
|
+
return ip
|
|
90
|
+
import urllib.request
|
|
91
|
+
for url in ("https://api.ipify.org", "https://checkip.amazonaws.com"):
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(url, timeout=2.0) as resp:
|
|
94
|
+
candidate = resp.read().decode(errors="ignore").strip()
|
|
95
|
+
ipaddress.ip_address(candidate)
|
|
96
|
+
return candidate
|
|
97
|
+
except Exception:
|
|
98
|
+
continue
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|