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/__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
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
|
|
Binary file
|
|
Binary file
|
|
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)
|