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 ADDED
@@ -0,0 +1,10 @@
1
+ """decbackup — Python clone of the Stormshield SNS backup decryption utility.
2
+
3
+ Reimplementation of ``/usr/Firewall/sbin/decbackup`` and the backup
4
+ container handling of ``libncrypto.so`` from Stormshield SNS v5.1.4,
5
+ derived by reverse engineering (see ``RECON.md``).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .backup import BackupError, decrypt_backup # noqa: F401
decbackup/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
decbackup/backup.py ADDED
@@ -0,0 +1,238 @@
1
+ """Stormshield SNS backup container (``.bkup``) reader.
2
+
3
+ Layout, recovered from ``libncrypto.so`` ``fwcrypto_upd_update`` (flags=2,
4
+ the backup path) and cross-checked against ``encbackup``-produced files::
5
+
6
+ offset size contents
7
+ ------------------------------------------------------
8
+ 0x000 0x400 ``[BACKUP]`` header, key=value lines,
9
+ NUL terminated, zero padded to 0x400
10
+ 0x400 4 be32 *X* — length of the PKCS#7 trailer
11
+ 0x404 X detached PKCS#7 SignedData (signature over
12
+ the full header block + plaintext, device
13
+ certificate, CA intermediate)
14
+ 0x404+X 4 be32 *Y* — plaintext/ciphertext length
15
+ 0x408+X Y AES-128-CBC ciphertext (PKCS#7 padding)
16
+
17
+ The original processes the file in 0x4000-byte chunks and only verifies
18
+ the signature when the trailer fits inside the first chunk; anything that
19
+ does not fit is rejected fail-closed (error 6), so the clone can simply
20
+ read the whole file.
21
+
22
+ Error codes are those printed by ``decbackup``'s
23
+ ``decrypt and/or verify backup failed (%d)`` message; see RECON.md for the
24
+ empirical derivation of each.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import os
31
+ import re
32
+ from pathlib import Path
33
+
34
+ from . import crypto, pkcs7, trust
35
+
36
+ HEADER_LEN = 0x400
37
+ CHUNK_LEN = 0x4000
38
+
39
+ # Error codes as reported by the original tool.
40
+ E_OK = 0
41
+ E_FILE = 1 # input/output could not be opened
42
+ E_PARSE = 6 # signature trailer / crypto parameters unparsable
43
+ E_VERIFY = 7 # signature, chain or content digest verification failed
44
+ E_HEADER = 8 # file shorter than the 0x400 header block
45
+ E_MAJVERSION = 12 # unknown backup majversion / "Invalid archive"
46
+ E_DECRYPT = 13 # decryption failed (in practice: wrong password)
47
+ E_ALGO = 32 # unsupported cipher algorithm
48
+
49
+ _INT_RE = re.compile(r"[0-9]{1,4}\Z")
50
+
51
+
52
+ class BackupError(Exception):
53
+ """Decryption/verification failure carrying the original error code."""
54
+
55
+ def __init__(self, code: int, detail: str = ""):
56
+ super().__init__(f"({code}) {detail}" if detail else f"({code})")
57
+ self.code = code
58
+
59
+
60
+ def parse_header(block: bytes) -> tuple[str, dict[str, str]]:
61
+ """Split the header block at its NUL and parse ``key=value`` lines.
62
+
63
+ Values may be double-quoted (``description="..."``). Returns the raw
64
+ text and an ordered mapping of the parameters.
65
+ """
66
+ text = block.split(b"\x00", 1)[0].decode("latin-1")
67
+ fields: dict[str, str] = {}
68
+ for line in text.splitlines():
69
+ key, _, value = line.partition("=")
70
+ if not _:
71
+ continue
72
+ # values stay verbatim (the original's str_get_int_ex tolerates
73
+ # leading but not trailing whitespace, so keep it observable)
74
+ if len(value) >= 2 and value[0] == '"' == value[-1]:
75
+ value = value[1:-1]
76
+ fields[key.strip()] = value
77
+ return text, fields
78
+
79
+
80
+ def _be32(buf: bytes, off: int) -> int:
81
+ return int.from_bytes(buf[off : off + 4], "big")
82
+
83
+
84
+ def _version_ok(value: str | None) -> bool:
85
+ """The original gate at 0x1318e (``Invalid archive``).
86
+
87
+ ``fwcrypto_maj_get_version`` runs the parsed value through
88
+ ``str_get_int_ex``, which skips *leading* whitespace but fails on
89
+ trailing characters (strict mode). Empirically a pure decimal value
90
+ of up to four digits is compared against 1 and anything else (dotted
91
+ versions, longer digit strings, trailing spaces) falls back to the
92
+ default of 1 and passes. See RECON.md: probes ``version=9`` → 12,
93
+ ``version="9 "`` → 7 (accepted), ``version=90000`` → 7 (accepted),
94
+ ``version=5.1.4`` → accepted.
95
+ """
96
+ m = _INT_RE.match((value or "").lstrip())
97
+ return m is None or int(m.group()) == 1
98
+
99
+
100
+ def _crypto_datas(fields: dict[str, str], maj_v: int, password: str) -> tuple[bytes, bytes, int]:
101
+ """``fwcrypto_get_backup_crypto_datas`` + key derivation."""
102
+ algo = int(fields["algo"]) if fields.get("algo", "").isdigit() else None
103
+ if maj_v == 2:
104
+ if algo is None:
105
+ raise BackupError(E_PARSE, "missing algo")
106
+ try:
107
+ salt = bytes.fromhex(fields["salt"])
108
+ iv = bytes.fromhex(fields["iv"])
109
+ except (KeyError, ValueError) as exc:
110
+ raise BackupError(E_PARSE, f"bad salt/iv: {exc}") from exc
111
+ iterations = fields.get("pbkdf_iterations", "1")
112
+ if not iterations.isdigit():
113
+ raise BackupError(E_PARSE, "bad pbkdf_iterations")
114
+ key = crypto.derive_key_v2(password.encode(), salt, int(iterations))
115
+ else: # v1: salted SHA1, no parameters in the header
116
+ if algo is None:
117
+ algo = crypto.ALGO_AES_128_CBC
118
+ pwd = password.encode()
119
+ key = crypto.derive_key_v1(pwd)
120
+ iv = crypto.derive_iv_v1(pwd)
121
+ return key, iv, algo
122
+
123
+
124
+ def decrypt_backup(
125
+ input_path: str | os.PathLike,
126
+ output_path: str | os.PathLike,
127
+ password: str = "",
128
+ *,
129
+ verify: bool = True,
130
+ ) -> dict[str, str]:
131
+ """Decrypt and (by default) verify a backup, mirroring ``decbackup``.
132
+
133
+ Returns the parsed header fields on success; raises :class:`BackupError`
134
+ with the original's numeric code otherwise. No output file survives a
135
+ failure — the original deletes it on every error path.
136
+
137
+ ``verify=False`` is a clone extension (no such option exists on the
138
+ device): it skips PKCS#7 trailer parsing and chain/signature
139
+ verification entirely — the trailer length field is still required to
140
+ locate the payload. The output is then UNAUTHENTICATED plaintext;
141
+ successful decryption alone says nothing about origin or integrity.
142
+ """
143
+ out = Path(output_path)
144
+ try:
145
+ # The original fopen()s the output early ("wb", truncating any
146
+ # existing file) and removes it again on every error path.
147
+ out.write_bytes(b"")
148
+ return _decrypt_backup(input_path, out, password, verify)
149
+ except BackupError:
150
+ with contextlib.suppress(OSError):
151
+ out.unlink(missing_ok=True)
152
+ raise
153
+ except OSError as exc:
154
+ raise BackupError(E_FILE, str(exc)) from exc
155
+
156
+
157
+ def _decrypt_backup(input_path, out: Path, password: str, verify: bool = True) -> dict[str, str]:
158
+ try:
159
+ data = Path(input_path).read_bytes()
160
+ except OSError as exc:
161
+ raise BackupError(E_FILE, str(exc)) from exc
162
+
163
+ if len(data) < HEADER_LEN:
164
+ raise BackupError(E_HEADER, "file shorter than backup header")
165
+
166
+ block = data[:HEADER_LEN]
167
+ _text, fields = parse_header(block)
168
+
169
+ # Empirically the version gate fires before the trailer-fit check:
170
+ # a length-shifted file with a bad version yields 12, not 6 (§6).
171
+ if not _version_ok(fields.get("version")):
172
+ raise BackupError(E_MAJVERSION, "Invalid archive")
173
+
174
+ # The signature trailer must fit in the first 0x4000-byte chunk the
175
+ # original reads; otherwise verification can never run and the file
176
+ # is rejected fail-closed (fwcrypto_upd_get_signature failed).
177
+ first_len = min(CHUNK_LEN, len(data))
178
+ sig_len = _be32(data, HEADER_LEN)
179
+ if sig_len + HEADER_LEN + 4 >= first_len:
180
+ raise BackupError(E_PARSE, "fwcrypto_upd_get_signature failed")
181
+
182
+ # fwcrypto_get_backup_majversion: defaults to 1 when the key is absent.
183
+ maj = fields.get("majversion")
184
+ if maj is None:
185
+ maj_v = 1
186
+ elif re.fullmatch(r"[0-9]+", maj):
187
+ maj_v = int(maj)
188
+ else:
189
+ maj_v = 1
190
+ if maj_v not in (1, 2):
191
+ raise BackupError(E_MAJVERSION, f"Unknown backup majversion: {maj_v}")
192
+
193
+ if verify:
194
+ try:
195
+ certs, signers = pkcs7.parse_signed_data(
196
+ data[HEADER_LEN + 4 : HEADER_LEN + 4 + sig_len]
197
+ )
198
+ except pkcs7.SignatureError as exc:
199
+ # fail closed: unparsable trailer means no verification possible
200
+ raise BackupError(E_PARSE, str(exc)) from exc
201
+
202
+ try:
203
+ key, iv, algo = _crypto_datas(fields, maj_v, password)
204
+ except BackupError:
205
+ raise
206
+ except (ValueError, KeyError) as exc:
207
+ raise BackupError(E_PARSE, f"fwcrypto_get_backup_crypto_datas: {exc}") from exc
208
+
209
+ if algo != crypto.ALGO_AES_128_CBC:
210
+ raise BackupError(E_ALGO, f"unsupported algo ({algo})")
211
+
212
+ off = HEADER_LEN + 4 + sig_len + 4
213
+ if off >= len(data):
214
+ raise BackupError(E_PARSE, "fwcrypto_upd_get_data_size failed")
215
+ # The device decrypts filesize - off bytes and ignores the be32 Y
216
+ # field (device probes: +16 trailing bytes -> 13; 16 bytes short -> 13).
217
+ ciphertext = data[off:]
218
+ if len(ciphertext) % 16:
219
+ raise BackupError(E_DECRYPT, "EVP_CipherFinal (maj) failed")
220
+
221
+ try:
222
+ plaintext = crypto.pkcs7_unpad(crypto.aes_cbc_decrypt(key, iv, ciphertext))
223
+ except ValueError as exc:
224
+ raise BackupError(E_DECRYPT, f"decrypt failed: {exc}") from exc
225
+
226
+ if verify:
227
+ content = block + plaintext
228
+ try:
229
+ signer_der = signers[0].verify(certs, content)
230
+ trust.verify_chain(signer_der, certs)
231
+ except (pkcs7.SignatureError, trust.ChainError) as exc:
232
+ raise BackupError(E_VERIFY, f"invalid backup: {exc}") from exc
233
+
234
+ try:
235
+ out.write_bytes(plaintext)
236
+ except OSError as exc:
237
+ raise BackupError(E_FILE, str(exc)) from exc
238
+ return fields
Binary file
decbackup/cli.py ADDED
@@ -0,0 +1,110 @@
1
+ """Command line interface, mirroring ``/usr/Firewall/sbin/decbackup``.
2
+
3
+ getopt string ``Pi:p:o:d``. Success is silent (exit 0); failure prints
4
+ ``decrypt and/or verify backup failed (%d)`` and exits 1. ``-d`` dumps
5
+ the backup header after a successful decryption.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getopt
11
+ import sys
12
+
13
+ from .backup import HEADER_LEN, BackupError, decrypt_backup
14
+
15
+ USAGE = (
16
+ "Usage : () -i <backup> -o <output archive>\n"
17
+ " [-p <password>] [-d : dump backup header]\n"
18
+ " [-P : prompt for password]\n"
19
+ " [--no-verify : skip signature verification"
20
+ " (clone extension)]\n"
21
+ )
22
+
23
+ PROMPT = "Enter backup password (<Enter> for none): "
24
+
25
+
26
+ def _read_password(prompt: bool) -> str:
27
+ """``t_getpass``: reads from /dev/tty; without a tty the password is
28
+ empty (empirically ``-P`` over a pipe yields error 13, i.e. an empty
29
+ password was used)."""
30
+ if not prompt and not sys.stdin.isatty():
31
+ return ""
32
+ try:
33
+ with open("/dev/tty", "r+") as tty:
34
+ tty.write(PROMPT)
35
+ tty.flush()
36
+ return tty.readline().rstrip("\r\n")
37
+ except OSError:
38
+ return ""
39
+
40
+
41
+ def main(argv: list[str] | None = None) -> int:
42
+ argv = sys.argv[1:] if argv is None else argv
43
+ input_path = output_path = None
44
+ password: str | None = None
45
+ prompt = False
46
+ dump = False
47
+ verify = True
48
+ p_count = 0
49
+
50
+ # the original getopt string is exactly "Pi:p:o:d"; --no-verify is a
51
+ # long-option extension, which cannot collide with that grammar
52
+ try:
53
+ opts, _ = getopt.getopt(argv, "Pi:p:o:d", ["no-verify"])
54
+ except getopt.GetoptError as exc:
55
+ # match the C library's message shape
56
+ if exc.opt:
57
+ print(f"decbackup: illegal option -- {exc.opt}", file=sys.stderr)
58
+ else:
59
+ print(f"decbackup: {exc.msg}", file=sys.stderr)
60
+ print(USAGE, file=sys.stderr, end="")
61
+ return 1
62
+
63
+ for opt, arg in opts:
64
+ if opt == "-P":
65
+ prompt = True
66
+ elif opt == "-p":
67
+ p_count += 1
68
+ if p_count > 1:
69
+ # device-verified: message only, no usage dump
70
+ print("-p can only be specified once", file=sys.stderr)
71
+ return 1
72
+ password = arg
73
+ elif opt == "-i":
74
+ input_path = arg
75
+ elif opt == "-o":
76
+ output_path = arg
77
+ elif opt == "-d":
78
+ dump = True
79
+ elif opt == "--no-verify":
80
+ verify = False
81
+
82
+ if input_path is None or output_path is None:
83
+ print(USAGE, file=sys.stderr, end="")
84
+ return 1
85
+
86
+ if password is None:
87
+ password = _read_password(prompt)
88
+
89
+ if not verify:
90
+ print(
91
+ "decbackup: WARNING: signature verification skipped -- "
92
+ "the output is decrypted but UNAUTHENTICATED",
93
+ file=sys.stderr,
94
+ )
95
+
96
+ try:
97
+ decrypt_backup(input_path, output_path, password, verify=verify)
98
+ except BackupError as exc:
99
+ print(f"decrypt and/or verify backup failed ({exc.code})", file=sys.stderr)
100
+ return 1
101
+
102
+ if dump:
103
+ try:
104
+ with open(input_path, "rb") as f:
105
+ block = f.read(HEADER_LEN)
106
+ except OSError:
107
+ block = b""
108
+ text = block.split(b"\x00", 1)[0].decode("latin-1")
109
+ print(f"Header :\n{text}\n", end="")
110
+ return 0
decbackup/crypto.py ADDED
@@ -0,0 +1,82 @@
1
+ """Key derivation and ciphers recovered from ``libncrypto.so``.
2
+
3
+ Two password-based schemes exist, selected by the backup header's
4
+ ``majversion`` field:
5
+
6
+ * **v1** (``fwcrypto_keyfrompasswd`` / ``fwcrypto_ivfrompasswd``):
7
+ ``SHA1(salt_constant || password)``, truncated/padded to the wanted
8
+ length. Only reachable by reading old backups; the 5.1.4 tools always
9
+ write v2.
10
+ * **v2** (``fwcrypto_derivekey``): PBKDF2-HMAC-SHA256 over the password
11
+ with the header's ``salt``/``pbkdf_iterations``; the IV is stored in the
12
+ header in hex.
13
+
14
+ Both feed AES-128-CBC (``algo=1``) with standard PKCS#7 padding.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+
21
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
22
+
23
+ #: AES-128-CBC
24
+ ALGO_AES_128_CBC = 1
25
+
26
+ #: salt constants embedded in libncrypto.so .rodata at 0x1b41f / 0x1b40f
27
+ V1_KEY_SALT = bytes.fromhex("2807a5ef00620c4b62f32e0985e30e4e")
28
+ V1_IV_SALT = bytes.fromhex("463538798d48c8c0323df3e75109d221")
29
+
30
+ #: key length actually requested by the backup path (``mov esi, 0x10``)
31
+ KEY_LEN = 16
32
+
33
+
34
+ def _sha1_salted(salt: bytes, password: bytes, size: int) -> bytes:
35
+ """``fwcrypto_keyfrompasswd``/``ivfrompasswd`` body.
36
+
37
+ ``SHA1(salt || password)``; when the requested size exceeds the 20-byte
38
+ digest the tail is filled with a byte progression (only relevant for
39
+ key sizes > 20, which the backup path never requests).
40
+ """
41
+ digest = hashlib.sha1(salt + password).digest()
42
+ if size <= len(digest):
43
+ return digest[:size]
44
+ buf = bytearray(digest) + bytes(size - len(digest))
45
+ for i in range(len(digest), size):
46
+ buf[i] = (size - 1 + (size - i) * 0xEB) & 0xFF
47
+ return bytes(buf)
48
+
49
+
50
+ def derive_key_v1(password: bytes, size: int = KEY_LEN) -> bytes:
51
+ return _sha1_salted(V1_KEY_SALT, password, size)
52
+
53
+
54
+ def derive_iv_v1(password: bytes, size: int = KEY_LEN) -> bytes:
55
+ return _sha1_salted(V1_IV_SALT, password, size)
56
+
57
+
58
+ def derive_key_v2(password: bytes, salt: bytes, iterations: int, size: int = KEY_LEN) -> bytes:
59
+ return hashlib.pbkdf2_hmac("sha256", password, salt, iterations, size)
60
+
61
+
62
+ def aes_cbc_decrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
63
+ if len(data) % 16:
64
+ raise ValueError("ciphertext is not a multiple of the AES block size")
65
+ dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
66
+ return dec.update(data) + dec.finalize()
67
+
68
+
69
+ def pkcs7_unpad(data: bytes) -> bytes:
70
+ """Strip PKCS#7 padding; raises ``ValueError`` on malformed padding.
71
+
72
+ Mirrors OpenSSL's ``EVP_CipherFinal`` behaviour: a bad padding check is
73
+ what makes a wrong password surface as error code 13.
74
+ """
75
+ if not data:
76
+ raise ValueError("empty plaintext")
77
+ pad = data[-1]
78
+ if not 1 <= pad <= 16 or len(data) < pad:
79
+ raise ValueError("bad PKCS#7 padding")
80
+ if data[-pad:] != bytes([pad]) * pad:
81
+ raise ValueError("bad PKCS#7 padding")
82
+ return data[:-pad]
decbackup/der.py ADDED
@@ -0,0 +1,77 @@
1
+ """Minimal DER (ASN.1 TLV) reader.
2
+
3
+ Just enough to walk PKCS#7 SignedData structures. Every function works on
4
+ plain ``bytes`` and offsets; a TLV is described by the tuple
5
+ ``(tag, value_start, length, next)`` where ``next == value_start + length``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class DERError(ValueError):
12
+ """Raised when a buffer is not well-formed DER."""
13
+
14
+
15
+ def read_tlv(buf: bytes, i: int) -> tuple[int, int, int, int]:
16
+ """Read one TLV at offset *i*; return ``(tag, value_start, length, next)``."""
17
+ try:
18
+ tag = buf[i]
19
+ j = i + 1
20
+ if tag == 0x00: # padding
21
+ raise DERError("null filler")
22
+ if tag == 0xFF:
23
+ raise DERError("bad tag")
24
+ if tag & 0x1F == 0x1F: # high tag number form
25
+ while buf[j] & 0x80:
26
+ j += 1
27
+ j += 1
28
+ first = buf[j]
29
+ j += 1
30
+ if first < 0x80:
31
+ length = first
32
+ elif first == 0x80:
33
+ raise DERError("indefinite length not supported")
34
+ else:
35
+ nbytes = first & 0x7F
36
+ if nbytes > 8:
37
+ raise DERError("overlong length")
38
+ length = int.from_bytes(buf[j : j + nbytes], "big")
39
+ j += nbytes
40
+ if j + length > len(buf):
41
+ raise DERError("truncated value")
42
+ return tag, j, length, j + length
43
+ except IndexError as exc:
44
+ raise DERError("truncated TLV") from exc
45
+
46
+
47
+ def children(buf: bytes, start: int, end: int):
48
+ """Yield every TLV between *start* and *end* as
49
+ ``(tag, value_start, length, next)`` tuples."""
50
+ i = start
51
+ while i < end:
52
+ tlv = read_tlv(buf, i)
53
+ yield tlv
54
+ i = tlv[3]
55
+
56
+
57
+ def encode_len(length: int) -> bytes:
58
+ """DER length encoding of an integer."""
59
+ if length < 0x80:
60
+ return bytes([length])
61
+ body = length.to_bytes((length.bit_length() + 7) // 8, "big")
62
+ return bytes([0x80 | len(body)]) + body
63
+
64
+
65
+ def oid_str(buf: bytes, start: int, end: int) -> str:
66
+ """Decode an OBJECT IDENTIFIER value to dotted form."""
67
+ if start >= end:
68
+ return ""
69
+ first = buf[start]
70
+ parts = [str(first // 40), str(first % 40)]
71
+ value = 0
72
+ for byte in buf[start + 1 : end]:
73
+ value = (value << 7) | (byte & 0x7F)
74
+ if not byte & 0x80:
75
+ parts.append(str(value))
76
+ value = 0
77
+ return ".".join(parts)