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 +16 -0
- redundanet/__main__.py +6 -0
- redundanet/auth/__init__.py +9 -0
- redundanet/auth/gpg.py +323 -0
- redundanet/auth/keyserver.py +219 -0
- redundanet/cli/__init__.py +5 -0
- redundanet/cli/main.py +247 -0
- redundanet/cli/network.py +194 -0
- redundanet/cli/node.py +305 -0
- redundanet/cli/storage.py +267 -0
- redundanet/core/__init__.py +31 -0
- redundanet/core/config.py +200 -0
- redundanet/core/exceptions.py +84 -0
- redundanet/core/manifest.py +325 -0
- redundanet/core/node.py +135 -0
- redundanet/network/__init__.py +11 -0
- redundanet/network/discovery.py +218 -0
- redundanet/network/dns.py +180 -0
- redundanet/network/validation.py +279 -0
- redundanet/storage/__init__.py +13 -0
- redundanet/storage/client.py +306 -0
- redundanet/storage/furl.py +196 -0
- redundanet/storage/introducer.py +175 -0
- redundanet/storage/storage.py +195 -0
- redundanet/utils/__init__.py +15 -0
- redundanet/utils/files.py +165 -0
- redundanet/utils/logging.py +93 -0
- redundanet/utils/process.py +226 -0
- redundanet/vpn/__init__.py +12 -0
- redundanet/vpn/keys.py +173 -0
- redundanet/vpn/mesh.py +201 -0
- redundanet/vpn/tinc.py +323 -0
- redundanet-2.0.0.dist-info/LICENSE +674 -0
- redundanet-2.0.0.dist-info/METADATA +265 -0
- redundanet-2.0.0.dist-info/RECORD +37 -0
- redundanet-2.0.0.dist-info/WHEEL +4 -0
- redundanet-2.0.0.dist-info/entry_points.txt +3 -0
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
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
|