hbkit 0.4.5__tar.gz → 0.4.6__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.
@@ -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. Variant handling marked [D] is implemented from disassembly but
20
- has never met a real archive of that kind.
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 check when
287
- implementing. The `uncomp_len` and MD5 in the bucket index still describe the *final*
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.5
3
+ Version: 0.4.6
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
@@ -280,8 +280,9 @@ Read this before trusting it with the only copy of anything.
280
280
  contain plaintext names (not file contents). Delete it if that matters to you.
281
281
  - **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
282
282
  archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
283
- the same DSM version, both verified byte-exact. Older record layouts
284
- (16-byte `chunk_index`, 28-byte bucket records), zlib chunks and multi-version archives
283
+ the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
284
+ Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
285
+ Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
285
286
  are implemented from disassembly but have not met a real archive of that kind. `doctor`
286
287
  exists precisely so you can find out in seconds rather than mid-restore.
287
288
  - **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
@@ -251,8 +251,9 @@ Read this before trusting it with the only copy of anything.
251
251
  contain plaintext names (not file contents). Delete it if that matters to you.
252
252
  - **Proven against a limited set of archives.** A 3 TB DSM 7 / Hyper Backup 4.1.2
253
253
  archive (unencrypted, LZ4, single version, single pool) and a small encrypted one from
254
- the same DSM version, both verified byte-exact. Older record layouts
255
- (16-byte `chunk_index`, 28-byte bucket records), zlib chunks and multi-version archives
254
+ the same DSM version, both verified byte-exact, plus a 7-version encrypted archive from
255
+ Hyper Backup 4.2.2 reported in [#4](https://github.com/YordiLorenzo/hbkit/issues/4).
256
+ Older record layouts (16-byte `chunk_index`, 28-byte bucket records) and zlib chunks
256
257
  are implemented from disassembly but have not met a real archive of that kind. `doctor`
257
258
  exists precisely so you can find out in seconds rather than mid-restore.
258
259
  - **Unknown layouts are refused, not guessed.** A wrong guess would mean silently wrong
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.5"
7
+ version = "0.4.6"
8
8
  description = "Recover files from Synology Hyper Backup (.hbk) archives without Synology software"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  """hbkit - recover files from Synology Hyper Backup (.hbk) archives without Synology software."""
2
2
 
3
- __version__ = "0.4.5"
3
+ __version__ = "0.4.6"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -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 the version key using the password. Raises WrongPassword on mismatch."""
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
- row = db.execute("SELECT rsa_vkey, rsa_vkey_iv FROM vkey ORDER BY version_id").fetchone()
338
+ rows = db.execute("SELECT rsa_vkey, rsa_vkey_iv FROM vkey ORDER BY version_id").fetchall()
327
339
  db.close()
328
- if not row:
340
+ if not rows:
329
341
  raise UnsupportedArchive("encrypted archive has no rows in vkey db")
330
- return ArchiveCrypto(password, unikey, pub, bytes(row[0]), bytes(row[1]))
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
- raw = self.crypto.decrypt_chunk(raw)
420
- clen = len(raw)
421
- dst = ctypes.create_string_buffer(ulen)
422
- n = lz4().LZ4_decompress_safe(raw, dst, clen, ulen)
423
- data = dst.raw[:n] if n > 0 else b""
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
- sealed_vkey: bytes, sealed_viv: bytes):
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
- self.key = nb.crypto_box_seal_open(sealed_vkey, pub, priv)
84
- self.iv = nb.crypto_box_seal_open(sealed_viv, pub, priv)
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
- return self._dec(ct, self.key, self.iv)
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 is used by default "
129
- "(--version selects another): " + "; ".join(multi))
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
- rows = [x for x in rows if stat.S_ISREG(x[3] or 0)]
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
- picked += [(s, *rnd.choice(rows)[:3]) for _ in range(min(sample, len(rows)))]
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()
@@ -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):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes