decbackup 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.
decbackup/pkcs7.py ADDED
@@ -0,0 +1,239 @@
1
+ """Detached PKCS#7 SignedData verification for Stormshield backups.
2
+
3
+ Recovered from ``libncrypto.so`` (``VerifyMajSignatureInit`` /
4
+ ``VerifySignatureFinal`` and the OpenSSL 3 ``PKCS7_*`` helpers they call):
5
+
6
+ * the trailer is a detached ``SignedData`` — the content itself is not
7
+ encapsulated;
8
+ * the signed content is the **full 0x400-byte header block** (text plus
9
+ NUL padding) followed by the decrypted payload;
10
+ * the signature is RSA/ECDSA over the DER of the authenticated attributes
11
+ with the ``[0]`` context tag replaced by ``SET`` (``0x31``); Stormshield's
12
+ encoder stores the attribute SEQUENCEs directly under ``[0]`` without a
13
+ SET wrapper, so the tag swap also has to re-emit the length;
14
+ * the ``messageDigest`` attribute must equal ``SHA256(content)``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from collections.abc import Callable
21
+
22
+ from cryptography import x509
23
+ from cryptography.hazmat.primitives import hashes
24
+ from cryptography.hazmat.primitives.asymmetric import ec, padding
25
+
26
+ from .der import DERError, children, encode_len, oid_str, read_tlv
27
+
28
+ OID_SIGNED_DATA = "1.2.840.113549.1.7.2"
29
+ OID_PKCS7_DATA = "1.2.840.113549.1.7.1"
30
+ OID_CONTENT_TYPE = "1.2.840.113549.1.9.3"
31
+ OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4"
32
+
33
+ #: digestAlgorithm OIDs (hash algorithms only; rsaEncryption's hash comes
34
+ #: from this field, per _SIG_ALGS below)
35
+ _HASHES: dict[str, Callable[[], hashes.HashAlgorithm]] = {
36
+ "1.2.840.113549.1.1.14": hashes.SHA224,
37
+ "2.16.840.1.101.3.4.2.1": hashes.SHA256,
38
+ "2.16.840.1.101.3.4.2.2": hashes.SHA384,
39
+ "2.16.840.1.101.3.4.2.3": hashes.SHA512,
40
+ }
41
+
42
+ _SIG_ALGS = {
43
+ # rsaEncryption: the DigestInfo lives in the padding, so the digest
44
+ # algorithm comes from the SignerInfo's digestAlgorithm field.
45
+ "1.2.840.113549.1.1.1": "rsa",
46
+ "1.2.840.113549.1.1.10": "rsa-pss",
47
+ "1.2.840.113549.1.1.11": "rsa",
48
+ "1.2.840.113549.1.1.12": "rsa",
49
+ "1.2.840.113549.1.1.13": "rsa",
50
+ "1.2.840.10045.4.3.2": "ecdsa",
51
+ "1.2.840.10045.4.3.3": "ecdsa",
52
+ "1.2.840.10045.4.3.4": "ecdsa",
53
+ }
54
+
55
+
56
+ class SignatureError(Exception):
57
+ """Signature/attribute verification failed (original error code 7)."""
58
+
59
+
60
+ class SignerInfo:
61
+ """One parsed ``SignerInfo`` with its raw DER pieces."""
62
+
63
+ def __init__(self, buf: bytes, start: int, end: int):
64
+ fields = list(children(buf, start, end))
65
+ if len(fields) < 6:
66
+ raise SignatureError("truncated SignerInfo")
67
+ self.version = int.from_bytes(buf[fields[0][1] : fields[0][3]], "big")
68
+ self.sid = fields[1]
69
+ self.digest_alg = oid_str(buf, *_oid_span(buf, fields[2]))
70
+ # fields[3] is [0] authenticatedAttributes, fields[4] the signature
71
+ # AlgorithmIdentifier and fields[5] the OCTET STRING signature.
72
+ self.sig_alg = oid_str(buf, *_oid_span(buf, fields[4]))
73
+ attrs = fields[3]
74
+ if attrs[0] & 0xC0 != 0x80:
75
+ raise SignatureError("authenticatedAttributes missing")
76
+ # PKCS#7 signs the attributes as a SET OF. Stormshield stores them
77
+ # bare under the [0] tag; a standard encoder nests a SET inside.
78
+ value = buf[attrs[1] : attrs[3]]
79
+ if value and value[0] == 0x31:
80
+ self.attrs_der = value
81
+ else:
82
+ self.attrs_der = b"\x31" + encode_len(len(value)) + value
83
+ self.signature = buf[fields[5][1] : fields[5][3]]
84
+ self._attributes(buf, attrs)
85
+ self._sid(buf, self.sid)
86
+
87
+ def _attributes(self, buf: bytes, attrs) -> None:
88
+ self.content_type: str | None = None
89
+ self.message_digest: bytes | None = None
90
+ inner = buf[attrs[1] : attrs[3]]
91
+ start, end = 0, len(inner)
92
+ if inner and inner[0] == 0x31: # SET wrapper present
93
+ _, start, _, end = read_tlv(inner, 0)
94
+ for _tag, vs, _ln, ni in children(inner, start, end):
95
+ vals = list(children(inner, vs, ni))
96
+ if len(vals) < 2:
97
+ continue
98
+ oid = oid_str(inner, vals[0][1], vals[0][3])
99
+ # vals[1] is the SET OF holding the actual value TLV
100
+ try:
101
+ value = next(iter(children(inner, vals[1][1], vals[1][3])))
102
+ except (DERError, StopIteration):
103
+ continue
104
+ if oid == OID_CONTENT_TYPE:
105
+ self.content_type = oid_str(inner, value[1], value[3])
106
+ elif oid == OID_MESSAGE_DIGEST:
107
+ self.message_digest = inner[value[1] : value[3]]
108
+
109
+ def _sid(self, buf: bytes, sid) -> None:
110
+ """Extract issuer DER bytes + serial (issuerAndSerialNumber form)."""
111
+ self.sid_serial: int | None = None
112
+ self.sid_issuer_der: bytes | None = None
113
+ inner = list(children(buf, sid[1], sid[3]))
114
+ if len(inner) == 2:
115
+ # the issuer Name TLV starts right at the SID value start
116
+ self.sid_issuer_der = buf[sid[1] : inner[0][3]]
117
+ self.sid_serial = int.from_bytes(buf[inner[1][1] : inner[1][3]], "big", signed=False)
118
+
119
+ def find_signer_cert(self, certs: list[bytes]) -> bytes:
120
+ """Pick the certificate the SID designates.
121
+
122
+ Serial numbers are unique per issuer, so the serial match pins the
123
+ signer; a wrong pick would fail signature verification anyway.
124
+ """
125
+ for der in certs:
126
+ if self.sid_serial is None:
127
+ continue
128
+ try:
129
+ cert = x509.load_der_x509_certificate(der)
130
+ except ValueError as exc:
131
+ raise SignatureError(f"unparsable certificate: {exc}") from exc
132
+ if cert.serial_number == self.sid_serial:
133
+ return der
134
+ if len(certs) == 1: # no usable SID match; single candidate
135
+ return certs[0]
136
+ raise SignatureError("signer certificate not found in SignedData")
137
+
138
+ def verify(self, certs: list[bytes], content: bytes) -> bytes:
139
+ """Verify attributes signature + messageDigest over *content*.
140
+
141
+ Returns the signer certificate DER on success.
142
+ """
143
+ try:
144
+ return self._verify_inner(certs, content)
145
+ except SignatureError:
146
+ raise
147
+ except Exception as exc:
148
+ # cryptography parses certificate fields lazily and raises
149
+ # odd classes (ValueError, UnsupportedAlgorithm,
150
+ # DuplicateExtension, ...): at this attacker-input boundary
151
+ # anything unexpected must fail closed into error 7
152
+ raise SignatureError(f"certificate field error: {exc}") from exc
153
+
154
+ def _verify_inner(self, certs: list[bytes], content: bytes) -> bytes:
155
+ signer_der = self.find_signer_cert(certs)
156
+ if self.content_type not in (None, OID_PKCS7_DATA):
157
+ raise SignatureError("unexpected contentType")
158
+ if self.message_digest is None:
159
+ raise SignatureError("missing messageDigest attribute")
160
+ hasher = _HASHES.get(self.digest_alg)
161
+ if hasher is None:
162
+ raise SignatureError(f"unsupported digest algorithm {self.digest_alg}")
163
+ if hashlib.new(hasher().name, content).digest() != self.message_digest:
164
+ raise SignatureError("messageDigest mismatch")
165
+ try:
166
+ cert = x509.load_der_x509_certificate(signer_der)
167
+ except ValueError as exc:
168
+ raise SignatureError(f"unparsable signer certificate: {exc}") from exc
169
+ _verify(cert.public_key(), self.signature, self.attrs_der, self.sig_alg, hasher)
170
+ return signer_der
171
+
172
+
173
+ def _oid_span(buf: bytes, alg_tlv):
174
+ """(value_start, value_end) of the OID inside an AlgorithmIdentifier."""
175
+ _, vs, _, ni = alg_tlv
176
+ oid = read_tlv(buf, vs) # first child is the algorithm OID
177
+ return oid[1], oid[3]
178
+
179
+
180
+ def _verify(pubkey, signature: bytes, signed: bytes, sig_alg: str, hasher):
181
+ kind = _SIG_ALGS.get(sig_alg, "rsa")
182
+ try:
183
+ if kind == "rsa":
184
+ pubkey.verify(signature, signed, padding.PKCS1v15(), hasher())
185
+ elif kind == "rsa-pss":
186
+ pubkey.verify(
187
+ signature,
188
+ signed,
189
+ padding.PSS(mgf=padding.MGF1(hasher()), salt_length=padding.PSS.MAX_LENGTH),
190
+ hasher(),
191
+ )
192
+ else: # ecdsa
193
+ pubkey.verify(signature, signed, ec.ECDSA(hasher()))
194
+ except SignatureError:
195
+ raise
196
+ except Exception as exc: # includes InvalidSignature / UnsupportedAlgorithm
197
+ raise SignatureError(f"signature verification failed: {exc}") from exc
198
+
199
+
200
+ def parse_signed_data(sig_der: bytes):
201
+ """Parse a DER ``ContentInfo`` of type signedData.
202
+
203
+ Returns ``(certs, signers)`` — certificate DER blobs and
204
+ :class:`SignerInfo` objects. Raises :class:`SignatureError` on any
205
+ structural problem (original fail-closed behaviour, error code 6).
206
+ """
207
+ try:
208
+ _tag, vs, _ln, ni = read_tlv(sig_der, 0)
209
+ content_info = list(children(sig_der, vs, ni))
210
+ if oid_str(sig_der, content_info[0][1], content_info[0][3]) != OID_SIGNED_DATA:
211
+ raise SignatureError("trailer is not a SignedData")
212
+ sd = content_info[1] # [0] EXPLICIT SignedData
213
+ inner = read_tlv(sig_der, sd[1]) # the SignedData SEQUENCE itself
214
+ fields = list(children(sig_der, inner[1], inner[3]))
215
+
216
+ certs: list[bytes] = []
217
+ signers: list[SignerInfo] = []
218
+ # SignedData field order is fixed: version, digestAlgorithms,
219
+ # encapContentInfo, [certificates], [crls], signerInfos (last).
220
+ for f in fields:
221
+ if f[0] == 0xA0: # certificates [0] — cert SEQUENCEs stored bare
222
+ i = f[1]
223
+ while i < f[3]:
224
+ t, _vs, _l2, ni2 = read_tlv(sig_der, i)
225
+ if t != 0x30:
226
+ break
227
+ certs.append(sig_der[i:ni2])
228
+ i = ni2
229
+ if fields and fields[-1][0] in (0x30, 0x31):
230
+ for si in children(sig_der, fields[-1][1], fields[-1][3]):
231
+ signers.append(SignerInfo(sig_der, si[1], si[3]))
232
+ if not signers:
233
+ raise SignatureError("SignedData has no signerInfos")
234
+ return certs, signers
235
+ except SignatureError:
236
+ raise
237
+ except (DERError, ValueError, IndexError, StopIteration) as exc:
238
+ # the whole trailer walk must fail closed into error code 6
239
+ raise SignatureError(f"cannot parse PKCS#7 trailer: {exc}") from exc
decbackup/trust.py ADDED
@@ -0,0 +1,187 @@
1
+ """Chain verification against the CAs embedded in ``libncrypto.so``.
2
+
3
+ ``fw_libcrypto_get_trust_store`` (backup type) installs four hard-coded CA
4
+ certificates found in the library's .rodata at 0x8890, 0x6b40, 0x8f40 and
5
+ 0x6f60, sets ``X509_V_FLAG_NO_CHECK_TIME`` (expired device certificates
6
+ still verify — backups stay readable forever) and adds the policy OID
7
+ ``1.3.6.1.4.1.11256.2.3`` to the verify parameters. The verify callback
8
+ accepts error codes 0, 9, 10 and 43 (NETASQ-issued), which boils down to:
9
+ build a signature-valid chain to one of the embedded roots, ignore dates.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import warnings
15
+ from collections.abc import Callable
16
+ from functools import lru_cache
17
+ from importlib import resources
18
+
19
+ from cryptography import x509
20
+ from cryptography.hazmat.primitives import hashes
21
+ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
22
+
23
+ #: certificatePolicies OID the verify parameters require (EXPLICIT_POLICY)
24
+ BACKUP_POLICY_OID = "1.3.6.1.4.1.11256.2.3"
25
+
26
+ EXT_CERTIFICATE_POLICIES = "2.5.29.32"
27
+
28
+ _MAX_DEPTH = 8
29
+
30
+ _RSA_HASHES: dict[str, Callable[[], hashes.HashAlgorithm]] = {
31
+ "1.2.840.113549.1.1.5": hashes.MD5,
32
+ "1.2.840.113549.1.1.11": hashes.SHA256,
33
+ "1.2.840.113549.1.1.12": hashes.SHA384,
34
+ "1.2.840.113549.1.1.13": hashes.SHA512,
35
+ }
36
+
37
+ _ECDSA_HASHES: dict[str, Callable[[], hashes.HashAlgorithm]] = {
38
+ "1.2.840.10045.4.3.2": hashes.SHA256,
39
+ "1.2.840.10045.4.3.3": hashes.SHA384,
40
+ "1.2.840.10045.4.3.4": hashes.SHA512,
41
+ }
42
+
43
+
44
+ class ChainError(Exception):
45
+ """Chain construction failed (original error code 7)."""
46
+
47
+
48
+ def load_trust_store() -> list[bytes]:
49
+ """DER blobs of the four CA certificates embedded in libncrypto."""
50
+ store = resources.files(__package__).joinpath("certs")
51
+ names = (
52
+ "stormshield-sns-enrollment-products-ca.der",
53
+ "netasq-firewall-ca.der",
54
+ "stormshield-products-root-ca.der",
55
+ "stormshield-product-and-services-root-ca.der",
56
+ )
57
+ with warnings.catch_warnings():
58
+ # the NETASQ CA carries a non-positive serial (RFC 5280 violation
59
+ # in the real CA); the original tool loads it fine.
60
+ warnings.simplefilter("ignore")
61
+ return [store.joinpath(n).read_bytes() for n in names]
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def _netasq_subject_der() -> bytes:
66
+ der = (
67
+ resources.files(__package__)
68
+ .joinpath("certs")
69
+ .joinpath("netasq-firewall-ca.der")
70
+ .read_bytes()
71
+ )
72
+ return _load_cert(der).subject.public_bytes()
73
+
74
+
75
+ def _netasq_issued(cert: x509.Certificate) -> bool:
76
+ """``fw_libcrypto_is_netasq_issued``: DER memcmp of the issuer Name."""
77
+ return cert.issuer.public_bytes() == _netasq_subject_der()
78
+
79
+
80
+ def _verify_cert_signature(cert: x509.Certificate, issuer: x509.Certificate) -> None:
81
+ """Check *cert*'s signature against *issuer*'s public key."""
82
+ sig, tbs = cert.signature, cert.tbs_certificate_bytes
83
+ alg = cert.signature_algorithm_oid.dotted_string
84
+ key = issuer.public_key()
85
+ try:
86
+ if isinstance(key, rsa.RSAPublicKey):
87
+ if alg == "1.2.840.113549.1.1.10": # rsassa-pss
88
+ key.verify(
89
+ sig,
90
+ tbs,
91
+ padding.PSS(
92
+ mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH
93
+ ),
94
+ hashes.SHA256(),
95
+ )
96
+ else:
97
+ key.verify(sig, tbs, padding.PKCS1v15(), _RSA_HASHES.get(alg, hashes.SHA256)())
98
+ elif isinstance(key, ec.EllipticCurvePublicKey):
99
+ key.verify(sig, tbs, ec.ECDSA(_ECDSA_HASHES.get(alg, hashes.SHA256)()))
100
+ else:
101
+ raise ChainError(f"unsupported issuer key type: {type(key)}")
102
+ except ChainError:
103
+ raise
104
+ except Exception as exc:
105
+ raise ChainError(f"certificate signature check failed: {exc}") from exc
106
+
107
+
108
+ def _load_cert(der: bytes) -> x509.Certificate:
109
+ with warnings.catch_warnings():
110
+ warnings.simplefilter("ignore")
111
+ try:
112
+ return x509.load_der_x509_certificate(der)
113
+ except ValueError as exc:
114
+ raise ChainError(f"unparsable certificate: {exc}") from exc
115
+
116
+
117
+ def verify_chain(leaf_der: bytes, untrusted: list[bytes]) -> list[bytes]:
118
+ """Build and cryptographically verify a chain from the leaf certificate
119
+ to one of the embedded trust anchors.
120
+
121
+ *untrusted* holds the extra certificates carried in the SignedData.
122
+ Validity dates are ignored, mirroring the original's NO_CHECK_TIME.
123
+ Returns the chain (leaf first, DER blobs).
124
+ """
125
+ try:
126
+ return _verify_chain_inner(leaf_der, untrusted)
127
+ except ChainError:
128
+ raise
129
+ except Exception as exc:
130
+ # lazy field parsing in tampered certificates raises assorted
131
+ # classes (ValueError, UnsupportedAlgorithm, DuplicateExtension,
132
+ # ...); everything must fail closed into the original's error 7
133
+ raise ChainError(f"certificate field error: {exc}") from exc
134
+
135
+
136
+ def _verify_chain_inner(leaf_der: bytes, untrusted: list[bytes]) -> list[bytes]:
137
+ trusted = load_trust_store()
138
+ trusted_certs = [_load_cert(der) for der in trusted]
139
+ others = [_load_cert(der) for der in untrusted]
140
+
141
+ leaf = _load_cert(leaf_der)
142
+ try:
143
+ _check_policy(leaf)
144
+ except ChainError:
145
+ # the verify callback accepts error 43 (no matching policy) only
146
+ # for NETASQ-issued certs (fw_libcrypto_is_netasq_issued)
147
+ if not _netasq_issued(leaf):
148
+ raise
149
+
150
+ chain = [leaf_der]
151
+ cur = leaf
152
+ for _ in range(_MAX_DEPTH):
153
+ if cur.issuer == cur.subject: # self-signed: must be an anchor
154
+ for anchor in trusted_certs:
155
+ if anchor.subject == cur.subject and anchor.serial_number == cur.serial_number:
156
+ _verify_cert_signature(cur, cur)
157
+ return chain
158
+ raise ChainError("self-signed certificate is not trusted")
159
+ found: tuple[x509.Certificate, bytes] | None = None
160
+ for der, cand in zip(untrusted + trusted, others + trusted_certs):
161
+ if cand.subject == cur.issuer:
162
+ try:
163
+ _verify_cert_signature(cur, cand)
164
+ except ChainError:
165
+ continue
166
+ found = (cand, der)
167
+ break
168
+ if found is None:
169
+ raise ChainError(f"unable to get issuer certificate: {cur.issuer.rfc4514_string()}")
170
+ cur, der = found
171
+ chain.append(der)
172
+ raise ChainError("maximum chain depth exceeded")
173
+
174
+
175
+ def _check_policy(cert: x509.Certificate) -> None:
176
+ """The verify parameters require the Stormshield backup policy OID."""
177
+ try:
178
+ ext = cert.extensions.get_extension_for_class(x509.CertificatePolicies).value
179
+ except x509.ExtensionNotFound:
180
+ return # no policy extension: unconstrained, OpenSSL accepts it
181
+ try:
182
+ ids = [p.policy_identifier.dotted_string for p in ext]
183
+ except Exception:
184
+ return
185
+ if BACKUP_POLICY_OID in ids or "2.5.29.32.0" in ids: # anyPolicy
186
+ return
187
+ raise ChainError(f"certificate policy {BACKUP_POLICY_OID} not satisfied")
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: decbackup
3
+ Version: 0.1.0
4
+ Summary: Python clone of Stormshield SNS's decbackup: decrypt and verify .bkup appliance backups without the device
5
+ Author: fl0l0u
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/fl0l0u/decbackup
8
+ Project-URL: Documentation, https://github.com/fl0l0u/decbackup#readme
9
+ Project-URL: Repository, https://github.com/fl0l0u/decbackup.git
10
+ Project-URL: Issues, https://github.com/fl0l0u/decbackup/issues
11
+ Keywords: stormshield,sns,backup,decbackup,pkcs7,forensics
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Security :: Cryptography
18
+ Classifier: Topic :: System :: Archiving
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: cryptography>=41
23
+ Provides-Extra: dev
24
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ <h1 align="center">
28
+ <br>
29
+ decbackup
30
+ <br>
31
+ </h1>
32
+
33
+ <h4 align="center">Python clone of Stormshield SNS's backup decryption utility, built from a full reverse-engineering of decbackup + libncrypto.so</h4>
34
+
35
+ <p align="center">
36
+ <a href="https://pypi.org/project/decbackup/">
37
+ <img src="https://img.shields.io/pypi/v/decbackup?style=flat-square" alt="PyPI">
38
+ </a>
39
+ <a href="LICENSE">
40
+ <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
41
+ </a>
42
+ <img src="https://img.shields.io/badge/platform-any-lightgrey?style=flat-square" alt="Platform">
43
+ <img src="https://img.shields.io/badge/python-3.9%2B-yellow?style=flat-square" alt="Python">
44
+ <a href="https://github.com/fl0l0u/decbackup/actions">
45
+ <img src="https://img.shields.io/github/actions/workflow/status/fl0l0u/decbackup/ci.yml?style=flat-square&label=CI" alt="CI">
46
+ </a>
47
+ </p>
48
+
49
+ <p align="center">
50
+ <a href="#features">Features</a> •
51
+ <a href="#installation">Installation</a> •
52
+ <a href="#usage">Usage</a> •
53
+ <a href="#the-format">The Format</a> •
54
+ <a href="#development">Development</a> •
55
+ <a href="#license">License</a>
56
+ </p>
57
+
58
+ ---
59
+
60
+ **decbackup** decrypts and verifies Stormshield Network Security (SNS)
61
+ backup files (`.bkup`) — no appliance required. It is a faithful Python
62
+ reimplementation of `/usr/Firewall/sbin/decbackup` and the backup container
63
+ code of `libncrypto.so`, derived from SNS **v5.1.4** by static analysis
64
+ plus an empirical error-code matrix against a live device (see
65
+ [RECON.md](RECON.md)).
66
+
67
+ ## Features
68
+
69
+ | Feature | Description |
70
+ |---------|-------------|
71
+ | **Byte-exact format support** | `[BACKUP]` header, detached PKCS#7 trailer, AES-128-CBC payload |
72
+ | **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) |
74
+ | **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
+ | **Tiny dependency set** | `cryptography` only; DER/PKCS#7 parsing is in-tree (`decbackup/der.py`, `pkcs7.py`) |
76
+
77
+ ## Installation
78
+
79
+ ```bash
80
+ pip install decbackup
81
+ ```
82
+
83
+ Or from a checkout:
84
+
85
+ ```bash
86
+ pip install .
87
+ ```
88
+
89
+ The wheel bundles everything needed at runtime: the package code, the
90
+ four CA certificates extracted from `libncrypto.so` (`decbackup/certs/`),
91
+ and a `decbackup` console script. The only dependency is
92
+ `cryptography`.
93
+
94
+ ## Usage
95
+
96
+ ```
97
+ Usage : () -i <backup> -o <output archive>
98
+ [-p <password>] [-d : dump backup header]
99
+ [-P : prompt for password]
100
+ [--no-verify : skip signature verification (clone extension)]
101
+ ```
102
+
103
+ ```bash
104
+ decbackup -i firewall.bkup -o archive.tar -p 's3cret'
105
+ tar tf archive.tar
106
+ ```
107
+
108
+ Success is silent (exit 0), exactly like the original; failure prints
109
+ `decrypt and/or verify backup failed (%d)` with the appliance's own error
110
+ code. `-d` prints the decoded header block after a successful run.
111
+
112
+ `--no-verify` has no counterpart on the device: it skips trailer
113
+ parsing and signature/chain checks to rescue backups whose signature
114
+ region is corrupted. It prints a warning and the output is
115
+ **unauthenticated** — decryption alone proves the password was right,
116
+ not that the backup came from a genuine appliance.
117
+
118
+ Library use:
119
+
120
+ ```python
121
+ from decbackup import decrypt_backup, BackupError
122
+ try:
123
+ header = decrypt_backup("firewall.bkup", "archive.tar", "s3cret")
124
+ print(header["model"], header["date"])
125
+ except BackupError as e:
126
+ print("failed:", e.code)
127
+ ```
128
+
129
+ ## The Format
130
+
131
+ ```
132
+ 0x000 0x400 [BACKUP] key=value header, NUL-terminated, zero-padded
133
+ 0x400 4 be32 X — PKCS#7 trailer length
134
+ 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)
137
+ ```
138
+
139
+ The signature covers the **full 0x400-byte header block plus the decrypted
140
+ payload**; it chains device cert → *Stormshield Network Security Products
141
+ CA* → *Stormshield Products Root CA* (embedded). Expired device
142
+ certificates still verify — the appliance ignores dates, so backups stay
143
+ readable forever. Full details in [RECON.md](RECON.md).
144
+
145
+ ## Development
146
+
147
+ ```
148
+ decbackup/ the package (cli, backup format, crypto, pkcs7, der, trust, certs/)
149
+ analysis/ RE tooling: disassembler, strings, symbol lists, the device
150
+ 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)
154
+ legacy/ the earlier single-file iteration (kept for reference)
155
+ ```
156
+
157
+ ```bash
158
+ python tests/test_decbackup.py # exit code = number of failures
159
+ python analysis/probes.py # real-vs-clone error-code parity table
160
+ python analysis/fuzz.py 30000 # robustness contract fuzzer
161
+ ```
162
+
163
+ ## Releasing
164
+
165
+ 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
169
+ (`.github/workflows/ci.yml`).
170
+
171
+ ```bash
172
+ git tag v0.1.0 && git push origin v0.1.0 # → PyPI
173
+ ```
174
+
175
+ ## License
176
+
177
+ Apache License 2.0 — see [LICENSE](LICENSE). The patent grant and
178
+ patent-retaliation clauses of Apache-2.0 apply.
179
+
180
+ *Stormshield* is a trademark of Stormshield. This project is unaffiliated
181
+ with Stormshield; it exists so that backups you own remain readable.
@@ -0,0 +1,18 @@
1
+ decbackup/__init__.py,sha256=sWQ0R-P295fXLCrfaHJccBeXfTEiXt-5Vi1TuCEPfdc,359
2
+ decbackup/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ decbackup/backup.py,sha256=fWOkiHkUyV54x6rB_MATBduLWeGw-aOl9FefQYWLXIU,9322
4
+ decbackup/cli.py,sha256=Cfr66S6lPlCMucLwYhq-AR38XOQOwAbjzp2I1tZm8vI,3427
5
+ decbackup/crypto.py,sha256=tehNM2E7mrVB1MHw03Tk97GjKXQAeF5KOx5wU7fXrLM,2924
6
+ decbackup/der.py,sha256=N9Exvr-VVgB5M198eyg3c8Le-oHszzFj-ozhZhJvoS8,2372
7
+ decbackup/pkcs7.py,sha256=FpQcKsbj-r5MeZNxkLbDodf70FgBM3I8rhBa20OLyXQ,10332
8
+ decbackup/trust.py,sha256=xco9bS0-4thiomO4Nlc71wXqvrVXoGpFIlHBcyh3cNc,7144
9
+ decbackup/certs/netasq-firewall-ca.der,sha256=_KqQzWp4c1JZikmL5O_CwxXIpF6zCzwmT5TYd0GhUrs,1048
10
+ decbackup/certs/stormshield-product-and-services-root-ca.der,sha256=v-7_lMWO1oo6-ecdO7qLVRqVOhI1E46s6-4twbBMCLo,1542
11
+ decbackup/certs/stormshield-products-root-ca.der,sha256=Qg3WRvECe_XZduXzTM2nMxYWuXosJb077kfBH65gEEY,1537
12
+ decbackup/certs/stormshield-sns-enrollment-products-ca.der,sha256=vlQeq_WjqNeTJBBkn_m6lf-_kw7VYz4XJKvbszjeud0,1712
13
+ decbackup-0.1.0.dist-info/licenses/LICENSE,sha256=2-Ay_fkWnKU-z__VTmVYCIhPDpb4yoKhLlHrYH3Ayy4,10755
14
+ decbackup-0.1.0.dist-info/METADATA,sha256=sKhLcyUHmGicUDuH3iq8ShqUm4nwp7NdCkkjgYMQv4o,6861
15
+ decbackup-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ decbackup-0.1.0.dist-info/entry_points.txt,sha256=7EWGsTZVW1rYExTjDVD_VTIQXjY98l5uVwZHczPfreQ,49
17
+ decbackup-0.1.0.dist-info/top_level.txt,sha256=95G07-VQPhUMVmGDEX_gN3n7cEpukBKkOi2NZfUMwJM,10
18
+ decbackup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ decbackup = decbackup.cli:main