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/__init__.py +10 -0
- decbackup/__main__.py +5 -0
- decbackup/backup.py +238 -0
- decbackup/certs/netasq-firewall-ca.der +0 -0
- decbackup/certs/stormshield-product-and-services-root-ca.der +0 -0
- decbackup/certs/stormshield-products-root-ca.der +0 -0
- decbackup/certs/stormshield-sns-enrollment-products-ca.der +0 -0
- decbackup/cli.py +110 -0
- decbackup/crypto.py +82 -0
- decbackup/der.py +77 -0
- decbackup/pkcs7.py +239 -0
- decbackup/trust.py +187 -0
- decbackup-0.1.0.dist-info/METADATA +181 -0
- decbackup-0.1.0.dist-info/RECORD +18 -0
- decbackup-0.1.0.dist-info/WHEEL +5 -0
- decbackup-0.1.0.dist-info/entry_points.txt +2 -0
- decbackup-0.1.0.dist-info/licenses/LICENSE +191 -0
- decbackup-0.1.0.dist-info/top_level.txt +1 -0
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,,
|