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.
@@ -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
+