firecloud-devnet 0.1.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.
- fc_mlops/__init__.py +3 -0
- fc_mlops/__main__.py +5 -0
- fc_mlops/anomaly.py +112 -0
- fc_mlops/artifact_store.py +111 -0
- fc_mlops/cli.py +190 -0
- fc_mlops/simulate_failure.py +100 -0
- fc_mlops/telemetry.py +72 -0
- fc_rag/__init__.py +3 -0
- fc_rag/cli.py +51 -0
- fc_rag/config.py +24 -0
- fc_rag/embedder.py +62 -0
- fc_rag/indexer.py +121 -0
- fc_rag/query_engine.py +79 -0
- fc_rag/requirements.txt +6 -0
- fc_rag/retriever.py +46 -0
- firecloud/__init__.py +17 -0
- firecloud/chunker.py +122 -0
- firecloud/cli.py +540 -0
- firecloud/crypto.py +269 -0
- firecloud/discovery.py +164 -0
- firecloud/distributor.py +269 -0
- firecloud/exceptions.py +41 -0
- firecloud/fec.py +87 -0
- firecloud/manifest.py +263 -0
- firecloud/network.py +90 -0
- firecloud/node.py +562 -0
- firecloud/storage.py +146 -0
- firecloud/sync.py +277 -0
- firecloud/transport.py +387 -0
- firecloud_devnet-0.1.0.dist-info/METADATA +158 -0
- firecloud_devnet-0.1.0.dist-info/RECORD +34 -0
- firecloud_devnet-0.1.0.dist-info/WHEEL +4 -0
- firecloud_devnet-0.1.0.dist-info/entry_points.txt +4 -0
- firecloud_devnet-0.1.0.dist-info/licenses/LICENSE +21 -0
firecloud/exceptions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""FireCloud exceptions — typed errors for all failure modes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FireCloudError(Exception):
|
|
5
|
+
"""Base exception for all FireCloud errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkKeyError(FireCloudError):
|
|
9
|
+
"""Wrong passphrase or corrupt keyfile."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeAuthError(FireCloudError):
|
|
13
|
+
"""Peer rejected authentication — invalid network token."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChunkNotFoundError(FireCloudError):
|
|
17
|
+
"""Chunk is missing from all known nodes and cannot be recovered."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChunkCorruptError(FireCloudError):
|
|
21
|
+
"""Chunk failed integrity check after decryption — data was tampered with."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InsufficientPeersError(FireCloudError):
|
|
25
|
+
"""Not enough online peers to satisfy the requested replication/FEC level."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StorageFullError(FireCloudError):
|
|
29
|
+
"""No node has enough storage capacity for the requested operation."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FileNotFoundError(FireCloudError):
|
|
33
|
+
"""The given file_id does not exist in the network manifest."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TransportError(FireCloudError):
|
|
37
|
+
"""Network transport failure — connection refused, timeout, or protocol error."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DiscoveryError(FireCloudError):
|
|
41
|
+
"""mDNS discovery or peer configuration error."""
|
firecloud/fec.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""FireCloud Forward Error Correction (FEC) Engine.
|
|
2
|
+
|
|
3
|
+
Wraps zfec to encode arbitrary bytes into N shares where any K can reconstruct
|
|
4
|
+
the original data. Handles padding and size restoration transparently.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import struct
|
|
9
|
+
import zfec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_n(k: int, expansion: float = 1.5) -> int:
|
|
13
|
+
"""Compute the total number of shares N given the reconstruction threshold K.
|
|
14
|
+
|
|
15
|
+
N is calculated as ceil(K * expansion).
|
|
16
|
+
"""
|
|
17
|
+
return math.ceil(k * expansion)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode(data: bytes, k: int, n: int) -> list[bytes]:
|
|
21
|
+
"""Encode arbitrary bytes into N shares. Any K shares can reconstruct.
|
|
22
|
+
|
|
23
|
+
The original length is prefixed to the data so that padding can be
|
|
24
|
+
correctly stripped during decoding.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
data: The input bytes to encode.
|
|
28
|
+
k: The threshold number of shares needed for decoding.
|
|
29
|
+
n: The total number of shares to generate.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A list of N shares (each as bytes).
|
|
33
|
+
"""
|
|
34
|
+
if k <= 0 or n < k:
|
|
35
|
+
raise ValueError("Invalid FEC parameters: K must be > 0 and N >= K")
|
|
36
|
+
|
|
37
|
+
original_len = len(data)
|
|
38
|
+
# Prefix the original length (8 bytes) to the data
|
|
39
|
+
prepended = struct.pack("!Q", original_len) + data
|
|
40
|
+
|
|
41
|
+
# Pad prepended data so its length is a multiple of K
|
|
42
|
+
pad_len = (k - (len(prepended) % k)) % k
|
|
43
|
+
padded = prepended + (b"\x00" * pad_len)
|
|
44
|
+
|
|
45
|
+
# Split into K equal blocks
|
|
46
|
+
block_size = len(padded) // k
|
|
47
|
+
blocks = [padded[i * block_size : (i + 1) * block_size] for i in range(k)]
|
|
48
|
+
|
|
49
|
+
# Run zfec encoder
|
|
50
|
+
encoder = zfec.Encoder(k, n)
|
|
51
|
+
shares = encoder.encode(blocks)
|
|
52
|
+
return shares
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def decode(shares: list[tuple[int, bytes]], k: int) -> bytes:
|
|
56
|
+
"""Decode K shares back to the original bytes.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
shares: A list of tuples containing (blocknum, share_data).
|
|
60
|
+
k: The threshold number of shares required.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The reconstructed original bytes.
|
|
64
|
+
"""
|
|
65
|
+
if len(shares) < k:
|
|
66
|
+
raise ValueError(f"Insufficient shares: need at least {k}, got {len(shares)}")
|
|
67
|
+
|
|
68
|
+
# Take the first K shares
|
|
69
|
+
k_shares = shares[:k]
|
|
70
|
+
blocknums = [s[0] for s in k_shares]
|
|
71
|
+
blocks = [s[1] for s in k_shares]
|
|
72
|
+
|
|
73
|
+
# Instantiate decoder. We need m (which is N).
|
|
74
|
+
# We can infer N from the maximum block number in our shares.
|
|
75
|
+
max_blocknum = max(blocknums)
|
|
76
|
+
n = max(max_blocknum + 1, k)
|
|
77
|
+
|
|
78
|
+
decoder = zfec.Decoder(k, n)
|
|
79
|
+
decoded_blocks = decoder.decode(blocks, blocknums)
|
|
80
|
+
padded = b"".join(decoded_blocks)
|
|
81
|
+
|
|
82
|
+
# Extract original length and strip padding
|
|
83
|
+
if len(padded) < 8:
|
|
84
|
+
raise ValueError("Decoded data is too short to contain length prefix")
|
|
85
|
+
|
|
86
|
+
original_len = struct.unpack("!Q", padded[:8])[0]
|
|
87
|
+
return padded[8 : 8 + original_len]
|
firecloud/manifest.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""JSON-backed file manifest with Lamport timestamps and tombstone support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
from datetime import datetime, timezone, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from firecloud.exceptions import FileNotFoundError as ManifestFileNotFoundError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ------------------------------------------------------------------
|
|
13
|
+
# Data classes
|
|
14
|
+
# ------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ChunkInfo:
|
|
19
|
+
"""Metadata for a single chunk belonging to a file."""
|
|
20
|
+
|
|
21
|
+
chunk_id: str
|
|
22
|
+
integrity_hash: str
|
|
23
|
+
index: int
|
|
24
|
+
size: int
|
|
25
|
+
stored_on: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FileEntry:
|
|
30
|
+
"""Metadata for a file tracked by the manifest."""
|
|
31
|
+
|
|
32
|
+
file_id: str
|
|
33
|
+
name: str
|
|
34
|
+
size: int
|
|
35
|
+
chunk_count: int
|
|
36
|
+
uploaded_at: str # ISO 8601
|
|
37
|
+
uploaded_by: str # node ID
|
|
38
|
+
lamport_ts: int = 0
|
|
39
|
+
chunks: list[ChunkInfo] = field(default_factory=list)
|
|
40
|
+
fec_enabled: bool = False
|
|
41
|
+
replication_factor: int = 1
|
|
42
|
+
deleted: bool = False # tombstone flag
|
|
43
|
+
deleted_at: str | None = None # ISO 8601 when tombstoned
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Manifest
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Manifest:
|
|
52
|
+
"""Thread-safe, JSON-backed file manifest with Lamport clock.
|
|
53
|
+
|
|
54
|
+
The manifest is persisted as a JSON file at
|
|
55
|
+
``{storage_path}/manifest.json``. Every mutation increments a Lamport
|
|
56
|
+
clock so that distributed nodes can merge their manifests using a
|
|
57
|
+
last-writer-wins strategy.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, storage_path: Path | str) -> None:
|
|
61
|
+
"""Initialise the manifest.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
storage_path: Directory that will contain ``manifest.json``.
|
|
65
|
+
"""
|
|
66
|
+
self._storage_path = Path(storage_path)
|
|
67
|
+
self._storage_path.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
self._manifest_file = self._storage_path / "manifest.json"
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
self._clock: int = 0
|
|
71
|
+
self._entries: dict[str, FileEntry] = {}
|
|
72
|
+
self.load()
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Public API
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def add_file(self, entry: FileEntry) -> None:
|
|
79
|
+
"""Add or update a file entry.
|
|
80
|
+
|
|
81
|
+
The Lamport clock is incremented and the new timestamp is written
|
|
82
|
+
onto *entry* before it is stored.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
entry: The :class:`FileEntry` to insert or update.
|
|
86
|
+
"""
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._clock += 1
|
|
89
|
+
entry.lamport_ts = self._clock
|
|
90
|
+
self._entries[entry.file_id] = entry
|
|
91
|
+
self._save_unlocked()
|
|
92
|
+
|
|
93
|
+
def get_file(self, file_id: str) -> FileEntry:
|
|
94
|
+
"""Retrieve a file entry by its ID.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
file_id: Unique identifier of the file.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The corresponding :class:`FileEntry`.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
firecloud.exceptions.FileNotFoundError: If the file is absent
|
|
104
|
+
or has been tombstoned.
|
|
105
|
+
"""
|
|
106
|
+
with self._lock:
|
|
107
|
+
entry = self._entries.get(file_id)
|
|
108
|
+
if entry is None or entry.deleted:
|
|
109
|
+
raise ManifestFileNotFoundError(
|
|
110
|
+
f"File {file_id} not found in manifest"
|
|
111
|
+
)
|
|
112
|
+
return entry
|
|
113
|
+
|
|
114
|
+
def delete_file(self, file_id: str) -> None:
|
|
115
|
+
"""Tombstone a file entry.
|
|
116
|
+
|
|
117
|
+
The Lamport clock is incremented and the entry is marked as deleted
|
|
118
|
+
with the current UTC time.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
file_id: Unique identifier of the file.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
firecloud.exceptions.FileNotFoundError: If the file does not
|
|
125
|
+
exist in the manifest.
|
|
126
|
+
"""
|
|
127
|
+
with self._lock:
|
|
128
|
+
entry = self._entries.get(file_id)
|
|
129
|
+
if entry is None:
|
|
130
|
+
raise ManifestFileNotFoundError(
|
|
131
|
+
f"File {file_id} not found in manifest"
|
|
132
|
+
)
|
|
133
|
+
self._clock += 1
|
|
134
|
+
entry.lamport_ts = self._clock
|
|
135
|
+
entry.deleted = True
|
|
136
|
+
entry.deleted_at = datetime.now(timezone.utc).isoformat()
|
|
137
|
+
self._save_unlocked()
|
|
138
|
+
|
|
139
|
+
def list_files(self, include_deleted: bool = False) -> list[FileEntry]:
|
|
140
|
+
"""Return file entries tracked by the manifest.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
include_deleted: When ``True``, tombstoned entries are included.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A list of :class:`FileEntry` instances.
|
|
147
|
+
"""
|
|
148
|
+
with self._lock:
|
|
149
|
+
if include_deleted:
|
|
150
|
+
return list(self._entries.values())
|
|
151
|
+
return [e for e in self._entries.values() if not e.deleted]
|
|
152
|
+
|
|
153
|
+
def merge(self, remote_entries: list[FileEntry]) -> None:
|
|
154
|
+
"""Merge remote manifest entries using last-writer-wins.
|
|
155
|
+
|
|
156
|
+
For each remote entry the local entry is replaced only when the
|
|
157
|
+
remote Lamport timestamp is strictly greater. New file IDs are
|
|
158
|
+
always accepted. The local clock is advanced to the maximum of the
|
|
159
|
+
local clock and the highest remote timestamp.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
remote_entries: Entries received from a remote node.
|
|
163
|
+
"""
|
|
164
|
+
with self._lock:
|
|
165
|
+
for remote in remote_entries:
|
|
166
|
+
local = self._entries.get(remote.file_id)
|
|
167
|
+
if local is None or remote.lamport_ts > local.lamport_ts:
|
|
168
|
+
self._entries[remote.file_id] = remote
|
|
169
|
+
# Advance clock to at least the remote timestamp.
|
|
170
|
+
if remote.lamport_ts > self._clock:
|
|
171
|
+
self._clock = remote.lamport_ts
|
|
172
|
+
self._save_unlocked()
|
|
173
|
+
|
|
174
|
+
def increment_clock(self) -> int:
|
|
175
|
+
"""Increment and return the Lamport clock.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The new clock value.
|
|
179
|
+
"""
|
|
180
|
+
with self._lock:
|
|
181
|
+
self._clock += 1
|
|
182
|
+
return self._clock
|
|
183
|
+
|
|
184
|
+
def gc_tombstones(self, max_age_days: int = 30) -> int:
|
|
185
|
+
"""Remove tombstoned entries older than *max_age_days*.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
max_age_days: Number of days after which a tombstone is eligible
|
|
189
|
+
for garbage collection.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The number of entries removed.
|
|
193
|
+
"""
|
|
194
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
|
195
|
+
removed = 0
|
|
196
|
+
with self._lock:
|
|
197
|
+
to_remove: list[str] = []
|
|
198
|
+
for file_id, entry in self._entries.items():
|
|
199
|
+
if entry.deleted and entry.deleted_at is not None:
|
|
200
|
+
deleted_dt = datetime.fromisoformat(entry.deleted_at)
|
|
201
|
+
if deleted_dt < cutoff:
|
|
202
|
+
to_remove.append(file_id)
|
|
203
|
+
for file_id in to_remove:
|
|
204
|
+
del self._entries[file_id]
|
|
205
|
+
removed += 1
|
|
206
|
+
if removed:
|
|
207
|
+
self._save_unlocked()
|
|
208
|
+
return removed
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
# Serialisation helpers
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def to_dict(self) -> dict:
|
|
215
|
+
"""Serialise the entire manifest to a plain ``dict``."""
|
|
216
|
+
return {
|
|
217
|
+
"clock": self._clock,
|
|
218
|
+
"entries": {
|
|
219
|
+
fid: asdict(entry)
|
|
220
|
+
for fid, entry in self._entries.items()
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def to_entries(self) -> list[FileEntry]:
|
|
225
|
+
"""Return all entries (including tombstones) for sync."""
|
|
226
|
+
with self._lock:
|
|
227
|
+
return list(self._entries.values())
|
|
228
|
+
|
|
229
|
+
def save(self) -> None:
|
|
230
|
+
"""Persist the manifest to disk."""
|
|
231
|
+
with self._lock:
|
|
232
|
+
self._save_unlocked()
|
|
233
|
+
|
|
234
|
+
def load(self) -> None:
|
|
235
|
+
"""Load the manifest from disk.
|
|
236
|
+
|
|
237
|
+
If the manifest file does not exist an empty manifest is created.
|
|
238
|
+
"""
|
|
239
|
+
with self._lock:
|
|
240
|
+
if not self._manifest_file.is_file():
|
|
241
|
+
self._clock = 0
|
|
242
|
+
self._entries = {}
|
|
243
|
+
return
|
|
244
|
+
raw = json.loads(self._manifest_file.read_text(encoding="utf-8"))
|
|
245
|
+
self._clock = raw.get("clock", 0)
|
|
246
|
+
self._entries = {}
|
|
247
|
+
for fid, edict in raw.get("entries", {}).items():
|
|
248
|
+
# Reconstruct ChunkInfo objects.
|
|
249
|
+
chunks = [
|
|
250
|
+
ChunkInfo(**ci) for ci in edict.pop("chunks", [])
|
|
251
|
+
]
|
|
252
|
+
self._entries[fid] = FileEntry(**edict, chunks=chunks)
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Private helpers
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _save_unlocked(self) -> None:
|
|
259
|
+
"""Write manifest JSON to disk (caller must hold ``_lock``)."""
|
|
260
|
+
self._manifest_file.write_text(
|
|
261
|
+
json.dumps(self.to_dict(), indent=2, default=str),
|
|
262
|
+
encoding="utf-8",
|
|
263
|
+
)
|
firecloud/network.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""FireCloud Network Key Management.
|
|
2
|
+
|
|
3
|
+
Handles network key generation, loading/saving passphrase-wrapped keystores,
|
|
4
|
+
and accessing derived sub-keys for encryption, HMAC, and authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from firecloud.crypto import (
|
|
11
|
+
generate_network_key,
|
|
12
|
+
encrypt_keystore,
|
|
13
|
+
decrypt_keystore,
|
|
14
|
+
derive_auth_token,
|
|
15
|
+
derive_encryption_key,
|
|
16
|
+
derive_hmac_key,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Network:
|
|
21
|
+
"""Manages the network-wide key and derives sub-keys.
|
|
22
|
+
|
|
23
|
+
The network key is protected on disk using a passphrase-derived key.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, key: bytes, passphrase: str | None = None) -> None:
|
|
27
|
+
"""Initialize the network with a key.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
key: The 32-byte network key.
|
|
31
|
+
passphrase: Optional passphrase associated with this network key.
|
|
32
|
+
"""
|
|
33
|
+
self.key = key
|
|
34
|
+
self.passphrase = passphrase
|
|
35
|
+
# network_id is the first 8 bytes of SHA-256 of the network key, hex-encoded
|
|
36
|
+
self.network_id = hashlib.sha256(key).digest()[:8].hex()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def create(cls, passphrase: str) -> "Network":
|
|
40
|
+
"""Create a new network with a fresh cryptographically random key.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
passphrase: The passphrase to protect the new network key.
|
|
44
|
+
"""
|
|
45
|
+
key = generate_network_key()
|
|
46
|
+
return cls(key, passphrase)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def load(cls, path: Path | str, passphrase: str) -> "Network":
|
|
50
|
+
"""Load a network key from a passphrase-protected keystore file.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path: Path to the keystore file.
|
|
54
|
+
passphrase: Passphrase to decrypt the keystore.
|
|
55
|
+
"""
|
|
56
|
+
path = Path(path)
|
|
57
|
+
encrypted_key = path.read_bytes()
|
|
58
|
+
key = decrypt_keystore(encrypted_key, passphrase)
|
|
59
|
+
return cls(key, passphrase)
|
|
60
|
+
|
|
61
|
+
def save(self, path: Path | str, passphrase: str | None = None) -> None:
|
|
62
|
+
"""Save the network key to a passphrase-protected keystore file.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Path to write the keystore file.
|
|
66
|
+
passphrase: Optional passphrase to use. Defaults to the passphrase
|
|
67
|
+
associated with this instance if not provided.
|
|
68
|
+
"""
|
|
69
|
+
path = Path(path)
|
|
70
|
+
enc_pass = passphrase or self.passphrase
|
|
71
|
+
if not enc_pass:
|
|
72
|
+
raise ValueError("Passphrase required to encrypt and save network key")
|
|
73
|
+
|
|
74
|
+
encrypted_key = encrypt_keystore(self.key, enc_pass)
|
|
75
|
+
path.write_bytes(encrypted_key)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def auth_token(self) -> bytes:
|
|
79
|
+
"""Derive the network authentication token."""
|
|
80
|
+
return derive_auth_token(self.key)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def encryption_key(self) -> bytes:
|
|
84
|
+
"""Derive the symmetric key used for chunk encryption."""
|
|
85
|
+
return derive_encryption_key(self.key)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def hmac_key(self) -> bytes:
|
|
89
|
+
"""Derive the keyed HMAC addressing key used for chunk IDs."""
|
|
90
|
+
return derive_hmac_key(self.key)
|