capyy 0.3.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.
capyy-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Izac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
capyy-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: capyy
3
+ Version: 0.3.0
4
+ Summary: Tiny encrypted JSON-over-TCP APIs: call a function in another program, on your Wi-Fi or across the internet
5
+ Author: Izac
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://libs.zazac.link/capy/
8
+ Project-URL: Documentation, https://libs.zazac.link/capy/
9
+ Keywords: rpc,api,tcp,json,tls,lan,discovery,relay,nat
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Natural Language :: French
15
+ Classifier: Topic :: Internet
16
+ Classifier: Topic :: System :: Networking
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # capyy — tiny encrypted JSON-over-TCP APIs
24
+
25
+ Call a function in another Python program — on the same machine, on your Wi-Fi, or across the internet —
26
+ with one line of code. Zero dependencies, Python 3.9+, encrypted with TLS by default.
27
+
28
+ ```bash
29
+ pip install capyy
30
+ ```
31
+
32
+ The package is published as **capyy**, but you import it as **`capy`**.
33
+ Error messages are in French (the library was written for French speakers), and the full documentation,
34
+ with examples, is at **[libs.zazac.link/capy](https://libs.zazac.link/capy/)**.
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ # server.py
40
+ import capy
41
+
42
+ api = capy.Server(4000)
43
+
44
+ @api.action("hello")
45
+ def hello(name="you"):
46
+ return {"message": f"Hello {name}!"}
47
+
48
+ api.run()
49
+ ```
50
+
51
+ ```python
52
+ # client.py
53
+ import capy
54
+
55
+ print(capy.call("localhost", 4000, "hello", name="Alice")) # {'message': 'Hello Alice!'}
56
+ ```
57
+
58
+ - An action returns a `dict`, or JSON written as text with `capy.json('{"text": "Hello"}')`.
59
+ - Errors raised on the server come back as `{"erreur": "..."}` (unknown action, bad parameters, exceptions).
60
+ - `capy.call` raises `ConnectionError` when the server does not answer.
61
+
62
+ ## Reading a response
63
+
64
+ ```python
65
+ reply = {"city": "Paris", "temp": "18.5", "days": [{"name": "monday"}, {"name": "tuesday"}]}
66
+
67
+ capy.get(reply, "days.1.name") # "tuesday" (paths go through dicts and lists)
68
+ capy.get(reply, "wind", default=0) # 0
69
+ capy.to_float(reply, "temp") # 18.5
70
+ capy.to_int("42"), capy.to_bool("yes") # 42, True
71
+ ```
72
+
73
+ `to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
74
+ what could not be converted.
75
+
76
+ ## Encryption (TLS)
77
+
78
+ Everything is encrypted by default, with nothing to configure.
79
+
80
+ - Each machine running a server creates its own certificate on first launch (`~/.capy`, using `openssl`)
81
+ and prints its **fingerprint**.
82
+ - Like SSH, a client **remembers a server's fingerprint** the first time (`~/.capy/known_hosts.json`)
83
+ and raises `capy.SecurityError` if it ever changes.
84
+ - Use `tls=False` on **both** sides to talk in clear (faster on localhost).
85
+
86
+ ## `capy.wifi` — find each other on the same network
87
+
88
+ ```python
89
+ import capy.wifi
90
+
91
+ api = capy.wifi.Server("living-room") # announces itself on the LAN, port chosen automatically
92
+ ...
93
+ capy.wifi.call("living-room", "light", on=False) # from any device on the same Wi-Fi
94
+ capy.wifi.scan() # {'living-room': ('192.168.1.42', 51234)}
95
+ ```
96
+
97
+ ## `capy.network` — across the internet, through a relay
98
+
99
+ A machine behind a home router cannot be reached from the internet, so `capy.network` goes through a
100
+ **relay** running on a machine with a public IP (a VPS). Servers connect to it and register a name;
101
+ clients call that name. Everything is encrypted, and the connection is kept open between calls.
102
+
103
+ ```bash
104
+ python3 -m capy.network 3879 my-secret-key # on the VPS (open the port in its firewall)
105
+ ```
106
+
107
+ ```python
108
+ import capy.network
109
+
110
+ capy.network.use("my-vps.example:3879", key="my-secret-key")
111
+ capy.network.Server("home").run() # at home, even behind a router
112
+ capy.network.call("home", "temperature") # from anywhere
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
capyy-0.3.0/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # capyy — tiny encrypted JSON-over-TCP APIs
2
+
3
+ Call a function in another Python program — on the same machine, on your Wi-Fi, or across the internet —
4
+ with one line of code. Zero dependencies, Python 3.9+, encrypted with TLS by default.
5
+
6
+ ```bash
7
+ pip install capyy
8
+ ```
9
+
10
+ The package is published as **capyy**, but you import it as **`capy`**.
11
+ Error messages are in French (the library was written for French speakers), and the full documentation,
12
+ with examples, is at **[libs.zazac.link/capy](https://libs.zazac.link/capy/)**.
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ # server.py
18
+ import capy
19
+
20
+ api = capy.Server(4000)
21
+
22
+ @api.action("hello")
23
+ def hello(name="you"):
24
+ return {"message": f"Hello {name}!"}
25
+
26
+ api.run()
27
+ ```
28
+
29
+ ```python
30
+ # client.py
31
+ import capy
32
+
33
+ print(capy.call("localhost", 4000, "hello", name="Alice")) # {'message': 'Hello Alice!'}
34
+ ```
35
+
36
+ - An action returns a `dict`, or JSON written as text with `capy.json('{"text": "Hello"}')`.
37
+ - Errors raised on the server come back as `{"erreur": "..."}` (unknown action, bad parameters, exceptions).
38
+ - `capy.call` raises `ConnectionError` when the server does not answer.
39
+
40
+ ## Reading a response
41
+
42
+ ```python
43
+ reply = {"city": "Paris", "temp": "18.5", "days": [{"name": "monday"}, {"name": "tuesday"}]}
44
+
45
+ capy.get(reply, "days.1.name") # "tuesday" (paths go through dicts and lists)
46
+ capy.get(reply, "wind", default=0) # 0
47
+ capy.to_float(reply, "temp") # 18.5
48
+ capy.to_int("42"), capy.to_bool("yes") # 42, True
49
+ ```
50
+
51
+ `to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
52
+ what could not be converted.
53
+
54
+ ## Encryption (TLS)
55
+
56
+ Everything is encrypted by default, with nothing to configure.
57
+
58
+ - Each machine running a server creates its own certificate on first launch (`~/.capy`, using `openssl`)
59
+ and prints its **fingerprint**.
60
+ - Like SSH, a client **remembers a server's fingerprint** the first time (`~/.capy/known_hosts.json`)
61
+ and raises `capy.SecurityError` if it ever changes.
62
+ - Use `tls=False` on **both** sides to talk in clear (faster on localhost).
63
+
64
+ ## `capy.wifi` — find each other on the same network
65
+
66
+ ```python
67
+ import capy.wifi
68
+
69
+ api = capy.wifi.Server("living-room") # announces itself on the LAN, port chosen automatically
70
+ ...
71
+ capy.wifi.call("living-room", "light", on=False) # from any device on the same Wi-Fi
72
+ capy.wifi.scan() # {'living-room': ('192.168.1.42', 51234)}
73
+ ```
74
+
75
+ ## `capy.network` — across the internet, through a relay
76
+
77
+ A machine behind a home router cannot be reached from the internet, so `capy.network` goes through a
78
+ **relay** running on a machine with a public IP (a VPS). Servers connect to it and register a name;
79
+ clients call that name. Everything is encrypted, and the connection is kept open between calls.
80
+
81
+ ```bash
82
+ python3 -m capy.network 3879 my-secret-key # on the VPS (open the port in its firewall)
83
+ ```
84
+
85
+ ```python
86
+ import capy.network
87
+
88
+ capy.network.use("my-vps.example:3879", key="my-secret-key")
89
+ capy.network.Server("home").run() # at home, even behind a router
90
+ capy.network.call("home", "temperature") # from anywhere
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,275 @@
1
+ # capy : mini-API en TCP, une ligne JSON par requête / réponse, chiffrée (TLS)
2
+
3
+ import json as jsonlib # "json" est pris par capy.json()
4
+ import socket
5
+ import threading
6
+
7
+ from . import _tls
8
+ from ._tls import SecurityError # noqa: F401 (capy.SecurityError)
9
+
10
+
11
+ # Serveur : @server.action("nom") pour déclarer une action, server.run() pour lancer
12
+ # Le port se donne à Server(port) ou à run(port), au choix
13
+ # Chiffré (TLS) par défaut ; tls=False pour parler en clair (le client aussi doit mettre tls=False)
14
+ class Server:
15
+ def __init__(self, port=None, tls=True):
16
+ self.port, self.tls = port, tls
17
+ self.actions = {}
18
+ self.listening = False # True une fois le serveur prêt
19
+ self._context = None
20
+
21
+ def action(self, name):
22
+ def decorator(function):
23
+ self.actions[name] = function
24
+ return function
25
+ return decorator
26
+
27
+ # {"action": "get", "id": 2} -> get(id=2)
28
+ def handle(self, line):
29
+ try:
30
+ request = jsonlib.loads(line)
31
+ function = self.actions[request.pop("action")]
32
+ except (ValueError, AttributeError, TypeError): # pas du JSON, ou pas un objet {...}
33
+ return {"erreur": "JSON invalide"}
34
+ except KeyError:
35
+ return {"erreur": "action inconnue"}
36
+ try:
37
+ return function(**request)
38
+ except Exception as e:
39
+ return {"erreur": str(e)}
40
+
41
+ def client(self, conn, addr):
42
+ if self._context:
43
+ conn = _tls.accept(self._context, conn)
44
+ if conn is None: # Le client ne parle pas en chiffré -> on raccroche
45
+ return
46
+ else:
47
+ conn.settimeout(10)
48
+ with conn:
49
+ try:
50
+ line = conn.makefile(encoding="utf-8").readline()
51
+ if not line.strip(): # Connexion ouverte puis fermée sans rien demander
52
+ return
53
+ conn.sendall((jsonlib.dumps(self.handle(line), ensure_ascii=False) + "\n").encode())
54
+ except (OSError, ValueError):
55
+ return
56
+ print(f"{addr[0]} → {line.strip()}")
57
+
58
+ # port 0 -> le système choisit un port libre
59
+ def run(self, port=None):
60
+ self.port = self.port if port is None else port
61
+ if self.port is None:
62
+ raise ValueError("capy: aucun port, utilise Server(port) ou run(port)")
63
+ if self.tls:
64
+ self._context, empreinte = _tls.server_context("server")
65
+ with socket.create_server(("", self.port)) as server:
66
+ self.port = server.getsockname()[1]
67
+ self.listening = True
68
+ if self.tls:
69
+ print(f"API en écoute sur le port {self.port}, chiffrée (TLS) · empreinte : {empreinte}")
70
+ else:
71
+ print(f"API en écoute sur le port {self.port} (pas chiffrée)")
72
+ while True:
73
+ conn, addr = server.accept()
74
+ threading.Thread(target=self.client, args=(conn, addr), daemon=True).start()
75
+
76
+
77
+ # Renvoyer du JSON écrit en texte depuis une action
78
+ # Ex : return capy.json('{"text": "Hello"}')
79
+ def json(text):
80
+ try:
81
+ return jsonlib.loads(text)
82
+ except ValueError as e:
83
+ raise ValueError(f"capy.json : JSON invalide ({e})") from None
84
+
85
+
86
+ # ============================================================
87
+ # EXTRAIRE UNE VALEUR
88
+ # ============================================================
89
+
90
+ _NOTHING = object() # Pour savoir si default a été donné (None est une vraie valeur)
91
+
92
+
93
+ # Extraire une valeur d'un JSON (dict, liste, ou texte JSON) avec un chemin "a.b.0.c"
94
+ # Ex : get('{"text": "Hello"}', "text") -> "Hello"
95
+ # get(reponse, "users.0.name") -> "Izac" (0 = 1er élément d'une liste)
96
+ # get(reponse, "age", default=0) -> 0 si "age" n'existe pas (au lieu d'une erreur)
97
+ def get(data, path, default=_NOTHING):
98
+ value = json(data) if isinstance(data, str) else data
99
+ for key in str(path).split("."):
100
+ if isinstance(value, dict) and key in value:
101
+ value = value[key]
102
+ elif isinstance(value, list) and key.lstrip("-").isdigit() and -len(value) <= int(key) < len(value):
103
+ value = value[int(key)]
104
+ elif default is not _NOTHING:
105
+ return default
106
+ elif isinstance(value, dict):
107
+ raise ValueError(f"capy.get : clé {key!r} introuvable dans {path!r} (clés : {', '.join(map(str, value)) or 'aucune'})")
108
+ elif isinstance(value, list):
109
+ raise ValueError(f"capy.get : {key!r} pas valable dans {path!r} (liste de {len(value)} éléments, 0 à {len(value) - 1})")
110
+ else:
111
+ raise ValueError(f"capy.get : impossible d'aller dans {key!r} de {path!r}, la valeur est un {type(value).__name__}")
112
+ return value
113
+
114
+
115
+ # ============================================================
116
+ # CONVERSIONS
117
+ # ============================================================
118
+ # Pour transformer une valeur reçue (réponse de capy.call, paramètre d'une action...)
119
+ # Si ça ne marche pas -> ValueError avec un message clair
120
+ # (dans une action, le client reçoit alors {"erreur": "..."})
121
+ # key (optionnel) : extraire d'abord une valeur, comme get() -> to_int(reponse, "age")
122
+
123
+ # Erreur commune : "capy.to_int : impossible de transformer 'abc' (str) en int"
124
+ def _error(name, data, target):
125
+ text = repr(data)
126
+ if len(text) > 60:
127
+ text = text[:60] + "…"
128
+ return ValueError(f"capy.{name} : impossible de transformer {text} ({type(data).__name__}) en {target}")
129
+
130
+
131
+ # Du JSON (dict, liste...) -> texte JSON sur une ligne (un texte reste tel quel)
132
+ # Ex : to_str({"text": "Hello"}) -> '{"text": "Hello"}'
133
+ # to_str('{"text": "Hello"}', "text") -> "Hello"
134
+ def to_str(data, key=None):
135
+ if key is not None:
136
+ data = get(data, key)
137
+ if isinstance(data, str):
138
+ return data
139
+ try:
140
+ return jsonlib.dumps(data, ensure_ascii=False)
141
+ except (TypeError, ValueError):
142
+ raise _error("to_str", data, "texte JSON") from None
143
+
144
+
145
+ # -> entier. Accepte 42, 42.0, "42", " 42 "
146
+ # Ex : to_int("42") -> 42 / to_int("abc") -> ValueError
147
+ def to_int(data, key=None):
148
+ if key is not None:
149
+ data = get(data, key)
150
+ if isinstance(data, bool): # True est un int pour Python, mais pas pour nous
151
+ raise _error("to_int", data, "int")
152
+ if isinstance(data, int):
153
+ return data
154
+ if isinstance(data, float) and data.is_integer():
155
+ return int(data)
156
+ if isinstance(data, str):
157
+ try:
158
+ return to_int(float(data)) if "." in data else int(data)
159
+ except ValueError:
160
+ pass
161
+ raise _error("to_int", data, "int")
162
+
163
+
164
+ # -> nombre à virgule. Accepte 4.5, 4, "4.5"
165
+ # Ex : to_float("4.5") -> 4.5
166
+ def to_float(data, key=None):
167
+ if key is not None:
168
+ data = get(data, key)
169
+ if not isinstance(data, bool) and isinstance(data, (int, float, str)):
170
+ try:
171
+ return float(data)
172
+ except ValueError:
173
+ pass
174
+ raise _error("to_float", data, "float")
175
+
176
+
177
+ # -> True / False. Accepte true/false, 1/0, "true"/"false", "oui"/"non", "1"/"0"
178
+ # Ex : to_bool("oui") -> True
179
+ def to_bool(data, key=None):
180
+ if key is not None:
181
+ data = get(data, key)
182
+ if isinstance(data, bool):
183
+ return data
184
+ if data in (0, 1):
185
+ return bool(data)
186
+ if isinstance(data, str):
187
+ text = data.strip().lower()
188
+ if text in ("true", "oui", "yes", "1"):
189
+ return True
190
+ if text in ("false", "non", "no", "0"):
191
+ return False
192
+ raise _error("to_bool", data, "bool")
193
+
194
+
195
+ # -> liste. Accepte une liste, un tuple, ou du texte JSON '[1, 2]'
196
+ # Ex : to_list("[1, 2]") -> [1, 2]
197
+ def to_list(data, key=None):
198
+ if key is not None:
199
+ data = get(data, key)
200
+ if isinstance(data, (list, tuple)):
201
+ return list(data)
202
+ if isinstance(data, str):
203
+ try:
204
+ value = jsonlib.loads(data)
205
+ except ValueError:
206
+ value = None
207
+ if isinstance(value, list):
208
+ return value
209
+ raise _error("to_list", data, "list")
210
+
211
+
212
+ # -> dict. Accepte un dict, ou du texte JSON '{"a": 1}'
213
+ # Ex : to_dict('{"a": 1}') -> {"a": 1}
214
+ def to_dict(data, key=None):
215
+ if key is not None:
216
+ data = get(data, key)
217
+ if isinstance(data, dict):
218
+ return data
219
+ if isinstance(data, str):
220
+ try:
221
+ value = jsonlib.loads(data)
222
+ except ValueError:
223
+ value = None
224
+ if isinstance(value, dict):
225
+ return value
226
+ raise _error("to_dict", data, "dict")
227
+
228
+
229
+ # Client : capy.call("127.0.0.1", 4000, "get", id=2) -> dict
230
+ # Lève ConnectionError si le serveur ne répond pas, SecurityError si ce n'est pas le bon serveur
231
+ # tls=False : pour un serveur pas chiffré (Server(..., tls=False))
232
+ def call(host, port, action, /, tls=True, **params):
233
+ return _call(host, port, action, params, tls, label=f"{host}:{port}")
234
+
235
+
236
+ # Envoyer une demande et lire la réponse
237
+ # label : sous quel nom retenir l'empreinte du serveur ("ip:port", "wifi:salon"...)
238
+ def _call(host, port, action, params, tls, label, who="capy"):
239
+ if not isinstance(host, str) or isinstance(port, bool) or not isinstance(port, int):
240
+ raise TypeError(
241
+ f"capy.call : l'ordre est call(host, port, action), ex : call(\"localhost\", 4000, \"get\")"
242
+ f" -> reçu call({host!r}, {port!r}, {action!r})"
243
+ )
244
+ if "://" in host: # capy parle en TCP, pas en HTTP
245
+ raise ValueError(f"capy.call : host est un nom ou une IP, pas une URL -> {host.split('://', 1)[1].strip('/')!r}")
246
+ connected = False
247
+ hint = " — il est peut-être chiffré : enlève tls=False" if not tls else ""
248
+ try:
249
+ if tls:
250
+ conn = _tls.connect(host, port, label, timeout=3, who=who)
251
+ else:
252
+ conn = socket.create_connection((host, port), timeout=3)
253
+ connected = True
254
+ with conn:
255
+ conn.sendall((jsonlib.dumps({"action": action, **params}) + "\n").encode())
256
+ line = conn.makefile(encoding="utf-8").readline()
257
+ if not line:
258
+ raise ConnectionError(f"{who} : le serveur {host}:{port} n'a rien répondu{hint}")
259
+ return jsonlib.loads(line)
260
+ except ConnectionError as e:
261
+ if str(e).startswith(who): # Message déjà clair (ex : chiffrement impossible)
262
+ raise
263
+ error = e
264
+ except (OSError, ValueError) as e:
265
+ error = e
266
+ if connected: # Il a décroché puis coupé : souvent un serveur chiffré appelé en clair
267
+ raise ConnectionError(f"{who} : le serveur {host}:{port} a coupé la connexion sans répondre{hint}") from None
268
+ raise ConnectionError(f"{who} : le serveur {host}:{port} ne répond pas ({type(error).__name__})") from None
269
+
270
+
271
+ # Empreinte du certificat des serveurs capy de cette machine (à donner aux clients qui veulent vérifier)
272
+ # Ex : capy.fingerprint() -> "9dfa7b98…"
273
+ def fingerprint():
274
+ _tls.server_context("server") # Crée le certificat s'il n'existe pas encore
275
+ return _tls.my_fingerprint("server")
@@ -0,0 +1,139 @@
1
+ # capy._tls : le chiffrement (TLS) commun à capy, capy.wifi et capy.network
2
+ #
3
+ # Chaque machine qui fait tourner un serveur crée son certificat tout seul (dans ~/.capy, avec openssl).
4
+ # Comme SSH, un client retient l'empreinte d'un serveur à la 1re connexion (~/.capy/known_hosts.json) ;
5
+ # si elle change ensuite -> SecurityError (quelqu'un se fait peut-être passer pour le serveur).
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import os
11
+ import pathlib as pth
12
+ import socket
13
+ import ssl
14
+ import subprocess
15
+ import threading
16
+
17
+ CAPY_DIR = pth.Path.home() / ".capy"
18
+ KNOWN_FILE = CAPY_DIR / "known_hosts.json"
19
+ LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
20
+ _lock = threading.Lock()
21
+
22
+
23
+ # L'empreinte d'un serveur ne correspond pas : on refuse de lui parler
24
+ class SecurityError(Exception):
25
+ pass
26
+
27
+
28
+ # Empreinte d'un certificat (64 caractères)
29
+ def fingerprint(der):
30
+ return hashlib.sha256(der).hexdigest()
31
+
32
+
33
+ # Certificat de cette machine : créé une fois avec openssl, puis réutilisé
34
+ # kind : "server" (capy, capy.wifi) ou "relay" (capy.network)
35
+ def _certificate(kind):
36
+ cert, key = CAPY_DIR / f"{kind}-cert.pem", CAPY_DIR / f"{kind}-key.pem"
37
+ with _lock:
38
+ if not (cert.exists() and key.exists()):
39
+ CAPY_DIR.mkdir(exist_ok=True)
40
+ try:
41
+ subprocess.run(
42
+ ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650",
43
+ "-subj", f"/CN=capy-{kind}", "-keyout", str(key), "-out", str(cert)],
44
+ check=True, capture_output=True,
45
+ )
46
+ except (OSError, subprocess.CalledProcessError) as e:
47
+ raise RuntimeError(f"capy : impossible de créer le certificat avec openssl ({e})") from None
48
+ os.chmod(key, 0o600) # Clé privée lisible par toi seul
49
+ return cert, key
50
+
51
+
52
+ # Empreinte du certificat de cette machine (None s'il n'existe pas encore)
53
+ def my_fingerprint(kind="server"):
54
+ cert = CAPY_DIR / f"{kind}-cert.pem"
55
+ try:
56
+ return fingerprint(ssl.PEM_cert_to_DER_cert(cert.read_text()))
57
+ except (OSError, ValueError):
58
+ return None
59
+
60
+
61
+ # Contexte TLS côté serveur -> (contexte, empreinte)
62
+ def server_context(kind="server"):
63
+ cert, key = _certificate(kind)
64
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
65
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
66
+ context.load_cert_chain(cert, key)
67
+ return context, my_fingerprint(kind)
68
+
69
+
70
+ # Chiffrer une connexion qui arrive sur un serveur (None si le client ne parle pas en TLS)
71
+ def accept(context, raw, timeout=10):
72
+ try:
73
+ raw.settimeout(timeout)
74
+ conn = context.wrap_socket(raw, server_side=True)
75
+ conn.settimeout(timeout)
76
+ return conn
77
+ except (OSError, ValueError):
78
+ raw.close()
79
+ return None
80
+
81
+
82
+ # Se connecter en chiffré, et vérifier que c'est bien le bon serveur (empreinte)
83
+ # label : sous quel nom retenir l'empreinte ("ip:port", "wifi:salon"...)
84
+ # pinned : empreinte attendue (sinon : celle retenue la 1re fois)
85
+ def connect(host, port, label, timeout=10, pinned=None, who="capy", kind="serveur"):
86
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
87
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
88
+ context.check_hostname = False # Certificat fait maison :
89
+ context.verify_mode = ssl.CERT_NONE # on le vérifie nous-mêmes avec l'empreinte juste après
90
+ raw = socket.create_connection((host, port), timeout=timeout)
91
+ try:
92
+ conn = context.wrap_socket(raw)
93
+ except (ssl.SSLError, OSError):
94
+ raw.close()
95
+ raise ConnectionError(
96
+ f"{who} : connexion chiffrée impossible avec {host}:{port}"
97
+ f" — le {kind} n'est peut-être pas chiffré (tls=False des deux côtés pour s'en passer)"
98
+ ) from None
99
+ try:
100
+ check(label, fingerprint(conn.getpeercert(binary_form=True)), pinned, who, kind, local=host in LOCAL_HOSTS)
101
+ except SecurityError:
102
+ conn.close()
103
+ raise
104
+ return conn
105
+
106
+
107
+ # Comparer une empreinte avec celle attendue, ou celle retenue la 1re fois
108
+ def check(label, seen, pinned=None, who="capy", kind="serveur", local=False):
109
+ wanted = (pinned or "").replace(":", "").lower()
110
+ if wanted:
111
+ if not hmac.compare_digest(wanted, seen):
112
+ raise SecurityError(f"{who} : l'empreinte du {kind} {label} n'est pas celle attendue (reçu {seen})")
113
+ return
114
+ if local and seen == my_fingerprint("server"): # Ce Mac lui-même : rien à vérifier
115
+ return
116
+ with _lock:
117
+ known = _known()
118
+ if label not in known:
119
+ CAPY_DIR.mkdir(exist_ok=True)
120
+ known[label] = seen
121
+ KNOWN_FILE.write_text(json.dumps(known, indent=4))
122
+ print(f"{who} : nouveau {kind} {label}, empreinte retenue {seen}", flush=True)
123
+ return
124
+ if not hmac.compare_digest(known[label], seen):
125
+ raise SecurityError(
126
+ f"{who} : l'empreinte du {kind} {label} a CHANGÉ ! Quelqu'un se fait peut-être passer pour lui.\n"
127
+ f" Si tu as réinstallé le {kind} (normal), supprime sa ligne dans {KNOWN_FILE}"
128
+ )
129
+
130
+
131
+ # Les empreintes retenues (reprend l'ancien fichier known_relays.json s'il existe)
132
+ def _known():
133
+ old = CAPY_DIR / "known_relays.json"
134
+ if not KNOWN_FILE.exists() and old.exists():
135
+ old.rename(KNOWN_FILE)
136
+ try:
137
+ return json.loads(KNOWN_FILE.read_text())
138
+ except (OSError, ValueError):
139
+ return {}