decbackup 0.1.0__tar.gz → 0.2.0__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.
Files changed (61) hide show
  1. decbackup-0.2.0/MANIFEST.in +5 -0
  2. {decbackup-0.1.0 → decbackup-0.2.0}/PKG-INFO +35 -10
  3. {decbackup-0.1.0 → decbackup-0.2.0}/README.md +29 -9
  4. decbackup-0.2.0/RECON.md +386 -0
  5. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/__init__.py +1 -1
  6. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/backup.py +117 -36
  7. decbackup-0.2.0/decbackup/cli.py +224 -0
  8. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/crypto.py +3 -1
  9. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/der.py +32 -2
  10. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/pkcs7.py +71 -20
  11. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/trust.py +95 -23
  12. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup.egg-info/PKG-INFO +35 -10
  13. decbackup-0.2.0/decbackup.egg-info/SOURCES.txt +56 -0
  14. {decbackup-0.1.0 → decbackup-0.2.0}/pyproject.toml +5 -0
  15. decbackup-0.2.0/tests/test_backup_edges.py +281 -0
  16. decbackup-0.2.0/tests/test_cli_prompt.py +224 -0
  17. decbackup-0.2.0/tests/test_decbackup.py +620 -0
  18. decbackup-0.2.0/tests/test_der_units.py +289 -0
  19. decbackup-0.2.0/tests/test_trust_chain.py +378 -0
  20. decbackup-0.2.0/tests/vectors/README.md +107 -0
  21. decbackup-0.2.0/tests/vectors/algo2.bkup +0 -0
  22. decbackup-0.2.0/tests/vectors/big.bkup +0 -0
  23. decbackup-0.2.0/tests/vectors/big.tar +0 -0
  24. decbackup-0.2.0/tests/vectors/cert.bkup +0 -0
  25. decbackup-0.2.0/tests/vectors/ct.bkup +0 -0
  26. decbackup-0.2.0/tests/vectors/extdup.bkup +0 -0
  27. decbackup-0.2.0/tests/vectors/itovf.bkup +0 -0
  28. decbackup-0.2.0/tests/vectors/ivlen.bkup +0 -0
  29. decbackup-0.2.0/tests/vectors/lazyparse.bkup +0 -0
  30. decbackup-0.2.0/tests/vectors/mid.bkup +0 -0
  31. decbackup-0.2.0/tests/vectors/mid.tar +0 -0
  32. decbackup-0.2.0/tests/vectors/mv3.bkup +0 -0
  33. decbackup-0.2.0/tests/vectors/mvx.bkup +0 -0
  34. decbackup-0.2.0/tests/vectors/nopass.bkup +0 -0
  35. decbackup-0.2.0/tests/vectors/sig2bad.bkup +0 -0
  36. decbackup-0.2.0/tests/vectors/t2.tar +0 -0
  37. decbackup-0.2.0/tests/vectors/test.bkup +0 -0
  38. decbackup-0.2.0/tests/vectors/test.tar +0 -0
  39. decbackup-0.2.0/tests/vectors/trunc.bkup +0 -0
  40. decbackup-0.2.0/tests/vectors/v1badkey.bkup +0 -0
  41. decbackup-0.2.0/tests/vectors/v1probe.bkup +0 -0
  42. decbackup-0.2.0/tests/vectors/v9.bkup +0 -0
  43. decbackup-0.2.0/tests/vectors/v9s.bkup +0 -0
  44. decbackup-0.2.0/tests/vectors/xbig.bkup +0 -0
  45. decbackup-0.2.0/tests/vectors/xzero.bkup +0 -0
  46. decbackup-0.2.0/tests/vectors/ys.bkup +0 -0
  47. decbackup-0.2.0/tests/vectors/yt.bkup +0 -0
  48. decbackup-0.1.0/decbackup/cli.py +0 -110
  49. decbackup-0.1.0/decbackup.egg-info/SOURCES.txt +0 -22
  50. decbackup-0.1.0/tests/test_decbackup.py +0 -216
  51. {decbackup-0.1.0 → decbackup-0.2.0}/LICENSE +0 -0
  52. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/__main__.py +0 -0
  53. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/certs/netasq-firewall-ca.der +0 -0
  54. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/certs/stormshield-product-and-services-root-ca.der +0 -0
  55. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/certs/stormshield-products-root-ca.der +0 -0
  56. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup/certs/stormshield-sns-enrollment-products-ca.der +0 -0
  57. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup.egg-info/dependency_links.txt +0 -0
  58. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup.egg-info/entry_points.txt +0 -0
  59. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup.egg-info/requires.txt +0 -0
  60. {decbackup-0.1.0 → decbackup-0.2.0}/decbackup.egg-info/top_level.txt +0 -0
  61. {decbackup-0.1.0 → decbackup-0.2.0}/setup.cfg +0 -0
@@ -0,0 +1,5 @@
1
+ # The sdist must carry the runnable test suite promised in the README:
2
+ # tests/test_decbackup.py alone is useless without the binary device vectors.
3
+ graft tests
4
+ include LICENSE
5
+ include RECON.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: decbackup
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python clone of Stormshield SNS's decbackup: decrypt and verify .bkup appliance backups without the device
5
5
  Author: fl0l0u
6
6
  License-Expression: Apache-2.0
@@ -14,6 +14,11 @@ Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: System Administrators
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
17
22
  Classifier: Topic :: Security :: Cryptography
18
23
  Classifier: Topic :: System :: Archiving
19
24
  Requires-Python: >=3.9
