hbkit 0.4.5__tar.gz → 0.4.7__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.
- {hbkit-0.4.5 → hbkit-0.4.7}/FORMAT.md +19 -6
- {hbkit-0.4.5 → hbkit-0.4.7}/PKG-INFO +10 -20
- {hbkit-0.4.5 → hbkit-0.4.7}/README.md +9 -19
- hbkit-0.4.7/contrib/art/README.md +22 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/pyproject.toml +1 -1
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/__init__.py +1 -1
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/archive.py +39 -15
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/cli.py +9 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/crypto.py +13 -5
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/doctor.py +15 -4
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/tui.py +12 -7
- {hbkit-0.4.5 → hbkit-0.4.7}/tests/test_hbkit.py +109 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/.gitignore +0 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/LICENSE +0 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/packaging/README.md +0 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/index.py +0 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/manifest.py +0 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/mount.py +0 -0
- {hbkit-0.4.5 → hbkit-0.4.7}/src/hbkit/runner.py +0 -0
|
@@ -16,8 +16,9 @@ in file-signature databases, or in DFIR tooling.
|
|
|
16
16
|
|
|
17
17
|
Coverage caveat: this was derived from **one** archive — DSM 7, Hyper Backup 4.1.2-4039,
|
|
18
18
|
unencrypted, LZ4, single version, single pool, plus a second small **encrypted** archive
|
|
19
|
-
from the same DSM version.
|
|
20
|
-
|
|
19
|
+
from the same DSM version. A user's 7-version encrypted archive (Hyper Backup 4.2.2,
|
|
20
|
+
issue #4) later corrected §7.2. Variant handling marked [D] is implemented from
|
|
21
|
+
disassembly but has never met a real archive of that kind.
|
|
21
22
|
|
|
22
23
|
---
|
|
23
24
|
|
|
@@ -254,7 +255,7 @@ Extra files in an encrypted archive:
|
|
|
254
255
|
|---|---|
|
|
255
256
|
| `Config/public.pem[.n]` | raw 32-byte X25519 **public** key (not PEM despite the name) |
|
|
256
257
|
| `Config/encKeys[.n]` | header only: magic `ekhtar`, u16 version, u8 KeyWrapType |
|
|
257
|
-
| `Pool/vkey.db[.n]` | `vkey(version_id, rsa_vkey, rsa_vkey_iv, checksum, ref_count)
|
|
258
|
+
| `Pool/vkey.db[.n]` | `vkey(version_id, rsa_vkey, rsa_vkey_iv, checksum, ref_count)`, one row per backup version |
|
|
258
259
|
|
|
259
260
|
Despite the `rsa_` column names, no RSA is used in this version: the blobs are libsodium
|
|
260
261
|
**sealed boxes** (X25519 + XSalsa20-Poly1305). Sizes give it away — 80 = 48 overhead + 32
|
|
@@ -282,10 +283,22 @@ iv = crypto_box_seal_open(vkey.rsa_vkey_iv, pub, priv) # 16 bytes
|
|
|
282
283
|
plain = LZ4( PKCS7-strip( AES-256-CBC-decrypt(ciphertext, key, iv) ) )
|
|
283
284
|
```
|
|
284
285
|
|
|
286
|
+
**Every backup version has its own key and IV**: one `vkey.db` row per version, all
|
|
287
|
+
unsealed with the same keypair, and each version encrypts only the chunks it writes.
|
|
288
|
+
Dedup means a file from the newest version can mix chunks from all of them. On a
|
|
289
|
+
7-version archive (Hyper Backup 4.2.2, issue #4), every one of 399,676 chunks decoded
|
|
290
|
+
under exactly one key, the keys covered contiguous bucket ranges, and each row's
|
|
291
|
+
`ref_count` equalled the number of buckets in its range. [V, from that report]
|
|
292
|
+
|
|
293
|
+
No field found so far names the version that wrote a bucket, so hbkit tries the key that
|
|
294
|
+
last worked for the bucket, then the others, and accepts one only when the decompressed
|
|
295
|
+
output matches the chunk's MD5. A wrong key can occasionally still produce a valid LZ4
|
|
296
|
+
block of the right length, so the MD5 test is required even when verification is off.
|
|
297
|
+
|
|
285
298
|
The IV is fixed per backup version, so identical plaintext chunks produce identical
|
|
286
|
-
ciphertext — visible directly in a bucket file, and a useful sanity
|
|
287
|
-
implementing. The `uncomp_len` and MD5 in the bucket index still describe the
|
|
288
|
-
decompressed plaintext, so the existing verification works unchanged.
|
|
299
|
+
ciphertext within a version — visible directly in a bucket file, and a useful sanity
|
|
300
|
+
check when implementing. The `uncomp_len` and MD5 in the bucket index still describe the
|
|
301
|
+
*final* decompressed plaintext, so the existing verification works unchanged.
|
|
289
302
|
|
|
290
303
|
### 7.3 Filenames [V]
|
|
291
304
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hbkit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: Recover files from Synology Hyper Backup (.hbk) archives without Synology software
|
|
5
5
|
Project-URL: Homepage, https://github.com/YordiLorenzo/hbkit
|
|
6
6
|
Project-URL: Source, https://github.com/YordiLorenzo/hbkit
|
|
@@ -27,7 +27,9 @@ Provides-Extra: dev
|
|
|
27
27
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
|
|
32
|
+
</p>
|
|
31
33
|
|
|
32
34
|
[](https://pypi.org/project/hbkit/)
|
|
33
35
|
[](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
|
|
@@ -39,6 +41,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
|
|
|
39
41
|
a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
|
|
40
42
|
own Hyper Backup Explorer is awkward or unavailable.
|
|
41
43
|
|
|
44
|
+
Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
|
|
45
|
+
|
|
42
46
|
```sh
|
|
43
47
|
brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
|
|
44
48
|
|
|
@@ -77,22 +81,7 @@ case is a loud failure naming the file.
|
|
|
77
81
|
|
|
78
82
|
## The TUI
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
⭘ Hyper Backup Recovery 17:11:17
|
|
82
|
-
┌──────────────────────────────────────────────────────────────┐ │
|
|
83
|
-
│ search filename… (/) │ │ Selection
|
|
84
|
-
└──────────────────────────────────────────────────────────────┘ │ 197,607 files
|
|
85
|
-
▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
|
|
86
|
-
├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
|
|
87
|
-
├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
|
|
88
|
-
├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
|
|
89
|
-
├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
|
|
90
|
-
├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
|
|
91
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
|
|
92
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
|
|
93
|
-
│ Start recovery
|
|
94
|
-
a All n Clear d Destination r Recover / Search q Quit │
|
|
95
|
-
```
|
|
84
|
+

|
|
96
85
|
|
|
97
86
|
`space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
|
|
98
87
|
`ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
|
|
@@ -280,8 +269,9 @@ Read this before trusting it with the only copy of anything.
|
|
|
280
269
|
contain plaintext names (not file contents). Delete it if that matters to you.
|
|
281
270
|
- **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
|
|
282
271
|
archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
|
|
283
|
-
the same DSM version, both verified byte-exact
|
|
284
|
-
|
|
272
|
+
the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
|
|
273
|
+
Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
|
|
274
|
+
Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
|
|
285
275
|
are implemented from disassembly but have not met a real archive of that kind. `doctor`
|
|
286
276
|
exists precisely so you can find out in seconds rather than mid-restore.
|
|
287
277
|
- **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
5
|
[](https://pypi.org/project/hbkit/)
|
|
4
6
|
[](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
|
|
@@ -10,6 +12,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
|
|
|
10
12
|
a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
|
|
11
13
|
own Hyper Backup Explorer is awkward or unavailable.
|
|
12
14
|
|
|
15
|
+
Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
|
|
16
|
+
|
|
13
17
|
```sh
|
|
14
18
|
brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
|
|
15
19
|
|
|
@@ -48,22 +52,7 @@ case is a loud failure naming the file.
|
|
|
48
52
|
|
|
49
53
|
## The TUI
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
⭘ Hyper Backup Recovery 17:11:17
|
|
53
|
-
┌──────────────────────────────────────────────────────────────┐ │
|
|
54
|
-
│ search filename… (/) │ │ Selection
|
|
55
|
-
└──────────────────────────────────────────────────────────────┘ │ 197,607 files
|
|
56
|
-
▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
|
|
57
|
-
├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
|
|
58
|
-
├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
|
|
59
|
-
├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
|
|
60
|
-
├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
|
|
61
|
-
├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
|
|
62
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
|
|
63
|
-
│ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
|
|
64
|
-
│ Start recovery
|
|
65
|
-
a All n Clear d Destination r Recover / Search q Quit │
|
|
66
|
-
```
|
|
55
|
+

|
|
67
56
|
|
|
68
57
|
`space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
|
|
69
58
|
`ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
|
|
@@ -251,8 +240,9 @@ Read this before trusting it with the only copy of anything.
|
|
|
251
240
|
contain plaintext names (not file contents). Delete it if that matters to you.
|
|
252
241
|
- **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
|
|
253
242
|
archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
|
|
254
|
-
the same DSM version, both verified byte-exact
|
|
255
|
-
|
|
243
|
+
the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
|
|
244
|
+
Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
|
|
245
|
+
Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
|
|
256
246
|
are implemented from disassembly but have not met a real archive of that kind. `doctor`
|
|
257
247
|
exists precisely so you can find out in seconds rather than mid-restore.
|
|
258
248
|
- **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Art
|
|
2
|
+
|
|
3
|
+
Every image in `docs/images/` and `site/` is built from the files here, so the screenshots
|
|
4
|
+
can be redone whenever the TUI changes. Run from anywhere:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install -e . fonttools brotli # hbkit itself renders the TUI screenshot
|
|
8
|
+
brew install webp imagemagick # plus Google Chrome, used headless
|
|
9
|
+
contrib/art/render.sh
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| source | builds |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `site/images/icon.svg` | the icon, drawn by hand; everything else derives from it |
|
|
15
|
+
| `logo.py` | `docs/images/logo.svg` and `logo-light.svg`: the icon and the wordmark, in Outfit SemiBold as outlines |
|
|
16
|
+
| `render_tui.py`, `tui.html` | `docs/images/tui.png` and the site's `tui-*.webp`: hbkit's real browser screen over a made-up archive |
|
|
17
|
+
| `banner.html` | `docs/images/banner.png`, the README header |
|
|
18
|
+
| `social.html` | `site/social-preview.png`, the link preview card |
|
|
19
|
+
| `icon.html` | `docs/images/icon-1024.png` |
|
|
20
|
+
|
|
21
|
+
The demo archive in `render_tui.py` is invented: no real backup or personal data goes into
|
|
22
|
+
any image. Outfit is under the SIL Open Font License (`site/fonts/OFL.txt`).
|
|
@@ -81,6 +81,17 @@ def lz4():
|
|
|
81
81
|
return _lz4
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _decompress(raw: bytes, ulen: int) -> bytes | None:
|
|
85
|
+
"""A chunk payload: a raw LZ4 block, else zlib (compress type 4). None if neither fits."""
|
|
86
|
+
dst = ctypes.create_string_buffer(ulen)
|
|
87
|
+
if lz4().LZ4_decompress_safe(raw, dst, len(raw), ulen) == ulen:
|
|
88
|
+
return dst.raw
|
|
89
|
+
try:
|
|
90
|
+
return zlib.decompress(raw)
|
|
91
|
+
except zlib.error:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
84
95
|
# ------------------------------------------------------------ path resolution
|
|
85
96
|
|
|
86
97
|
_SHARD = re.compile(r"^(\d+)\.idx(?:\.(\d+))?$")
|
|
@@ -280,6 +291,7 @@ class Archive:
|
|
|
280
291
|
self._bidx: OrderedDict[int, bytes] = OrderedDict()
|
|
281
292
|
self._bdat: OrderedDict[int, object] = OrderedDict()
|
|
282
293
|
self._brec: dict[int, int] = {}
|
|
294
|
+
self._bkey: dict[int, int] = {} # bucket id -> index into crypto.data_keys
|
|
283
295
|
self._password = None
|
|
284
296
|
if self.is_encrypted():
|
|
285
297
|
if password is None:
|
|
@@ -311,7 +323,7 @@ class Archive:
|
|
|
311
323
|
return out
|
|
312
324
|
|
|
313
325
|
def _open_crypto(self, password: str):
|
|
314
|
-
"""Unwrap
|
|
326
|
+
"""Unwrap every version's data key using the password. Raises WrongPassword on mismatch."""
|
|
315
327
|
import sqlite3
|
|
316
328
|
|
|
317
329
|
from .crypto import ArchiveCrypto
|
|
@@ -323,11 +335,11 @@ class Archive:
|
|
|
323
335
|
with open(resolve(f"{self.root}/Config", "public.pem"), "rb") as fh:
|
|
324
336
|
pub = fh.read()
|
|
325
337
|
db = sqlite3.connect(f"file:{resolve(f'{self.root}/Pool', 'vkey.db')}?immutable=1", uri=True)
|
|
326
|
-
|
|
338
|
+
rows = db.execute("SELECT rsa_vkey, rsa_vkey_iv FROM vkey ORDER BY version_id").fetchall()
|
|
327
339
|
db.close()
|
|
328
|
-
if not
|
|
340
|
+
if not rows:
|
|
329
341
|
raise UnsupportedArchive("encrypted archive has no rows in vkey db")
|
|
330
|
-
return ArchiveCrypto(password, unikey, pub, bytes(
|
|
342
|
+
return ArchiveCrypto(password, unikey, pub, [(bytes(k), bytes(iv)) for k, iv in rows])
|
|
331
343
|
|
|
332
344
|
def decrypt_name(self, stored: str) -> str:
|
|
333
345
|
"""Decrypt a stored filename, or return it unchanged for plaintext archives."""
|
|
@@ -415,21 +427,33 @@ class Archive:
|
|
|
415
427
|
raise ValueError(f"bucket index CRC32 mismatch b{bid}@{boff}")
|
|
416
428
|
fh.seek(off)
|
|
417
429
|
raw = fh.read(clen)
|
|
430
|
+
md5 = rec[12:28]
|
|
418
431
|
if self.crypto is not None:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if n != ulen:
|
|
425
|
-
try:
|
|
426
|
-
data = zlib.decompress(raw) # compress type 4
|
|
427
|
-
except Exception:
|
|
428
|
-
raise ValueError(f"decompress failed b{bid}@{boff} lz4={n} want={ulen}") from None
|
|
429
|
-
if verify and hashlib.md5(data).digest() != rec[12:28]:
|
|
432
|
+
return self._decrypt(bid, boff, raw, ulen, md5)
|
|
433
|
+
data = _decompress(raw, ulen)
|
|
434
|
+
if data is None:
|
|
435
|
+
raise ValueError(f"decompress failed b{bid}@{boff} want={ulen}")
|
|
436
|
+
if verify and hashlib.md5(data).digest() != md5:
|
|
430
437
|
raise ValueError(f"MD5 mismatch b{bid}@{boff}")
|
|
431
438
|
return data
|
|
432
439
|
|
|
440
|
+
def _decrypt(self, bid: int, boff: int, raw: bytes, ulen: int, md5: bytes) -> bytes:
|
|
441
|
+
"""Decrypt and decompress one chunk of an encrypted archive.
|
|
442
|
+
|
|
443
|
+
Each backup version encrypts its chunks with its own data key, and nothing records
|
|
444
|
+
which version wrote a bucket. Try the key that last worked for this bucket, then
|
|
445
|
+
the others, and accept a key only when the output matches the chunk's MD5, whatever
|
|
446
|
+
`verify` says: a wrong key can still decompress to the right length.
|
|
447
|
+
"""
|
|
448
|
+
n = len(self.crypto.data_keys)
|
|
449
|
+
first = self._bkey.get(bid, 0)
|
|
450
|
+
for k in (first, *(i for i in range(n) if i != first)):
|
|
451
|
+
data = _decompress(self.crypto.decrypt_chunk(raw, k), ulen)
|
|
452
|
+
if data is not None and hashlib.md5(data).digest() == md5:
|
|
453
|
+
self._bkey[bid] = k
|
|
454
|
+
return data
|
|
455
|
+
raise ValueError(f"no data key decodes b{bid}@{boff} ({n} tried, want={ulen})")
|
|
456
|
+
|
|
433
457
|
def extract(self, ovf: int, size: int, verify: bool = True, out=None, on_bytes=None):
|
|
434
458
|
"""Rebuild one file. Writes to `out` if given (returns byte count), else returns bytes.
|
|
435
459
|
|
|
@@ -268,6 +268,15 @@ def main() -> int:
|
|
|
268
268
|
print(f" share {s} versions={arc.share_versions(s)}")
|
|
269
269
|
return 0
|
|
270
270
|
|
|
271
|
+
if cmd in ("get", "verify"):
|
|
272
|
+
# globs match paths, which start with "/", so a leading "-" is always a stray option
|
|
273
|
+
opts = [x for x in a[2:] if x.startswith("-")]
|
|
274
|
+
if opts:
|
|
275
|
+
print(f"unknown option {opts[0]}" + (
|
|
276
|
+
" (hbkit always reads the newest backup version)" if opts[0] == "--version" else ""),
|
|
277
|
+
file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
|
|
271
280
|
arc, db = open_index(archive, password)
|
|
272
281
|
|
|
273
282
|
if cmd == "list":
|
|
@@ -13,6 +13,10 @@ instant correctness check - a wrong password is detected before reading any data
|
|
|
13
13
|
viv = crypto_box_seal_open(vkey.rsa_vkey_iv) # 64B sealed box -> 16B AES IV
|
|
14
14
|
chunk = LZ4( unpad( AES-256-CBC(ciphertext, vkey, viv) ) )
|
|
15
15
|
|
|
16
|
+
vkey.db holds one (vkey, viv) row per backup version, and each version encrypts the
|
|
17
|
+
chunks it writes with its own pair. Nothing records which version wrote a chunk, so a
|
|
18
|
+
reader tries each pair and keeps the one whose output matches the chunk's MD5.
|
|
19
|
+
|
|
16
20
|
Filenames are encrypted separately, with their own key, and stored base64 with '/'
|
|
17
21
|
replaced by '_' (illegal in a filename):
|
|
18
22
|
|
|
@@ -69,7 +73,8 @@ class ArchiveCrypto:
|
|
|
69
73
|
"""Holds the unwrapped keys for one encrypted archive."""
|
|
70
74
|
|
|
71
75
|
def __init__(self, password: str, unikey: str, public_key: bytes,
|
|
72
|
-
|
|
76
|
+
sealed_keys: list[tuple[bytes, bytes]]):
|
|
77
|
+
"""`sealed_keys` is the (rsa_vkey, rsa_vkey_iv) rows of vkey.db, one per version."""
|
|
73
78
|
nb, Cipher, algorithms, modes = _deps()
|
|
74
79
|
self._Cipher, self._algorithms, self._modes = Cipher, algorithms, modes
|
|
75
80
|
|
|
@@ -80,8 +85,10 @@ class ArchiveCrypto:
|
|
|
80
85
|
if pub != public_key:
|
|
81
86
|
raise WrongPassword("password does not match this archive")
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
self.
|
|
88
|
+
# (key, iv) per backup version, in version order, without repeats
|
|
89
|
+
self.data_keys = list(dict.fromkeys(
|
|
90
|
+
(nb.crypto_box_seal_open(k, pub, priv), nb.crypto_box_seal_open(iv, pub, priv))
|
|
91
|
+
for k, iv in sealed_keys))
|
|
85
92
|
self.fn_key = hashlib.sha256(priv + uni).digest()
|
|
86
93
|
self.fn_iv = hashlib.md5(uni + FILENAME_IV_SALT).digest()
|
|
87
94
|
|
|
@@ -89,8 +96,9 @@ class ArchiveCrypto:
|
|
|
89
96
|
d = self._Cipher(self._algorithms.AES(key), self._modes.CBC(iv)).decryptor()
|
|
90
97
|
return unpad(d.update(ct) + d.finalize())
|
|
91
98
|
|
|
92
|
-
def decrypt_chunk(self, ct: bytes) -> bytes:
|
|
93
|
-
|
|
99
|
+
def decrypt_chunk(self, ct: bytes, which: int) -> bytes:
|
|
100
|
+
key, iv = self.data_keys[which]
|
|
101
|
+
return self._dec(ct, key, iv)
|
|
94
102
|
|
|
95
103
|
def decrypt_name(self, stored: str) -> str:
|
|
96
104
|
"""Decrypt one path component. '_' stands in for '/' in the base64 alphabet."""
|
|
@@ -74,6 +74,8 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
74
74
|
codec = CODECS.get(cfg.get("data_compress_type", ""), f"unknown({cfg.get('data_compress_type')})")
|
|
75
75
|
r.fact("chunk codec", codec)
|
|
76
76
|
r.fact("encrypted", "yes (unlocked)" if arc.crypto else "no")
|
|
77
|
+
if arc.crypto:
|
|
78
|
+
r.fact("data keys", len(arc.crypto.data_keys))
|
|
77
79
|
r.fact("dedup across files", cfg.get("support_cross_file_dedup", "?"))
|
|
78
80
|
|
|
79
81
|
# --- blockers -------------------------------------------------------
|
|
@@ -125,8 +127,8 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
125
127
|
if len(vs) > 1:
|
|
126
128
|
multi.append(f"{s}={vs}")
|
|
127
129
|
if multi:
|
|
128
|
-
r.warnings.append("multiple backup versions present; the newest
|
|
129
|
-
|
|
130
|
+
r.warnings.append("multiple backup versions present; hbkit reads the newest: "
|
|
131
|
+
+ "; ".join(multi))
|
|
130
132
|
|
|
131
133
|
# --- prove it by actually rebuilding files ---------------------------
|
|
132
134
|
if not r.blockers:
|
|
@@ -141,10 +143,16 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
141
143
|
except Exception as e: # noqa: BLE001
|
|
142
144
|
r.warnings.append(f"share {s}: cannot read file list ({e})")
|
|
143
145
|
continue
|
|
144
|
-
|
|
146
|
+
# One random file from each of `sample` equal slices of the virtual_file stream,
|
|
147
|
+
# which backups append to, so the newest files are always covered. On an
|
|
148
|
+
# encrypted archive their new chunks sit under the newest version's own key.
|
|
149
|
+
rows = sorted((x for x in rows if stat.S_ISREG(x[3] or 0)), key=lambda x: x[2])
|
|
145
150
|
if rows:
|
|
146
151
|
rnd = random.Random(seed or 1234)
|
|
147
|
-
|
|
152
|
+
k = min(sample, len(rows))
|
|
153
|
+
for i in range(k):
|
|
154
|
+
row = rows[rnd.randrange(i * len(rows) // k, (i + 1) * len(rows) // k)]
|
|
155
|
+
picked.append((s, *row[:3]))
|
|
148
156
|
|
|
149
157
|
if not picked:
|
|
150
158
|
r.warnings.append("no regular files found to sample")
|
|
@@ -168,6 +176,9 @@ def diagnose(path: str, sample: int = 10, seed: int = 0, password: str | None =
|
|
|
168
176
|
el = max(time.time() - t0, 1e-6)
|
|
169
177
|
r.check(f"rebuilt {len(picked)} sampled files, all chunks verified",
|
|
170
178
|
bad == 0, f"{ok} ok, {bad} failed" + (f" - {errs[0]}" if errs else ""))
|
|
179
|
+
if arc.crypto:
|
|
180
|
+
r.fact("data keys used", f"{len(set(arc._bkey.values()))} of "
|
|
181
|
+
f"{len(arc.crypto.data_keys)} by the sample")
|
|
171
182
|
r.fact("sample throughput", f"{human(nbytes)} in {el:.1f}s "
|
|
172
183
|
f"({nbytes/el/1e6:,.0f} MB/s, single-threaded)")
|
|
173
184
|
arc.close()
|
|
@@ -526,14 +526,19 @@ class BrowserScreen(Screen):
|
|
|
526
526
|
(f"{n}", "bold"), (" item(s)", "dim")))
|
|
527
527
|
self.update_space()
|
|
528
528
|
|
|
529
|
+
def dest(self) -> str:
|
|
530
|
+
"""The destination as typed, with ~ and relative paths resolved as a shell would."""
|
|
531
|
+
return os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
|
|
532
|
+
|
|
529
533
|
def update_space(self):
|
|
530
|
-
probe = self.query_one("#dest", Input).value
|
|
531
|
-
while probe and not os.path.exists(probe):
|
|
532
|
-
probe = os.path.dirname(probe)
|
|
533
534
|
w = self.query_one("#space-info", Static)
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
535
|
+
probe = self.dest()
|
|
536
|
+
while not os.path.exists(probe):
|
|
537
|
+
parent = os.path.dirname(probe)
|
|
538
|
+
if parent == probe: # a drive that isn't there
|
|
539
|
+
w.update(Text("invalid path", style="bold red"))
|
|
540
|
+
return
|
|
541
|
+
probe = parent
|
|
537
542
|
free = shutil.disk_usage(probe).free
|
|
538
543
|
_, need, _ = self.sel.totals()
|
|
539
544
|
if need > free:
|
|
@@ -595,7 +600,7 @@ class BrowserScreen(Screen):
|
|
|
595
600
|
if not self.sel.chosen:
|
|
596
601
|
self.notify("select something first — space to tick", severity="warning")
|
|
597
602
|
return
|
|
598
|
-
dest =
|
|
603
|
+
dest = self.dest()
|
|
599
604
|
files = self.sel.files()
|
|
600
605
|
if not files:
|
|
601
606
|
self.notify("selection contains no extractable files", severity="error")
|
|
@@ -231,6 +231,78 @@ def test_encrypted_names_and_content():
|
|
|
231
231
|
arc.close()
|
|
232
232
|
|
|
233
233
|
|
|
234
|
+
# ------------------------------------- one data key per backup version (no archive needed)
|
|
235
|
+
|
|
236
|
+
def _multi_key_archive():
|
|
237
|
+
"""An Archive holding just enough state for read_chunk: bucket N contains one chunk
|
|
238
|
+
encrypted with backup version N+1's data key, the way vkey.db lays them out."""
|
|
239
|
+
import ctypes
|
|
240
|
+
import hashlib
|
|
241
|
+
import io
|
|
242
|
+
import struct
|
|
243
|
+
import zlib
|
|
244
|
+
|
|
245
|
+
nb = pytest.importorskip("nacl.bindings")
|
|
246
|
+
try:
|
|
247
|
+
lib = hbk.lz4()
|
|
248
|
+
except RuntimeError:
|
|
249
|
+
pytest.skip("liblz4 not available")
|
|
250
|
+
from cryptography.hazmat.primitives import padding
|
|
251
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
252
|
+
|
|
253
|
+
from hbkit.crypto import ARGON2_ALG, ARGON2_MEMLIMIT, ARGON2_OPSLIMIT, ArchiveCrypto
|
|
254
|
+
|
|
255
|
+
pw, unikey = "pw", "unikey"
|
|
256
|
+
seed = nb.crypto_pwhash_alg(32, pw.encode(), hashlib.md5(unikey.encode()).digest(),
|
|
257
|
+
ARGON2_OPSLIMIT, ARGON2_MEMLIMIT, ARGON2_ALG)
|
|
258
|
+
pub, _ = nb.crypto_box_seed_keypair(seed)
|
|
259
|
+
keys = [(os.urandom(32), os.urandom(16)) for _ in range(3)]
|
|
260
|
+
|
|
261
|
+
arc = object.__new__(hbk.Archive)
|
|
262
|
+
arc.crypto = ArchiveCrypto(pw, unikey, pub, [(nb.crypto_box_seal(k, pub),
|
|
263
|
+
nb.crypto_box_seal(iv, pub)) for k, iv in keys])
|
|
264
|
+
arc._brec, arc._bkey, buckets, plain = {}, {}, {}, []
|
|
265
|
+
lib.LZ4_compress_default.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
|
|
266
|
+
ctypes.c_int, ctypes.c_int]
|
|
267
|
+
for bid, (key, iv) in enumerate([*keys, (os.urandom(32), os.urandom(16))]):
|
|
268
|
+
data = f"chunk written by version {bid + 1}. ".encode() * 40
|
|
269
|
+
dst = ctypes.create_string_buffer(len(data) + 64)
|
|
270
|
+
n = lib.LZ4_compress_default(data, dst, len(data), len(dst))
|
|
271
|
+
lz = dst.raw[:n]
|
|
272
|
+
pad = padding.PKCS7(128).padder()
|
|
273
|
+
enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
|
|
274
|
+
ct = enc.update(pad.update(lz) + pad.finalize()) + enc.finalize()
|
|
275
|
+
rec = struct.pack(">III", len(ct), 0, len(data)) + hashlib.md5(data).digest()
|
|
276
|
+
buckets[bid] = (rec + struct.pack(">I", zlib.crc32(rec)), io.BytesIO(ct))
|
|
277
|
+
arc._brec[bid] = 32
|
|
278
|
+
plain.append(data)
|
|
279
|
+
arc._bucket = buckets.__getitem__
|
|
280
|
+
return arc, plain
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_chunks_decode_under_the_key_of_the_version_that_wrote_them():
|
|
284
|
+
"""Issue #4: vkey.db has a row per backup version, each a different AES key. Only the
|
|
285
|
+
first was ever loaded, so every chunk a later version wrote failed to decompress."""
|
|
286
|
+
arc, plain = _multi_key_archive()
|
|
287
|
+
for bid in range(3):
|
|
288
|
+
assert arc.read_chunk(bid, 0, verify=False) == plain[bid]
|
|
289
|
+
assert arc._bkey == {0: 0, 1: 1, 2: 2}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_a_chunk_no_key_decodes_is_an_error_even_without_verify():
|
|
293
|
+
arc, _ = _multi_key_archive()
|
|
294
|
+
with pytest.raises(ValueError, match="no data key decodes b3@0"):
|
|
295
|
+
arc.read_chunk(3, 0, verify=False)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_verify_rejects_options_it_does_not_take():
|
|
299
|
+
"""`verify '*' --version 3` used to verify the newest version and say nothing."""
|
|
300
|
+
r = subprocess.run([sys.executable, "-m", "hbkit.cli", "/no/such/archive",
|
|
301
|
+
"verify", "*", "--version", "3"], capture_output=True, text=True)
|
|
302
|
+
assert r.returncode == 2
|
|
303
|
+
assert "unknown option --version" in r.stderr
|
|
304
|
+
|
|
305
|
+
|
|
234
306
|
# ------------------------------------------------- shard maths (no archive needed)
|
|
235
307
|
|
|
236
308
|
def _make_shards(tmp_path, sizes, shard_size, monkeypatch):
|
|
@@ -518,3 +590,40 @@ def test_lazycats_lists_shards_before_any_are_opened():
|
|
|
518
590
|
0: "/nonexistent/file_chunk0.index"})
|
|
519
591
|
assert cats.known == [0, 4]
|
|
520
592
|
assert len(cats) == 0, "listing shards must not open them"
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@pytest.mark.parametrize("typed", ["~/hbkit-test-not-there/restore", "restore/here"])
|
|
596
|
+
def test_tui_free_space_follows_home_and_relative_destinations(tmp_path, monkeypatch, typed):
|
|
597
|
+
"""Typing ~/restore showed "invalid path" under the destination, though the restore
|
|
598
|
+
itself expanded ~ fine. A relative path did the same."""
|
|
599
|
+
import asyncio
|
|
600
|
+
import sqlite3
|
|
601
|
+
from types import SimpleNamespace
|
|
602
|
+
|
|
603
|
+
from hbkit import tui
|
|
604
|
+
|
|
605
|
+
db = tmp_path / "index.db"
|
|
606
|
+
con = sqlite3.connect(db)
|
|
607
|
+
con.execute("CREATE TABLE node(id INTEGER PRIMARY KEY, parent INTEGER, name TEXT, "
|
|
608
|
+
"isdir INTEGER, size INTEGER, nfiles INTEGER, ovf INTEGER, mtime INTEGER, "
|
|
609
|
+
"share TEXT)")
|
|
610
|
+
con.execute("INSERT INTO node VALUES (1, NULL, 'share', 1, 10, 1, NULL, 0, 'share')")
|
|
611
|
+
con.commit()
|
|
612
|
+
con.close()
|
|
613
|
+
monkeypatch.chdir(tmp_path)
|
|
614
|
+
|
|
615
|
+
class App(tui.HBKApp):
|
|
616
|
+
def on_mount(self, event):
|
|
617
|
+
event.prevent_default() # skip the archive picker
|
|
618
|
+
self.push_screen(tui.BrowserScreen(SimpleNamespace(root="demo"), tui.Index(str(db))))
|
|
619
|
+
|
|
620
|
+
async def space_line():
|
|
621
|
+
app = App(1)
|
|
622
|
+
async with app.run_test() as pilot:
|
|
623
|
+
await pilot.pause()
|
|
624
|
+
app.screen.query_one("#dest", tui.Input).value = typed
|
|
625
|
+
await pilot.pause()
|
|
626
|
+
return str(app.screen.query_one("#space-info", tui.Static).render())
|
|
627
|
+
|
|
628
|
+
line = asyncio.run(space_line())
|
|
629
|
+
assert "free" in line and "invalid" not in line, line
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|