redundanet 2.0.0__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.
redundanet/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """RedundaNet - Distributed encrypted storage on a mesh VPN network."""
2
+
3
+ __version__ = "2.0.0"
4
+ __author__ = "Alessandro De Filippo"
5
+ __email__ = "alessandro@example.com"
6
+
7
+ from redundanet.core.config import NetworkConfig, NodeConfig, TahoeConfig
8
+ from redundanet.core.manifest import Manifest
9
+
10
+ __all__ = [
11
+ "__version__",
12
+ "NetworkConfig",
13
+ "NodeConfig",
14
+ "TahoeConfig",
15
+ "Manifest",
16
+ ]
redundanet/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running redundanet as a module: python -m redundanet."""
2
+
3
+ from redundanet.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,9 @@
1
+ """GPG authentication module for RedundaNet."""
2
+
3
+ from redundanet.auth.gpg import GPGManager
4
+ from redundanet.auth.keyserver import KeyServerClient
5
+
6
+ __all__ = [
7
+ "GPGManager",
8
+ "KeyServerClient",
9
+ ]
redundanet/auth/gpg.py ADDED
@@ -0,0 +1,323 @@
1
+ """GPG key management for RedundaNet node authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import gnupg
10
+
11
+ from redundanet.core.exceptions import GPGError
12
+ from redundanet.utils.files import ensure_dir
13
+ from redundanet.utils.logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class GPGKeyInfo:
20
+ """Information about a GPG key."""
21
+
22
+ key_id: str
23
+ fingerprint: str
24
+ user_id: str
25
+ email: str | None = None
26
+ created: str | None = None
27
+ expires: str | None = None
28
+ trust: str | None = None
29
+
30
+ @classmethod
31
+ def from_gpg_key(cls, key: dict[str, Any]) -> GPGKeyInfo:
32
+ """Create GPGKeyInfo from gnupg key dict."""
33
+ uids = key.get("uids", [])
34
+ user_id = uids[0] if uids else ""
35
+
36
+ # Parse email from user_id
37
+ email = None
38
+ if "<" in user_id and ">" in user_id:
39
+ email = user_id.split("<")[1].split(">")[0]
40
+
41
+ return cls(
42
+ key_id=key.get("keyid", ""),
43
+ fingerprint=key.get("fingerprint", ""),
44
+ user_id=user_id,
45
+ email=email,
46
+ created=key.get("date", None),
47
+ expires=key.get("expires", None),
48
+ trust=key.get("trust", None),
49
+ )
50
+
51
+
52
+ class GPGManager:
53
+ """Manages GPG keys for node authentication."""
54
+
55
+ def __init__(
56
+ self,
57
+ gnupg_home: Path | str | None = None,
58
+ node_name: str = "",
59
+ ) -> None:
60
+ """Initialize GPG manager.
61
+
62
+ Args:
63
+ gnupg_home: Path to GPG home directory
64
+ node_name: Name of the local node
65
+ """
66
+ self.node_name = node_name
67
+ self._gnupg_home: Path | None = None
68
+
69
+ if gnupg_home:
70
+ self._gnupg_home = Path(gnupg_home)
71
+ ensure_dir(self._gnupg_home, mode=0o700)
72
+
73
+ self._gpg = gnupg.GPG(gnupghome=str(self._gnupg_home) if self._gnupg_home else None)
74
+
75
+ def generate_key(
76
+ self,
77
+ name: str,
78
+ email: str,
79
+ passphrase: str | None = None,
80
+ key_type: str = "RSA",
81
+ key_length: int = 4096,
82
+ expire_date: str = "0", # Never expire
83
+ ) -> GPGKeyInfo:
84
+ """Generate a new GPG keypair.
85
+
86
+ Args:
87
+ name: Real name for the key
88
+ email: Email address for the key
89
+ passphrase: Optional passphrase (None for no passphrase)
90
+ key_type: Key algorithm (RSA, DSA, etc.)
91
+ key_length: Key length in bits
92
+ expire_date: Expiration (0 = never, 1y, 2m, etc.)
93
+
94
+ Returns:
95
+ Information about the generated key
96
+ """
97
+ logger.info("Generating GPG key", name=name, email=email)
98
+
99
+ input_data = self._gpg.gen_key_input(
100
+ key_type=key_type,
101
+ key_length=key_length,
102
+ name_real=name,
103
+ name_email=email,
104
+ expire_date=expire_date,
105
+ passphrase=passphrase,
106
+ )
107
+
108
+ key = self._gpg.gen_key(input_data)
109
+
110
+ if not key.ok:
111
+ raise GPGError(f"Failed to generate GPG key: {key.status}")
112
+
113
+ logger.info("GPG key generated", fingerprint=str(key))
114
+
115
+ # Get full key info
116
+ keys = self._gpg.list_keys(keys=[str(key)])
117
+ if not keys:
118
+ raise GPGError("Key generated but could not be retrieved")
119
+
120
+ return GPGKeyInfo.from_gpg_key(keys[0])
121
+
122
+ def import_key(self, key_data: str) -> GPGKeyInfo:
123
+ """Import a GPG public key.
124
+
125
+ Args:
126
+ key_data: ASCII-armored public key
127
+
128
+ Returns:
129
+ Information about the imported key
130
+ """
131
+ result = self._gpg.import_keys(key_data)
132
+
133
+ if not result.ok:
134
+ raise GPGError(f"Failed to import key: {result.status}")
135
+
136
+ if not result.fingerprints:
137
+ raise GPGError("No keys imported")
138
+
139
+ fingerprint = result.fingerprints[0]
140
+ logger.info("Imported GPG key", fingerprint=fingerprint)
141
+
142
+ # Get key info
143
+ keys = self._gpg.list_keys(keys=[fingerprint])
144
+ if not keys:
145
+ raise GPGError("Key imported but could not be retrieved")
146
+
147
+ return GPGKeyInfo.from_gpg_key(keys[0])
148
+
149
+ def export_public_key(self, key_id: str) -> str:
150
+ """Export a public key in ASCII-armored format.
151
+
152
+ Args:
153
+ key_id: Key ID or fingerprint
154
+
155
+ Returns:
156
+ ASCII-armored public key
157
+ """
158
+ result = self._gpg.export_keys(key_id, armor=True)
159
+
160
+ if not result:
161
+ raise GPGError(f"Failed to export public key: {key_id}")
162
+
163
+ return str(result)
164
+
165
+ def export_private_key(self, key_id: str, passphrase: str | None = None) -> str:
166
+ """Export a private key in ASCII-armored format.
167
+
168
+ Args:
169
+ key_id: Key ID or fingerprint
170
+ passphrase: Passphrase for the key
171
+
172
+ Returns:
173
+ ASCII-armored private key
174
+ """
175
+ result = self._gpg.export_keys(key_id, secret=True, armor=True, passphrase=passphrase)
176
+
177
+ if not result:
178
+ raise GPGError(f"Failed to export private key: {key_id}")
179
+
180
+ return str(result)
181
+
182
+ def list_keys(self, secret: bool = False) -> list[GPGKeyInfo]:
183
+ """List all keys in the keyring.
184
+
185
+ Args:
186
+ secret: If True, list secret keys instead of public keys
187
+
188
+ Returns:
189
+ List of key information
190
+ """
191
+ keys = self._gpg.list_keys(secret=secret)
192
+ return [GPGKeyInfo.from_gpg_key(k) for k in keys]
193
+
194
+ def get_key(self, key_id: str) -> GPGKeyInfo | None:
195
+ """Get information about a specific key.
196
+
197
+ Args:
198
+ key_id: Key ID or fingerprint
199
+
200
+ Returns:
201
+ Key information or None if not found
202
+ """
203
+ keys = self._gpg.list_keys(keys=[key_id])
204
+ if not keys:
205
+ return None
206
+ return GPGKeyInfo.from_gpg_key(keys[0])
207
+
208
+ def delete_key(self, key_id: str, secret: bool = False) -> bool:
209
+ """Delete a key from the keyring.
210
+
211
+ Args:
212
+ key_id: Key ID or fingerprint
213
+ secret: If True, delete secret key first
214
+
215
+ Returns:
216
+ True if deleted successfully
217
+ """
218
+ if secret:
219
+ result = self._gpg.delete_keys(key_id, secret=True)
220
+ if not result.ok:
221
+ logger.warning("Failed to delete secret key", key_id=key_id)
222
+ return False
223
+
224
+ result = self._gpg.delete_keys(key_id)
225
+ return bool(result.ok)
226
+
227
+ def sign_data(self, data: str | bytes, key_id: str, passphrase: str | None = None) -> str:
228
+ """Sign data with a private key.
229
+
230
+ Args:
231
+ data: Data to sign
232
+ key_id: Key ID to sign with
233
+ passphrase: Key passphrase
234
+
235
+ Returns:
236
+ ASCII-armored signature
237
+ """
238
+ if isinstance(data, str):
239
+ data = data.encode()
240
+
241
+ result = self._gpg.sign(data, keyid=key_id, passphrase=passphrase, detach=True)
242
+
243
+ if not result.ok:
244
+ raise GPGError(f"Failed to sign data: {result.status}")
245
+
246
+ return str(result)
247
+
248
+ def verify_signature(self, data: str | bytes, signature: str) -> bool:
249
+ """Verify a detached signature.
250
+
251
+ Args:
252
+ data: Original data
253
+ signature: Detached signature
254
+
255
+ Returns:
256
+ True if signature is valid
257
+ """
258
+ if isinstance(data, str):
259
+ data = data.encode()
260
+
261
+ # Write data to temp file for verification
262
+ import tempfile
263
+
264
+ with tempfile.NamedTemporaryFile(delete=False) as f:
265
+ f.write(data)
266
+ data_file = f.name
267
+
268
+ try:
269
+ result = self._gpg.verify_data(signature, data)
270
+ return bool(result.valid)
271
+ finally:
272
+ Path(data_file).unlink(missing_ok=True)
273
+
274
+ def encrypt(
275
+ self,
276
+ data: str | bytes,
277
+ recipients: list[str],
278
+ sign: bool = False,
279
+ passphrase: str | None = None,
280
+ ) -> str:
281
+ """Encrypt data for one or more recipients.
282
+
283
+ Args:
284
+ data: Data to encrypt
285
+ recipients: List of recipient key IDs
286
+ sign: Whether to sign the encrypted data
287
+ passphrase: Signing key passphrase (if signing)
288
+
289
+ Returns:
290
+ ASCII-armored encrypted data
291
+ """
292
+ if isinstance(data, str):
293
+ data = data.encode()
294
+
295
+ result = self._gpg.encrypt(
296
+ data,
297
+ recipients,
298
+ sign=sign,
299
+ passphrase=passphrase,
300
+ armor=True,
301
+ )
302
+
303
+ if not result.ok:
304
+ raise GPGError(f"Failed to encrypt data: {result.status}")
305
+
306
+ return str(result)
307
+
308
+ def decrypt(self, data: str, passphrase: str | None = None) -> str:
309
+ """Decrypt data.
310
+
311
+ Args:
312
+ data: Encrypted data
313
+ passphrase: Decryption key passphrase
314
+
315
+ Returns:
316
+ Decrypted data
317
+ """
318
+ result = self._gpg.decrypt(data, passphrase=passphrase)
319
+
320
+ if not result.ok:
321
+ raise GPGError(f"Failed to decrypt data: {result.status}")
322
+
323
+ return str(result)
@@ -0,0 +1,219 @@
1
+ """Keyserver interactions for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import httpx
8
+
9
+ from redundanet.core.exceptions import KeyServerError
10
+ from redundanet.utils.logging import get_logger
11
+
12
+ if TYPE_CHECKING:
13
+ from redundanet.auth.gpg import GPGManager
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ # Well-known keyservers
18
+ DEFAULT_KEYSERVERS = [
19
+ "keys.openpgp.org",
20
+ "keyserver.ubuntu.com",
21
+ "pgp.mit.edu",
22
+ ]
23
+
24
+
25
+ class KeyServerClient:
26
+ """Client for interacting with GPG keyservers."""
27
+
28
+ def __init__(
29
+ self,
30
+ gpg_manager: GPGManager,
31
+ keyservers: list[str] | None = None,
32
+ timeout: float = 30.0,
33
+ ) -> None:
34
+ """Initialize keyserver client.
35
+
36
+ Args:
37
+ gpg_manager: GPG manager instance
38
+ keyservers: List of keyserver hostnames
39
+ timeout: HTTP request timeout in seconds
40
+ """
41
+ self.gpg = gpg_manager
42
+ self.keyservers = keyservers or DEFAULT_KEYSERVERS
43
+ self.timeout = timeout
44
+ self._client = httpx.Client(timeout=timeout)
45
+
46
+ def __del__(self) -> None:
47
+ """Clean up HTTP client."""
48
+ if hasattr(self, "_client"):
49
+ self._client.close()
50
+
51
+ def search_key(self, search_term: str) -> list[dict[str, str]]:
52
+ """Search for keys on keyservers.
53
+
54
+ Args:
55
+ search_term: Email, key ID, or name to search for
56
+
57
+ Returns:
58
+ List of matching keys with their info
59
+ """
60
+ results = []
61
+
62
+ for server in self.keyservers:
63
+ try:
64
+ url = f"https://{server}/pks/lookup"
65
+ params = {
66
+ "op": "index",
67
+ "search": search_term,
68
+ "options": "mr", # Machine readable
69
+ }
70
+
71
+ response = self._client.get(url, params=params)
72
+ response.raise_for_status()
73
+
74
+ # Parse machine-readable output
75
+ for line in response.text.split("\n"):
76
+ if line.startswith("pub:"):
77
+ parts = line.split(":")
78
+ if len(parts) >= 5:
79
+ results.append(
80
+ {
81
+ "key_id": parts[1],
82
+ "created": parts[4],
83
+ "keyserver": server,
84
+ }
85
+ )
86
+ elif line.startswith("uid:"):
87
+ parts = line.split(":")
88
+ if len(parts) >= 2 and results:
89
+ results[-1]["uid"] = parts[1]
90
+
91
+ if results:
92
+ break # Found results, no need to try other servers
93
+
94
+ except httpx.HTTPError as e:
95
+ logger.debug("Keyserver search failed", server=server, error=str(e))
96
+ continue
97
+
98
+ return results
99
+
100
+ def fetch_key(self, key_id: str) -> str | None:
101
+ """Fetch a key from keyservers.
102
+
103
+ Args:
104
+ key_id: Key ID or fingerprint
105
+
106
+ Returns:
107
+ ASCII-armored public key, or None if not found
108
+ """
109
+ # Clean up key ID
110
+ key_id = key_id.replace(" ", "").upper()
111
+ if not key_id.startswith("0x"):
112
+ key_id = f"0x{key_id}"
113
+
114
+ for server in self.keyservers:
115
+ try:
116
+ url = f"https://{server}/pks/lookup"
117
+ params = {
118
+ "op": "get",
119
+ "search": key_id,
120
+ "options": "mr",
121
+ }
122
+
123
+ response = self._client.get(url, params=params)
124
+ response.raise_for_status()
125
+
126
+ if "BEGIN PGP PUBLIC KEY BLOCK" in response.text:
127
+ logger.info("Fetched key from keyserver", key_id=key_id, server=server)
128
+ return response.text
129
+
130
+ except httpx.HTTPError as e:
131
+ logger.debug("Keyserver fetch failed", server=server, error=str(e))
132
+ continue
133
+
134
+ logger.warning("Key not found on any keyserver", key_id=key_id)
135
+ return None
136
+
137
+ def upload_key(self, key_id: str) -> bool:
138
+ """Upload a public key to keyservers.
139
+
140
+ Args:
141
+ key_id: Key ID to upload
142
+
143
+ Returns:
144
+ True if upload succeeded
145
+ """
146
+ try:
147
+ public_key = self.gpg.export_public_key(key_id)
148
+ except Exception as e:
149
+ raise KeyServerError(f"Failed to export key: {e}") from e
150
+
151
+ uploaded = False
152
+
153
+ for server in self.keyservers:
154
+ try:
155
+ url = f"https://{server}/pks/add"
156
+ data = {"keytext": public_key}
157
+
158
+ response = self._client.post(url, data=data)
159
+ response.raise_for_status()
160
+
161
+ logger.info("Uploaded key to keyserver", key_id=key_id, server=server)
162
+ uploaded = True
163
+
164
+ except httpx.HTTPError as e:
165
+ logger.debug("Keyserver upload failed", server=server, error=str(e))
166
+ continue
167
+
168
+ return uploaded
169
+
170
+ def import_key_from_server(self, key_id: str) -> bool:
171
+ """Fetch a key from keyservers and import it.
172
+
173
+ Args:
174
+ key_id: Key ID to fetch and import
175
+
176
+ Returns:
177
+ True if import succeeded
178
+ """
179
+ key_data = self.fetch_key(key_id)
180
+
181
+ if not key_data:
182
+ return False
183
+
184
+ try:
185
+ self.gpg.import_key(key_data)
186
+ return True
187
+ except Exception as e:
188
+ logger.error("Failed to import key", key_id=key_id, error=str(e))
189
+ return False
190
+
191
+ def verify_key_on_server(self, key_id: str) -> bool:
192
+ """Verify that a key exists on keyservers.
193
+
194
+ Args:
195
+ key_id: Key ID to verify
196
+
197
+ Returns:
198
+ True if key exists on at least one keyserver
199
+ """
200
+ return self.fetch_key(key_id) is not None
201
+
202
+ def refresh_keys(self, key_ids: list[str] | None = None) -> dict[str, bool]:
203
+ """Refresh keys from keyservers.
204
+
205
+ Args:
206
+ key_ids: List of key IDs to refresh, or None for all keys
207
+
208
+ Returns:
209
+ Dictionary mapping key ID to success status
210
+ """
211
+ if key_ids is None:
212
+ key_ids = [k.key_id for k in self.gpg.list_keys()]
213
+
214
+ results = {}
215
+
216
+ for key_id in key_ids:
217
+ results[key_id] = self.import_key_from_server(key_id)
218
+
219
+ return results
@@ -0,0 +1,5 @@
1
+ """CLI module for RedundaNet."""
2
+
3
+ from redundanet.cli.main import app
4
+
5
+ __all__ = ["app"]