@@ -52,6 +57,7 @@ Dynamic: license-file
52
57
  <a href="#usage">Usage</a> •
53
58
  <a href="#the-format">The Format</a> •
54
59
  <a href="#development">Development</a> •
60
+ <a href="#releasing">Releasing</a> •
55
61
  <a href="#license">License</a>
56
62
  </p>
57
63
 
@@ -70,10 +76,14 @@ plus an empirical error-code matrix against a live device (see
70
76
  |---------|-------------|
71
77
  | **Byte-exact format support** | `[BACKUP]` header, detached PKCS#7 trailer, AES-128-CBC payload |
72
78
  | **Both password schemes** | v2 (PBKDF2-HMAC-SHA256) and legacy v1 (salted-SHA1) key derivation |
73
- | **Full signature verification** | RSA over PKCS#7 authAttrs, messageDigest over header+payload, chain to the four CAs embedded in libncrypto, policy 1.3.6.1.4.1.11256.2.3, dates ignored (as on the device) |
79
+ | **Full signature verification** | RSA over PKCS#7 authAttrs, messageDigest over header+payload, chain to the four CA certificates embedded in libncrypto, policy 1.3.6.1.4.1.11256.2.3, dates ignored (as on the device) |
74
80
  | **Original CLI parity** | same flags (`-i -o -p -P -d`), same messages, same numeric error codes (0/1/6/7/8/12/13/32) — validated against the real binary on device |
75
81
  | **Tiny dependency set** | `cryptography` only; DER/PKCS#7 parsing is in-tree (`decbackup/der.py`, `pkcs7.py`) |
76
82
 
83
+ About the v1 row: no released SNS version emits the legacy v1 scheme (both
84
+ tested `encbackup` builds write v2), so the v1 path is validated only
85
+ through failure-path vectors, not a decryptable v1 backup.
86
+
77
87
  ## Installation
78
88
 
79
89
  ```bash
@@ -109,6 +119,10 @@ Success is silent (exit 0), exactly like the original; failure prints
109
119
  `decrypt and/or verify backup failed (%d)` with the appliance's own error
110
120
  code. `-d` prints the decoded header block after a successful run.
111
121
 
122
+ Output-file contract (same as the device): the `-o` file is created and
123
+ truncated early, and **deleted on every failure path** — a pre-existing
124
+ file at `-o` is destroyed even when the backup is rejected.
125
+
112
126
  `--no-verify` has no counterpart on the device: it skips trailer
113
127
  parsing and signature/chain checks to rescue backups whose signature
114
128
  region is corrupted. It prints a warning and the output is
@@ -132,10 +146,14 @@ except BackupError as e:
132
146
  0x000 0x400 [BACKUP] key=value header, NUL-terminated, zero-padded
133
147
  0x400 4 be32 X — PKCS#7 trailer length
134
148
  0x404 X detached PKCS#7 SignedData (device cert + CA intermediate)
135
- 0x404+X 4 be32 Y — payload length
136
- 0x408+X Y AES-128-CBC ciphertext (PBKDF2-SHA256 key, PKCS#7 padding)
149
+ 0x404+X 4 be32 Y — ciphertext length
150
+ 0x408+X Y AES-128-CBC ciphertext (PBKDF2-SHA256 key¹, PKCS#7 padding)
137
151
  ```
138
152
 
153
+ ¹ v2 key derivation; the legacy v1 scheme instead uses a salted-SHA1
154
+ derivation (see [RECON.md](RECON.md)). Y counts ciphertext bytes including
155
+ the padding, so it exceeds the plaintext by a partial or full AES block.
156
+
139
157
  The signature covers the **full 0x400-byte header block plus the decrypted
140
158
  payload**; it chains device cert → *Stormshield Network Security Products
141
159
  CA* → *Stormshield Products Root CA* (embedded). Expired device
@@ -148,13 +166,19 @@ readable forever. Full details in [RECON.md](RECON.md).
148
166
  decbackup/ the package (cli, backup format, crypto, pkcs7, der, trust, certs/)
149
167
  analysis/ RE tooling: disassembler, strings, symbol lists, the device
150
168
  extraction script and the empirical error-code matrix
151
- tests/vectors/ real encbackup-produced backups + expected payloads
152
- (created on an SNS 5.1.4 device, cross-validated against
153
- the stock decbackup)
169
+ tests/vectors/ device-derived containers + expected payloads:
170
+ test/nopass/big/mid are stock encbackup output from an
171
+ SNS 5.1.4 device (cross-validated against the stock
172
+ decbackup); v1probe/v1badkey are patched v4.2.8
173
+ containers; extdup/lazyparse are pinned fuzz-crash
174
+ mutations; trunc/xzero/ct/cert/mv3/v9/yt/ys/mvx are
175
+ byte-edited probes
154
176
  legacy/ the earlier single-file iteration (kept for reference)
155
177
  ```
156
178
 
157
179
  ```bash
180
+ pip install -e ".[dev]" # dev extras: ruff
181
+ ruff check . # lint (same as CI)
158
182
  python tests/test_decbackup.py # exit code = number of failures
159
183
  python analysis/probes.py # real-vs-clone error-code parity table
160
184
  python analysis/fuzz.py 30000 # robustness contract fuzzer
@@ -163,9 +187,10 @@ python analysis/fuzz.py 30000 # robustness contract fuzzer
163
187
  ## Releasing
164
188
 
165
189
  Tagged pushes (`v*`) build an sdist + wheel, smoke-test the wheel against
166
- the bundled device vectors, and publish to PyPI via trusted publishing
167
- (see `.github/workflows/release.yml`). Pushes and PRs run lint + the
168
- error-code parity suite on Python 3.9–3.13
190
+ one bundled vector (`test.bkup`: clean decrypt, plus the wrong-password
191
+ failure contract), and publish to PyPI via trusted publishing
192
+ (see `.github/workflows/release.yml`). Pushes to `master`/`main` and pull
193
+ requests run lint + the error-code parity suite on Python 3.9–3.13
169
194
  (`.github/workflows/ci.yml`).
170
195
 
171
196
  ```bash
@@ -26,6 +26,7 @@
26
26
  <a href="#usage">Usage</a> •
27
27
  <a href="#the-format">The Format</a> •
28
28
  <a href="#development">Development</a> •
29
+ <a href="#releasing">Releasing</a> •
29
30
  <a href="#license">License</a>
30
31
  </p>
31
32
 
@@ -44,10 +45,14 @@ plus an empirical error-code matrix against a live device (see
44
45
  |---------|-------------|
45
46
  | **Byte-exact format support** | `[BACKUP]` header, detached PKCS#7 trailer, AES-128-CBC payload |
46
47
  | **Both password schemes** | v2 (PBKDF2-HMAC-SHA256) and legacy v1 (salted-SHA1) key derivation |
47
- | **Full signature verification** | RSA over PKCS#7 authAttrs, messageDigest over header+payload, chain to the four CAs embedded in libncrypto, policy 1.3.6.1.4.1.11256.2.3, dates ignored (as on the device) |
48
+ | **Full signature verification** | RSA over PKCS#7 authAttrs, messageDigest over header+payload, chain to the four CA certificates embedded in libncrypto, policy 1.3.6.1.4.1.11256.2.3, dates ignored (as on the device) |
48
49
  | **Original CLI parity** | same flags (`-i -o -p -P -d`), same messages, same numeric error codes (0/1/6/7/8/12/13/32) — validated against the real binary on device |
49
50
  | **Tiny dependency set** | `cryptography` only; DER/PKCS#7 parsing is in-tree (`decbackup/der.py`, `pkcs7.py`) |
50
51
 
52
+ About the v1 row: no released SNS version emits the legacy v1 scheme (both
53
+ tested `encbackup` builds write v2), so the v1 path is validated only
54
+ through failure-path vectors, not a decryptable v1 backup.
55
+
51
56
  ## Installation
52
57
 
53
58
  ```bash
@@ -83,6 +88,10 @@ Success is silent (exit 0), exactly like the original; failure prints
83
88
  `decrypt and/or verify backup failed (%d)` with the appliance's own error
84
89
  code. `-d` prints the decoded header block after a successful run.
85
90
 
91
+ Output-file contract (same as the device): the `-o` file is created and
92
+ truncated early, and **deleted on every failure path** — a pre-existing
93
+ file at `-o` is destroyed even when the backup is rejected.
94
+
86
95
  `--no-verify` has no counterpart on the device: it skips trailer
87
96
  parsing and signature/chain checks to rescue backups whose signature
88
97
  region is corrupted. It prints a warning and the output is
@@ -106,10 +115,14 @@ except BackupError as e:
106
115
  0x000 0x400 [BACKUP] key=value header, NUL-terminated, zero-padded
107
116
  0x400 4 be32 X — PKCS#7 trailer length
108
117
  0x404 X detached PKCS#7 SignedData (device cert + CA intermediate)
109
- 0x404+X 4 be32 Y — payload length
110
- 0x408+X Y AES-128-CBC ciphertext (PBKDF2-SHA256 key, PKCS#7 padding)
118
+ 0x404+X 4 be32 Y — ciphertext length
119
+ 0x408+X Y AES-128-CBC ciphertext (PBKDF2-SHA256 key¹, PKCS#7 padding)
111
120
  ```
112
121
 
122
+ ¹ v2 key derivation; the legacy v1 scheme instead uses a salted-SHA1
123
+ derivation (see [RECON.md](RECON.md)). Y counts ciphertext bytes including
124
+ the padding, so it exceeds the plaintext by a partial or full AES block.
125
+
113
126
  The signature covers the **full 0x400-byte header block plus the decrypted
114
127
  payload**; it chains device cert → *Stormshield Network Security Products
115
128
  CA* → *Stormshield Products Root CA* (embedded). Expired device
@@ -122,13 +135,19 @@ readable forever. Full details in [RECON.md](RECON.md).
122
135
  decbackup/ the package (cli, backup format, crypto, pkcs7, der, trust, certs/)
123
136
  analysis/ RE tooling: disassembler, strings, symbol lists, the device
124
137
  extraction script and the empirical error-code matrix
125
- tests/vectors/ real encbackup-produced backups + expected payloads
126
- (created on an SNS 5.1.4 device, cross-validated against
127
- the stock decbackup)
138
+ tests/vectors/ device-derived containers + expected payloads:
139
+ test/nopass/big/mid are stock encbackup output from an
140
+ SNS 5.1.4 device (cross-validated against the stock
141
+ decbackup); v1probe/v1badkey are patched v4.2.8
142
+ containers; extdup/lazyparse are pinned fuzz-crash
143
+ mutations; trunc/xzero/ct/cert/mv3/v9/yt/ys/mvx are
144
+ byte-edited probes
128
145
  legacy/ the earlier single-file iteration (kept for reference)
129
146
  ```
130
147
 
131
148
  ```bash
149
+ pip install -e ".[dev]" # dev extras: ruff
150
+ ruff check . # lint (same as CI)
132
151
  python tests/test_decbackup.py # exit code = number of failures
133
152
  python analysis/probes.py # real-vs-clone error-code parity table
134
153
  python analysis/fuzz.py 30000 # robustness contract fuzzer
@@ -137,9 +156,10 @@ python analysis/fuzz.py 30000 # robustness contract fuzzer
137
156
  ## Releasing
138
157
 
139
158
  Tagged pushes (`v*`) build an sdist + wheel, smoke-test the wheel against
140
- the bundled device vectors, and publish to PyPI via trusted publishing
141
- (see `.github/workflows/release.yml`). Pushes and PRs run lint + the
142
- error-code parity suite on Python 3.9–3.13
159
+ one bundled vector (`test.bkup`: clean decrypt, plus the wrong-password
160
+ failure contract), and publish to PyPI via trusted publishing
161
+ (see `.github/workflows/release.yml`). Pushes to `master`/`main` and pull
162
+ requests run lint + the error-code parity suite on Python 3.9–3.13
143
163
  (`.github/workflows/ci.yml`).
144
164
 
145
165
  ```bash
@@ -0,0 +1,386 @@
1
+ # decbackup / Stormshield SNS backup Reverse-Engineering Recon
2
+
3
+ Target: `/usr/Firewall/sbin/decbackup` + `libncrypto.so` from **Stormshield
4
+ Network Security v5.1.4** (SNS-BSD 5.1.4, EVA1 XL-VM). All addresses below
5
+ are file offsets into the binaries extracted from the device; those
6
+ artifacts are **not in this repo** (the extraction workflow is §1), so
7
+ resolve an address only against your own extracted copy.
8
+
9
+ ## 1. Environment & extraction workflow
10
+
11
+ * SNS **v5.1.4** lab VM; admin shell is a **restricted csh whitelist**:
12
+ no `cat`, `od`, `printf`, `for`, no `2>` redirection (csh: `2>&1` =
13
+ "Ambiguous output redirect"). `ls`, `stat`, `sed`, `dd`, `bsdtar`,
14
+ `openssl`, `md5`, `encbackup`, `decbackup` *are* available.
15
+ * SCP/SFTP is blocked. Files exfiltrate via
16
+ `ssh admin@vm 'openssl base64 -in /path/file' | base64 -d`
17
+ (`analysis/extract.sh`). Integrity confirmed with `md5` on both ends.
18
+ * Analysis tooling: `analysis/disasm.py` (capstone + pyelftools function
19
+ disassembler with PLT-resolved call targets and string annotations),
20
+ `analysis/strings.py`, `analysis/symbols.py`.
21
+
22
+ ### Binaries that matter
23
+
24
+ | file | role |
25
+ |---|---|
26
+ | `sbin/decbackup` | CLI front-end: getopt, password handling, `-d` dump |
27
+ | `sbin/encbackup` | creates backups (`-i archive -o bkup -t all -p pass`) |
28
+ | `sbin/backuprestore`, `sbin/backupinfo` | other consumers of the format |
29
+ | `lib/libncrypto.so` | the whole format: `fwcrypto_upd_*`, KDF, PKCS#7 |
30
+ | `lib/libnbase.so` | section/key-value parser (`str_get_int_ex` etc.) |
31
+
32
+ ## 2. Container format (CONFIRMED, byte-exact)
33
+
34
+ ```
35
+ offset size contents
36
+ 0x000 0x400 [BACKUP] header: key=value lines, NUL-terminated,
37
+ zero-padded to 1 KiB
38
+ 0x400 4 be32 X — length of the PKCS#7 trailer
39
+ 0x404 X detached PKCS#7 SignedData (signature + certs)
40
+ 0x404+X 4 be32 Y — ciphertext length (== filesize - 0x408 - X)
41
+ 0x408+X Y AES-128-CBC ciphertext, PKCS#7 padding
42
+ ```
43
+
44
+ Y counts the **encrypted** span, PKCS#7 padding included (measured:
45
+ `test.bkup` Y=2064 over a 2048-byte plaintext). The parser does **not
46
+ trust** it — decryption is bounded by filesize (yt/ys probes, §6).
47
+
48
+ Header keys seen: `majversion=2`, `algo=1`, `date="…"`, `model`, `version`,
49
+ `serial`, `advanced`, `description`, `content`, `iv` (hex), `salt` (hex),
50
+ `pbkdf_iterations=10000`, `type=PASSWORD`, `validated`, `IPv6State`.
51
+ Values may be double-quoted; the text block ends at the first NUL.
52
+
53
+ Parsing path: `fwcrypto_upd_update` (libncrypto 0x12860, flags=2 backup
54
+ path). The file is read in **0x4000-byte chunks**; the signature trailer
55
+ must fit inside the first chunk (`X + 0x404 >= chunk_len → error 6`,
56
+ `fwcrypto_upd_get_signature failed`) — a fail-closed design, so whole-file
57
+ processing is behaviourally equivalent.
58
+
59
+ **Quirk (not reproduced):** if the filesize is an exact multiple of
60
+ 0x4000, the streaming loop's EOF detection hits the zero-length-read path
61
+ and the tool fails. A bug; the clone simply reads the whole file.
62
+
63
+ ## 3. Key derivation (CONFIRMED v2 and v1)
64
+
65
+ Selected by header `majversion` (`fwcrypto_get_backup_majversion`,
66
+ default 1 when the key is absent; 1 or 2 accepted, anything else → error 12
67
+ `Unknown backup majversion`). "Anything else" applies to *parsed* numbers
68
+ only: an unparsable value falls back to the default 1 exactly like an
69
+ absent key (mvx probe, §6).
70
+
71
+ * **v2** (`fwcrypto_derivekey`): `key = PBKDF2-HMAC-SHA256(password,
72
+ unhex(salt), pbkdf_iterations, 16)`; `iv = unhex(iv)`.
73
+ * **v1**: `key = SHA1(2807a5ef00620c4b62f32e0985e30e4e || password)[:16]`,
74
+ `iv = SHA1(463538798d48c8c0323df3e75109d221 || password)[:16]`. The two
75
+ salts are consecutive 16-byte windows of one 48-byte pool
76
+ (`463538798d…09d221` + `2807a5ef00…0e4e`); the getters' `lea` targets
77
+ resolve to pool+0 and pool+16 in *both* the 5.1.4 and 4.2.8 binaries.
78
+ An earlier reading (`212807a5…` / `0046353879…`) was a one-byte
79
+ vaddr→file-offset slip, corrected in §12 by direct experiment.
80
+ For sizes >20 the fill is
81
+ `buf[i] = (size-1 + (size-i)*0xEB) & 0xFF` — unreachable for AES-128.
82
+ 5.1.4 `encbackup` cannot emit v1 (no version flag in getopt
83
+ `i:o:p:c:t:n:P`), but the **key derivation is now empirically confirmed**
84
+ (§12): a v1-header archive whose ciphertext was made with these
85
+ constants decrypts cleanly on both VMs (reaches the digest check → 7),
86
+ while the shifted-salt variant fails at padding (→ 13) on both.
87
+
88
+ Cipher: `algo=1` → AES-128-CBC (`EVP_aes_128_cbc`); anything else → error
89
+ 32 `unsupported algo`.
90
+
91
+ ## 4. Signature scheme (CONFIRMED end-to-end)
92
+
93
+ Detached PKCS#7 SignedData (OpenSSL 3 `PKCS7_*` via
94
+ `VerifyMajSignatureInit` 0x14a5? / `VerifySignatureFinal`; last nibble
95
+ unresolved from notes):
96
+
97
+ * **Signed content** = full 0x400-byte header block (text + NUL padding —
98
+ implemented as `strlen` + zero-pad loop) **followed by the decrypted
99
+ payload**. Proven: `messageDigest` attribute ==
100
+ `sha256(header_block + plaintext)` on every vector.
101
+ * The trailer's SignedData quirks:
102
+ * certificates sit under `[0]` as bare `SEQUENCE` TLVs (no SET wrapper);
103
+ * `signerInfos` is a plain `0x31` SET;
104
+ * authenticatedAttributes are stored bare under the `[0]` tag — the
105
+ signed DER is the tag-swapped form `0x31 + DER-length + value`
106
+ (Stormshield's encoder omits the SET, so the length must be re-encoded;
107
+ for the 105-byte value the short form `0x69` matches byte-exactly);
108
+ * signature AlgorithmIdentifier is `rsaEncryption` (1.2.840.113549.1.1.1,
109
+ hash from digestAlgorithm = sha256) — classic PKCS#7 DigestInfo.
110
+ * Attributes: contentType (1.9.3) = pkcs7-data, signingTime (1.9.5),
111
+ messageDigest (1.9.4).
112
+ * Signer: device certificate (leaf carries certificatePolicies
113
+ **1.3.6.1.4.1.11256.2.3**), SID = issuerAndSerialNumber.
114
+
115
+ ### Trust store (`fw_libcrypto_get_trust_store`, backup type)
116
+
117
+ Four CA certs hard-coded in libncrypto .rodata, extracted to
118
+ `decbackup/certs/`:
119
+
120
+ | .rodata off | size | subject |
121
+ |---|---|---|
122
+ | 0x8890 | 0x6b0 | Stormshield Network Security Enrollment Products CA |
123
+ | 0x6b40 | 0x418 | NETASQ Firewall Certification Authority |
124
+ | 0x8f40 | 0x601 | Stormshield Products Root CA |
125
+ | 0x6f60 | 0x606 | Stormshield Product and Services Root CA |
126
+
127
+ Verify parameters: `X509_V_FLAG_NO_CHECK_TIME` (expired device certs still
128
+ verify — backups stay readable forever), explicit policy
129
+ 1.3.6.1.4.1.11256.2.3. The callback `fwcrypto_verify_any_nodate_callback`
130
+ (0x11150) accepts errors {0, 9, 10} and 43 when
131
+ `fw_libcrypto_is_netasq_issued` (issuer DER memcmp vs the NETASQ root).
132
+ Net effect: signature-valid chain from leaf to an embedded root, dates
133
+ ignored. Chain in vectors: device ← Stormshield Network Security Products
134
+ CA (in the trailer) ← Stormshield Products Root CA (embedded).
135
+
136
+ ## 5. CLI grammar (`decbackup` main)
137
+
138
+ getopt string `Pi:p:o:d`; usage (stderr, exit 1 when missing args/options):
139
+
140
+ ```
141
+ Usage : () -i <backup> -o <output archive>
142
+ [-p <password>] [-d : dump backup header]
143
+ [-P : prompt for password]
144
+ ```
145
+
146
+ (The empty parens are faithful: both `%s` args point at an empty string.)
147
+
148
+ * `-p` more than once → `-p can only be specified once`, exit 1.
149
+ * Password selection: `-p x` → x; `-P` → `t_getpass` prompt
150
+ `Enter backup password (<Enter> for none): ` which reads **/dev/tty**
151
+ (piped stdin is ignored: `-P` over a pipe with no tty → empty password →
152
+ 13); no `-p`/`-P` on a tty → same prompt; otherwise empty password.
153
+ * `-d` → on success prints `Header :\n<text up to NUL>\n`. Success is
154
+ otherwise silent (exit 0); failure prints
155
+ `decrypt and/or verify backup failed (%d)` (stderr) and exits 1.
156
+ * Output file is created early (`fopen wb`) and **removed on every error
157
+ path**.
158
+ * Clone extension: `--no-verify` (long option only — the original
159
+ getopt string `Pi:p:o:d` is unchanged and cannot collide with it).
160
+ It skips trailer parsing and all verification; the X field is still
161
+ read to locate the payload, so the structural floor (8) and padding
162
+ errors (13) remain. A corrupted-but-in-range X can therefore yield
163
+ exit 0 with garbage content — that is the point of the switch.
164
+ A warning goes to stderr and exit 0 then means
165
+ "decrypted, not authenticated".
166
+
167
+ ## 6. Error codes (empirical matrix, real device vs clone — parity)
168
+
169
+ `analysis/probes.py` renders this matrix as a static table, run locally
170
+ against `tests/vectors/` (no device needed; the `real` column holds codes
171
+ recorded on the device). The **maintained** parity check is
172
+ `tests/test_decbackup.py` (run in CI); keep the two in sync.
173
+
174
+ | code | meaning | proven by |
175
+ |---|---|---|
176
+ | 0 | success | all vectors |
177
+ | 1 | input/output open failure | code path |
178
+ | 6 | signature trailer unparsable / doesn't fit first chunk; crypto params missing | X=0 → 6; sed-shifted files → 6 |
179
+ | 7 | verification failed (content digest, attribute signature, chain) | 1-byte ciphertext tamper → 7; cert bytes zeroed → 7; header edit → 7 |
180
+ | 8 | file shorter than 0x400 | trunc.bkup → 8 |
181
+ | 11 | short write to the output file (residual default err; seen when `-o /dev/stdout` truncates the stream) — **not reproduced in clone**: v4.2.8 short-write artifact with no clone code path | 4.2.8 binary, v1 probe to /dev/stdout |
182
+ | 12 | unknown majversion (`Unknown backup majversion: %d`) or version gate (`Invalid archive`) | majversion=3 → 12; version=9 → 12 |
183
+ | 13 | decrypt failed — in practice wrong password (EVP_CipherFinal padding) | wrongpass → 13 |
184
+ | 32 | unsupported algo | algo=2 → 32 |
185
+
186
+ **Verification is enforced**: an early "tampering is accepted" conclusion
187
+ was an artifact of csh mangling `dd … 2>/dev/null` (all those tamper
188
+ commands were silent no-ops). With surgical `dd conv=notrunc` patches the
189
+ tool fails closed on every corrupted byte in the ciphertext, signature,
190
+ attributes, or certificate regions.
191
+
192
+ ### Boundary probes (device, all derived from `test.bkup`)
193
+
194
+ | probe | edit | code | conclusion |
195
+ |---|---|---|---|
196
+ | yt.bkup | +16 zero bytes appended | 13 | decryption is bounded by **filesize**, not the be32 Y field — Y is informational; trailing garbage lands in the last CBC block → padding fails |
197
+ | ys.bkup | last 16 bytes removed | 13 | a file shorter than Y simply decrypts short (no separate Y error) |
198
+ | mvx.bkup | `majversion=x` (same length) | 13 | non-numeric majversion → parse fails → **defaults to 1** (v1 path) like an absent key; "anything else → 12" applies to parsed numbers only |
199
+
200
+ ### CLI probes (device)
201
+
202
+ | invocation | stderr |
203
+ |---|---|
204
+ | `decbackup -z` | `decbackup: illegal option -- z` + usage |
205
+ | `-p a -p b …` | `-p can only be specified once` — **no usage dump** |
206
+ | missing `-i`/`-o` | usage only |
207
+
208
+ ### The version gate (`fwcrypto_maj_get_version`, 0x11820)
209
+
210
+ `cmp ax,1 → error 12 ("Invalid archive")` at 0x13194 on the flags&2 backup
211
+ path. Observations (all same-length edits, X intact unless noted):
212
+
213
+ | header `version` | real code | interpretation |
214
+ |---|---|---|
215
+ | `5.1.4` (real) | pass | parse fails → default 1 |
216
+ | `1` | pass | parses to 1 |
217
+ | `9` (−4 shift) | 12 | parses to 9 → gate **before** the X check |
218
+ | `9 ` (padded) | 7 | trailing spaces → parse fails → default |
219
+ | `10000`/`90000` | 7 | 5-digit values → parse fails → default |
220
+ | `abcde`, `2.0.0` | 7 | parse fails → default |
221
+
222
+ Empirical rule implemented: value **parses** only when it is leading
223
+ whitespace + 1–4 digits + end (strict, no trailing chars); a parsed value
224
+ ≠ 1 → error 12. The digit-count cap (why 5-digit strings fail) is inferred,
225
+ not read off `str_get_int_ex` conclusively — the 4-vs-3 boundary is
226
+ untested. Real backups always carry dotted versions → always pass.
227
+
228
+ ## 7. Methodology gotchas (for future probes)
229
+
230
+ 1. **csh eats `2>/dev/null`** — `dd`/redirects with `2>` become silent
231
+ no-ops ("Unknown operand"). Tamper commands *must* omit it.
232
+ 2. **sed edits change file length** → everything after the header shifts →
233
+ the X field at 0x400 reads garbage → you measure the X check, not the
234
+ thing you probed. Only same-length substitutions, or patch with
235
+ `dd conv=notrunc`.
236
+ 3. `cmp` is not whitelisted; csh reports command-not-found as exit 1,
237
+ which looks exactly like "files differ". Use `openssl dgst -sha256`.
238
+ 4. Probe files: byte-identical local/VM copies verified by sha256
239
+ computed manually on both sides at extraction time (digests are not
240
+ stored in the repo), so device-observed codes apply to the vectors.
241
+
242
+ ## 8. Robustness contract (fuzzing)
243
+
244
+ `analysis/fuzz.py` mutation-fuzzes container bytes and the PKCS#7/DER
245
+ parsers against the contract: **any** input must end in a `BackupError`
246
+ with a documented code, never a traceback, never a surviving output
247
+ file, never a hang (5 s guard). Seeds are the real vectors plus every
248
+ input that ever violated the contract (`$TMPDIR/opencode/fuzz_failures/`,
249
+ in practice `/tmp/opencode/fuzz_failures/` — a machine-local, volatile
250
+ scratch dir, not versioned; the pinned regression vectors are
251
+ `extdup.bkup` + `lazyparse.bkup`); mutations cover bit flips, splices,
252
+ truncations, X/Y field corruption and random files with plausible headers.
253
+
254
+ The first campaign (60k iterations, 2 seeds) surfaced one systemic
255
+ class: `cryptography` parses certificate fields **lazily**, so a
256
+ structurally-valid-but-tampered cert loads fine and then explodes on
257
+ `.subject` / `.public_key()` / `.extensions` — including
258
+ `DuplicateExtension`, which derives from bare `Exception`. Both verify
259
+ boundaries (`SignerInfo.verify`, `trust.verify_chain`) now convert any
260
+ unexpected parser exception into the fail-closed error 7. Two crash
261
+ inputs are pinned as vectors (`extdup.bkup`, `lazyparse.bkup`).
262
+ Re-runs: 90k iterations / 3 seeds clean; re-run before every release.
263
+
264
+ ## 9. Clone deviations from the device (security hardening)
265
+
266
+ Deliberate behavior changes where the clone is *not* device-faithful:
267
+
268
+ * **Chain terminators**: a self-signed chain end must match an embedded
269
+ CA certificate **byte-exactly**. The device binds through an OpenSSL
270
+ store lookup to the genuine cert; the clone's earlier subject+serial
271
+ match was exploitable (forged chains accepted).
272
+ * **Chain signature algs**: explicit allow-list — unknown OIDs rejected,
273
+ `…1.1.5` → SHA-1, MD5 not accepted.
274
+ * **certificatePolicies**: an extension that fails to parse → reject
275
+ (previously fail-open).
276
+ * **Issuers**: intermediate/root issuers must carry `CA:TRUE` (matches
277
+ the device's INVALID_CA).
278
+ * **signerInfos**: **all** signerInfos are verified (previously only the
279
+ first).
280
+ * **Structure**: duplicate or missing `contentType` authAttrs, unknown
281
+ PKCS#7 signature algs, and malformed DER children (child extending
282
+ beyond its parent, oversized OID components) → reject.
283
+ * **pbkdf_iterations**: ≥ 2^31 → error 6; ASCII digits only. Device
284
+ analog: OpenSSL "iteration value is too great" (exact code unprobed).
285
+ * **iv**: wrong-length iv / whitespace-laced hex → error 6 at parse time
286
+ (inferred).
287
+ * **`-P`**: now actually prompts without echo on a tty (parity intent);
288
+ pipe-without-tty → empty password is unchanged (device-probed 13).
289
+ * **Passwords**: non-UTF-8 passwords remain a clone limitation (the
290
+ device uses raw argv bytes).
291
+
292
+ ## 10. Open items / uncertainties
293
+
294
+ * v1 KDF **empirically confirmed** on both VMs (§12). The IV window is
295
+ static-confirmed (identical `lea`-pool layout in both binaries) but
296
+ RC-invisible in probes (CBC padding ignores the IV and the digest can
297
+ never match a copied trailer); it is the pool[0:16] window.
298
+ * Exact digit-count cap of the version gate parser (implemented ≤4).
299
+ * Error code when the *output* file can't be created: assumed 1.
300
+ * `pbkdf_iterations` default when the key is missing: assumed 1
301
+ (`mov esi, 1` near the getter); real files always carry the key.
302
+ * A crafted `pbkdf_iterations` of ~10^9 stalls verification for minutes
303
+ on the clone AND on the device (the value is trusted straight from the
304
+ header) — DoS-by-input is a shared property, not a clone deviation.
305
+
306
+ ## 11. Test vectors (`tests/vectors/`)
307
+
308
+ Made with `encbackup -i <tar> -o <bkup> -t all -p <pass>` on the device,
309
+ fetched by base64-over-ssh, and cross-validated against the real
310
+ `decbackup` output. Error vectors are byte-identical to the device-side
311
+ probe files — verified by comparing digests manually on both sides at
312
+ extraction time; the digests themselves were never recorded in the repo.
313
+
314
+ Privacy note: the vectors are real device output and cannot be edited
315
+ without breaking the signature they exercise, so they carry the lab
316
+ device's certificate (public by design) and the default evaluation-VM
317
+ serial; the payload tars are synthetic and the passwords are the test
318
+ values shown below. No private key material is present in any vector.
319
+
320
+ | vector | payload | password | code |
321
+ |---|---|---|---|
322
+ | test.bkup | test.tar (2 KiB) | `testpass` | 0 |
323
+ | nopass.bkup | t2.tar | (empty) | 0 |
324
+ | big.bkup | big.tar | `BigPass789` | 0 |
325
+ | mid.bkup | mid.tar (23 KiB, multi-chunk) | `MidPass321` | 0 |
326
+ | trunc/xzero/ct/cert/mv3/v9 | — | `testpass` | 8/6/7/7/12/7 |
327
+ | v1probe.bkup / v1badkey.bkup | test.tar (§12, v1-encrypted) | `testpass` | 7 / 13 |
328
+
329
+ ## 12. Second VM: SNS v4.2.8
330
+
331
+ Older box, same EVA1-VM hardware line, the same default evaluation-VM
332
+ serial as the 5.1.4 box, clock
333
+ pinned to 2020. All four binaries differ from 5.1.4
334
+ (`decbackup fca8df91…`, `libncrypto.so ddca97b1…`). Its `decbackup` usage
335
+ string lacks the `-P` line; `encbackup` still writes **majversion=2**
336
+ (PBKDF2, 10000 iterations) with the identical container layout — so no
337
+ release in this VM line emits v1.
338
+
339
+ * **Cross-version compatibility (CONFIRMED)**: the clone decrypts a
340
+ v4.2.8-produced backup byte-exactly, and the v4.2.8 device `decbackup`
341
+ reproduces the same sha256. Same PKI chain as 5.1.4
342
+ (device ← Products CA ← Products Root CA).
343
+ * **v1 path present and identical in structure**: `upd_update` (flags&2)
344
+ → `fwcrypto_get_backup_majversion` → `==1` branch calls
345
+ `fwcrypto_keyfrompasswd(pass, 0x10)` + `fwcrypto_ivfrompasswd(pass, 0x10)`
346
+ (0xd692/0xd6ac), then the same streaming decrypt + `VerifySignatureFinal`
347
+ tail — v1 archives are signed containers too, and VSI failure is still
348
+ fail-closed (error 6). The version gate (`cmp ax,1` → 12) is the same.
349
+ * **v1 salt windows**: both binaries read the *same* windows of the shared
350
+ 48-byte pool (key = pool+16, iv = pool+0). Resolved by `lea`
351
+ instruction-by-instruction in both (`sns` 0x16d8d→file 0x1b420,
352
+ 0x16ac4→0x1b410; `sns428` 0x10d98→0x18030, 0x10aaf→0x18020), and
353
+ confirmed empirically with the probe matrix below.
354
+ * **v1 KDF probe matrix** (v1 header via same-length `majversion=2→1`
355
+ edit of a real v4.2.8 backup, copied valid trailer, content re-encrypted
356
+ under each salt×iv combination; padding validity is the key oracle, CBC
357
+ IV cannot affect it):
358
+
359
+ | ciphertext made with | old-VM (4.2.8) | 5.1.4 VM |
360
+ |---|---|---|
361
+ | pool+16 key (correct) | 7 (both IVs) | 7 |
362
+ | shifted key `212807a5…` | 13 | 13 |
363
+
364
+ `7` = decryption succeeded, digest check failed (expected: the trailer's
365
+ messageDigest covers the original v2 content). This proves the key
366
+ window on both binaries; the IV window follows from the identical
367
+ `lea`-pool layout (RC-invisible by construction).
368
+ * **4.2.8 embedded trust blobs** (libncrypto .data, six certs): the two
369
+ 5.1.4 roots + Enrollment Products CA, **no NETASQ root**, plus three
370
+ service certs for the update path (`firmware.sns.local`,
371
+ `update.sns.local`, `signvmkit.sns.local`, issued by "Stormshield
372
+ Network Security CA"). The NETASQ-issuer special case in the verify
373
+ callback therefore postdates 4.2.8. 4.2.8 also exposes older API
374
+ surface (`fwcrypto_dec_maj_file`, `fwcrypto_check_maj`,
375
+ `fwcrypto_backup_read_algo`, 3DES/Blowfish/DES helpers).
376
+ * **Error 11** appears on the old binary: the stream loop sets
377
+ `[err]=11` as the default before each output `fwrite` and a failing
378
+ `EVP_CipherFinal`/short-write path leaves it in place. Observed when
379
+ pointing `-o` at `/dev/stdout` (pipe semantics + `remove()` of the
380
+ symlink); a regular output file behaves as on 5.1.4.
381
+ * **Ingestion channel** (SCP blocked, echo-chunks proved lossy — csh
382
+ `echo` corrupted some base64 payloads at identical length): pipe bytes
383
+ straight through ssh stdin into `dd of=/tmp/x bs=<len> count=1`, then
384
+ `openssl base64 -A -d -in /tmp/x -out /tmp/y`. The `-A` flag is
385
+ required on this box's OpenSSL 1.1.1k for single-line payloads;
386
+ verify with `md5` on both ends.
@@ -5,6 +5,6 @@ container handling of ``libncrypto.so`` from Stormshield SNS v5.1.4,
5
5
  derived by reverse engineering (see ``RECON.md``).
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
9
9
 
10
10
  from .backup import BackupError, decrypt_backup # noqa: F401