spice-crypt 1.2.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.
@@ -0,0 +1,23 @@
1
+ # SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. <joe.sylve@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ """
6
+ SpiceCrypt - A library for decrypting LTspice® encrypted files
7
+ """
8
+
9
+ __version__ = "1.2.0"
10
+
11
+ from spice_crypt.binary_file import BinaryFileParser
12
+ from spice_crypt.crypto_state import CryptoState
13
+ from spice_crypt.decrypt import LTspiceFileParser, decrypt, decrypt_stream
14
+ from spice_crypt.des import LTspiceDES
15
+
16
+ __all__ = [
17
+ "BinaryFileParser",
18
+ "CryptoState",
19
+ "LTspiceDES",
20
+ "LTspiceFileParser",
21
+ "decrypt",
22
+ "decrypt_stream",
23
+ ]
@@ -0,0 +1,282 @@
1
+ # SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. <joe.sylve@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ """
6
+ Decryption support for LTspice® Binary File format.
7
+
8
+ This module handles encrypted model files that use the Binary File
9
+ format -- a binary encoding with a two-layer XOR stream cipher,
10
+ distinct from the text-based hex/DES format handled by the other
11
+ modules.
12
+
13
+ The file structure is:
14
+
15
+ Offset Size Field
16
+ ------ ---- -----
17
+ 0 20 Signature: ``\\r\\n<Binary File>\\r\\n\\r\\n\\x1a``
18
+ 20 4 key1 (uint32 LE)
19
+ 24 4 key2 (uint32 LE)
20
+ 28 ... Encrypted body (byte stream)
21
+
22
+ Decryption of each body byte at index *N* (0-based from offset 28):
23
+
24
+ decrypted[N] = (encrypted[N] ^ key2_bytes[N & 3])
25
+ ^ sbox[(base + step * N) % 2593]
26
+
27
+ where *base* and *step* are derived from the header key fields via
28
+ a lookup table, and *sbox* is a fixed 2593-byte substitution table.
29
+ """
30
+
31
+ import binascii
32
+ import struct
33
+ from collections.abc import Generator
34
+
35
+ from spice_crypt.des import _MASK32
36
+
37
+ # 20-byte file signature
38
+ SIGNATURE = b"\r\n<Binary File>\r\n\r\n\x1a"
39
+
40
+ # fmt: off
41
+ # Step values indexed by key2 % 26.
42
+ # Entry 0 is 1; entries 1-25 are the first 25 primes.
43
+ _STEP_TABLE = (
44
+ 1, 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31,
45
+ 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97,
46
+ )
47
+
48
+ _SBOX_MODULUS = 2593
49
+ # 2593-byte substitution table, indexed by (base + step * N) % 2593.
50
+ _SBOX = bytes.fromhex(
51
+ "d55f931826290d5b2961bf26ee61590a58e7b22742b9265466c72f56013d5800"
52
+ "52cff40c0b6f7565e0f4241c1f81fc2a0f6410603af89f5d139320161730ff10"
53
+ "ef101921c1976b254d7b525de8fafb0511071c475fad6c1bd2830d11bde37323"
54
+ "e54e2e66ed2d631152268548e8fe7035f924aa1ca1006572d7869c0acf843d35"
55
+ "c729724d00e85b31bde6963f1f11257542a1820523aec615214e7d7594707712"
56
+ "2e1d3c7b0143a211b3f1733d3e814c5b3b3b426fc784945355b14b6c2a4c5b10"
57
+ "881c0079a22c9e491247571699231c4002da0a65e4ca642756079063e728394b"
58
+ "d1f8c738a82d152ccf27aa00cb1d72554a2e7a1ea7ae460b9aa2af0a1158ec6b"
59
+ "a796a23c5789464a31691161ea3725427a370d6052b78e567ea89c54a954495b"
60
+ "53fa3068329a1012e7d595368e357357f91ea5653c87e122b881ce67813ba55e"
61
+ "dfb37f6ccac8257e1a5fc11ee18d8a51ae938a2570665102c8b6c31c808c525e"
62
+ "1894662e98de6d1d4baac43362c2e04c3f8db428e54c743e741acd38e6235765"
63
+ "3cd6ba08a583de19d05b7c27b60dc868f73a6d704f04197c5f6211444a359e58"
64
+ "819e290e4638a77ad86a11307abdce7383bf881d90ecdf17fbf87352627308"
65
+ "0a5ab50516155835714301935b0849903b85be86730bb8567888d5e2199d52ed"
66
+ "21a396c415d37fa74d0015ce6ee223793eb8cc1b0c742f9b27c947d023f4a2d6"
67
+ "1419b3794199a34c4babb09e7d10eee631e8a765470a13b0415a23850a69468f"
68
+ "55514b573c328e963ae3035e49d40ae059c27a7652defcd11b367ee8631c307c"
69
+ "68f354070d797f7b3f24790c2478138e008437d237ad4eef3d16667b2228ce96"
70
+ "4d80ce960b167b49110af20f0c399bb2178aaae438d339e02f2d3e892ca35d5e"
71
+ "7a6ddd2c7bd8ee272ab34b452c55859242e301d86b0d6fca36bfcb2118344d2f"
72
+ "283ffd6071a2cf7f6108580f020178d74381cc517d3ed6f7651da8532c742159"
73
+ "0ab755732541216050ed34e70a3b8d455dee6f4f0e039b622d635bdc2a6f3ee6"
74
+ "191916ac3e6e4dec36a8d99831a3c090774187cc66d517225e461eef71ae64f9"
75
+ "61ae064a08f969341e04ea8b249108227406d9fe54c3b5ad3cc555511c45d65f"
76
+ "4665852d1ecdad601e464e370ae6517f1b0b84580463f68a365b73d825c2d9cb"
77
+ "29a417eb0648a8bf30fd66110793873a154b43225e61c2ed3102c6202f6459ce"
78
+ "1ccf0fda68aa9fb960071a5f141097a64f7fb7db3e4d384e06bffb9f312dbe25"
79
+ "4746a28224c3e52b56bec6473b4c7b8179869bd912831c99579151e13feb2007"
80
+ "3150caf975d79f184ad272864c5b4e527a3a96a3002de65e721d281e24dead8e"
81
+ "07758e1e231b8f2f2b7135c91cc0d140017c511d5d73fbe94b242b0f1e4b61f7"
82
+ "451d9ba32c2b456e325bf89d159d527f6b787dbc381af43d47ca10a532be1f3f"
83
+ "5dddd9691d89d7ec6d0a9bc056637543300cf485459beca1164f964a615dbe7f"
84
+ "3b728cba602109d12db80cd235ac225e614eef2f20d634f0598ad0ec68c37d4e"
85
+ "43f1c31f05fc05b605834f8f446d153d626f01a051a77a9e62b87634288d9c43"
86
+ "7ed2bf0c15136fd23d2aefc2694a3dc94d2e631005f4ff671c085d082b0b3d7a"
87
+ "227dd7540a12f8c8016fb2bd528acbda4fade46a18be480834e7895a0b1f7125"
88
+ "79df51d9619f962c41cb93835a2d41090275cb1c1b55647043f0be5745668f3c"
89
+ "20516a2649730ee709d3a47902c16bc61a1a89856c8b1bae2a4e080a19ec4892"
90
+ "019f8a806878f7cc0236865b4fcded906d6cf7341f3ee3637ad82a0b10eace89"
91
+ "2950db2c7c47ddc862749a6479fdbf97140526d1165b24bf041c31bd0de477aa"
92
+ "78fabaeb45e7c4406811b9b37a708608613c29b12b01780b40d61545018e93d7"
93
+ "747486f249aababe034fff9d0f8e0f783635d66c2e9d07a8287a580a38d460ed"
94
+ "1615ff742bb0de6507a14e7e0481f6a94aeec1c9017a7989146bc533743e9df6"
95
+ "7dc1565277df5f986d3b5d8e12c77c230e3a845772578e4b20abf4cd06353f43"
96
+ "383e538c08bdad8101a5c54b197b7c3d34be258d417bdb901a0910152933ac7f"
97
+ "0b25964f1e580fb338c1bbf7415b6cbc4cf5165b613c14027a2fcda9630a16d0"
98
+ "0cecf26701d11b28688b0c7a57dbb431034b95b17cf7d1ad4b195228010cec03"
99
+ "74d631463955afb613d368270211b69d2bac3d02347f5df50846f5e063eb908e"
100
+ "3c3c0b770aebba2c7d660dcc70fa30044c6696bd176f1de1192ddd83578c2c0d"
101
+ "36c72c9452ef987b19e798c902bc43ef332bad7d1316667366c659bf4017a0e5"
102
+ "14e7819b4e51663918f254171832174d4b4838e7630ca73f193f03513f1f6a2d"
103
+ "1d6156f62c126c78413020cb480d94f86091c96d4a7615ac2cf824871dcdd4e4"
104
+ "5461d0d8295e32530ec805e920c7669641cd4f3428f5e26c785393a377947cc8"
105
+ "7ae47be8113a2c6d7a50c0b72e0f2966255192e060161a776f27c94b3a38147c"
106
+ "2f6880b007191e63526b2bc97ab0b8976b25c5a26baa2e1a3acf22c508861b99"
107
+ "18bc9a927bff42905194af91794e64004675583c7e8cd418171b39e51ad62815"
108
+ "28eb066c25e33ece3b9e8fab69b856a04dd9213b34f1224f614dd36848bd9d23"
109
+ "462c4fbc5b9d932077cdc6896b7de19c3cb4ad9766f48fd525b5f5186c1c2e48"
110
+ "6e0dae38782021e266cce6df593373db63ca4ffc209c09a562b98e747c87ea8e"
111
+ "1c9b4c35344d3e0676d54e8f6211a57132da121f0df087747de7cd865ac5198b"
112
+ "32d4c64239855d32447d702b00ade87d6d77808125ca4394486a86a133a3cf3d"
113
+ "0168d7b43f374d2b1f20b1da3d1c854c262bdd0045d5a6f32938b39414398b39"
114
+ "3df6c7d510049a746e6cfe1421c017d231a0a31951258d891d4702614e3cf04e"
115
+ "0573cb8f131c51f0304d95c0374ddeae200dd9642e3463471212f83953e19fa7"
116
+ "67bac079568f6865538e8825553141fb7b5aacf91bf80ec708d410397dc283ae"
117
+ "5b305cf227f4c1133bde08fb015b39f36cc968076516bc8f1694c42c2abf30dd"
118
+ "751a56040500c3414b8048af27bbf91d562650cb68c74a1076f7e96c5b991b5b"
119
+ "7ce49b0027447f2d13e6f9091df174655578e27425f8f14370d2140d3d32a3ee"
120
+ "7b875aa943609d321263e4e977e106a35f58acf91a37f52275a38a513b8808ec"
121
+ "422bb7363081934c3de441df2ff51f3e15974fdc5378060c5ab4501b0bb2a5e0"
122
+ "5879c94d253499ca326d9ffe2e9f19190efce3da2864896b0a3835740ae07fdb"
123
+ "4fa808991d1e2f7e27d1f4402520eb0d431621c217a3094e62538efc3e9d7b6b"
124
+ "5b03a78074b672e6367f820e3b5b537a0fee67092c220d6076e45b6652191f40"
125
+ "5ca4a0ac33c89d45020e3f7e713bf0880740a4515cc38f997ced956960b96d9f"
126
+ "01f728642f5a35680f5887b80ff30c3f58bebed31990bc2c1ad38c1a2866c76c"
127
+ "37aeebaa41a4815b4d87b27a7ac40c6d59478ba92fda4077396288d8344a322a"
128
+ "2490b35d70e10ae76fa685a4337e1b671c031847668ae10a06983aa778a7b8f3"
129
+ "19527f5008a679256ae3a87c219223a2646909bf66d03ee6014c91416661322316"
130
+ "2b744e11a418fa75543f626ee932222b35d5261028cc7c1650fa8e62e3c0d151"
131
+ "cc4dd863d7ac095da8cd3e2b14d98113b1ed80160a5617605e0bac3741a1de06"
132
+ "eb"
133
+ )
134
+
135
+ # Key validation table: 100 entries of (check_value, base_value) as
136
+ # packed little-endian uint32 pairs (800 bytes total).
137
+ _KEY_TABLE_RAW = bytes.fromhex(
138
+ "c0bc4523640700008e725b711e060000963139500c060000fe7012065c060000"
139
+ "73154e5e4100000049199c7354080000c9acf4021303000063bf893a84010000"
140
+ "5ec00c30f904000045b8d307300900007c55821c1e0300008567a56fbe050000"
141
+ "26df2d7f640700002e79d8273600000077f6040da4000000897b2a22dd030000"
142
+ "07531c2eb70200008faa374c27070000f8df310dc9030000165f0b705a070000"
143
+ "f6951b3f770100002a9c0f30d0080000d40592680b090000dc2e673351060000"
144
+ "26f04935ee040000e0803a2bb603000034ede6260e000000c6688d1939020000"
145
+ "9b3d06216a030000deb6ee5c140700000dbc3c39b70800004ae7555ee3020000"
146
+ "7f209f1284020000b293ae65da00000068add77c27050000e2f5500b04030000"
147
+ "286b61397a0500001d86037ee402000099edf925c10300002f37923a210a0000"
148
+ "1b9ca56c4f0600006123102d9806000075a0ac00e3040000a955a1398b050000"
149
+ "1b6e03080d0200002412be4f28030000ef3ea915280500003d399928da020000"
150
+ "488ba158d2070000e55f1948a7060000b7bf0164b40200000e7c6c113f000000"
151
+ "d3e7ca0e18030000dd9b563f4b04000025b7da40150400002cb3081064050000"
152
+ "1c8bb55fec08000090dc0c417e080000b562b60366020000a3091502d5010000"
153
+ "c13e3e1141080000f9faf94c030600003515e77fc50200001fdd2f4fbe010000"
154
+ "2501db035e0300002ed9012e39040000cc92b36add080000bdeb3f05fd050000"
155
+ "6857de4e8c0800000d50432e9a090000a65a7f3e5d050000ce61393d9d040000"
156
+ "c7d9647b830900005511977ed20700009770f478cd0700004e0dd50a18080000"
157
+ "bf367f52510700000a2d1a311f0a00007e3c624da003000072ecee2a55010000"
158
+ "2e479317db01000080fe1939cd040000de1a5f18d30000009b54c57b45040000"
159
+ "d771f002ba010000d480f67698030000e1a754685200000041b2a45f0a020000"
160
+ "00217632f901000026bed462660200008edee75e0b020000f0409d356c070000"
161
+ "bbd37845410500004161cd034e09000024780167cd010000dd4d18649c070000"
162
+ "5513ad070e0600004d99db001d0600009b368c5bcb04000079a049070c070000"
163
+ )
164
+ # fmt: on
165
+
166
+ _KEY_TABLE_COUNT = 100
167
+
168
+ # Pre-built dict mapping check_value -> base_value for O(1) lookup.
169
+ _KEY_TABLE = {
170
+ struct.unpack_from("<I", _KEY_TABLE_RAW, i * 8)[0]: struct.unpack_from(
171
+ "<I", _KEY_TABLE_RAW, i * 8 + 4
172
+ )[0]
173
+ for i in range(_KEY_TABLE_COUNT)
174
+ }
175
+
176
+
177
+ class BinaryFileParser:
178
+ """Parser for LTspice Binary File format encrypted files.
179
+
180
+ This format uses a two-layer XOR stream cipher:
181
+
182
+ 1. Each byte is XOR'd with a cyclically repeating 4-byte key derived
183
+ from the ``key2`` header field.
184
+ 2. Each byte is further XOR'd with a value from a 2593-byte
185
+ substitution table, indexed by a linear congruential sequence
186
+ ``(base + step * N) mod 2593`` whose *step* is always coprime
187
+ to 2593, guaranteeing a full-period cycle.
188
+ """
189
+
190
+ def __init__(self, file_obj):
191
+ """
192
+ Initialize the parser with a binary-mode file object.
193
+
194
+ Args:
195
+ file_obj: File-like object opened in binary mode.
196
+ """
197
+ self.file_obj = file_obj
198
+
199
+ @staticmethod
200
+ def check_signature(data):
201
+ """Return ``True`` if *data* starts with the Binary File signature."""
202
+ return len(data) >= 20 and data[:20] == SIGNATURE
203
+
204
+ def decrypt_stream(self) -> Generator[bytes, None, tuple[int, int]]:
205
+ """
206
+ Stream-decrypt the file, yielding decrypted chunks.
207
+
208
+ Returns:
209
+ Generator that yields decrypted byte chunks.
210
+ The return value (via ``StopIteration``) is a
211
+ ``(crc32, rotate_hash)`` verification tuple.
212
+ """
213
+ # -- Header parsing ------------------------------------------------
214
+ header = self.file_obj.read(28)
215
+ if len(header) < 28:
216
+ raise ValueError("File too short for Binary File header")
217
+ if header[:20] != SIGNATURE:
218
+ raise ValueError("Invalid Binary File signature")
219
+
220
+ key1, key2 = struct.unpack_from("<II", header, 20)
221
+ check = key1 ^ key2
222
+
223
+ base = _KEY_TABLE.get(check)
224
+ if base is None:
225
+ raise ValueError(
226
+ f"Unrecognized encryption key pair "
227
+ f"(key1=0x{key1:08X}, key2=0x{key2:08X}, check=0x{check:08X})"
228
+ )
229
+
230
+ step = _STEP_TABLE[key2 % 26]
231
+ key2_bytes = header[24:28] # raw LE bytes of key2
232
+
233
+ # -- Body decryption -----------------------------------------------
234
+ body = self.file_obj.read()
235
+ if not body:
236
+ return (0, 0)
237
+
238
+ n = len(body)
239
+
240
+ # Layer 1: cyclic XOR with the 4-byte key2 pattern.
241
+ # Build a repeating key2 mask covering the whole body.
242
+ key2_mask = key2_bytes * (n // 4 + 1)
243
+
244
+ # Layer 2: sbox keystream.
245
+ # The index sequence (base + step*i) % modulus has period exactly
246
+ # `modulus` (2593) because `step` is always coprime to 2593.
247
+ # Compute one full cycle, then tile to cover the body length.
248
+ #
249
+ # Note: the original C code computes (base + step*N) in uint32
250
+ # arithmetic, which would wrap for N > ~42 MB (step=97).
251
+ # Pre-computing one cycle and tiling avoids this — step*i stays
252
+ # well within 32 bits for i in [0, 2592].
253
+ sbox = _SBOX
254
+ modulus = _SBOX_MODULUS
255
+ one_cycle = bytes(sbox[(base + step * i) % modulus] for i in range(modulus))
256
+ full, remainder = divmod(n, modulus)
257
+ sbox_stream = one_cycle * full + one_cycle[:remainder]
258
+
259
+ # Apply both XOR layers in bulk using integer arithmetic.
260
+ body_int = int.from_bytes(body, "big")
261
+ key2_int = int.from_bytes(key2_mask[:n], "big")
262
+ sbox_int = int.from_bytes(sbox_stream, "big")
263
+ decrypted = (body_int ^ key2_int ^ sbox_int).to_bytes(n, "big")
264
+
265
+ # -- Verification checksums ----------------------------------------
266
+ crc = binascii.crc32(decrypted)
267
+
268
+ # Compute a rotate-left hash over every byte. Inlining _rol32
269
+ # and grouping by rotation amount (which cycles every 32 bytes)
270
+ # avoids per-byte function-call overhead.
271
+ rotate_hash = 0
272
+ mask = _MASK32
273
+ for i, byte_val in enumerate(decrypted):
274
+ shift = (i + 1) & 31
275
+ if shift:
276
+ rotated = ((byte_val << shift) | (byte_val >> (32 - shift))) & mask
277
+ rotate_hash = (rotate_hash + rotated) & mask
278
+ else:
279
+ rotate_hash = (rotate_hash + byte_val) & mask
280
+
281
+ yield decrypted
282
+ return (crc, rotate_hash)
spice_crypt/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. <joe.sylve@gmail.com>
4
+ #
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+
7
+ """
8
+ Command-line interface for SpiceCrypt
9
+ """
10
+
11
+ import argparse
12
+ import sys
13
+ import warnings
14
+ from pathlib import Path
15
+
16
+ from spice_crypt import __version__
17
+ from spice_crypt.decrypt import decrypt_stream
18
+
19
+
20
+ class _DeprecatedShortVersionAction(argparse.Action):
21
+ """Handles deprecated ``-v`` flag for ``--version``."""
22
+
23
+ def __init__(self, option_strings, version, **kwargs):
24
+ kwargs.setdefault("nargs", 0)
25
+ kwargs.setdefault("default", argparse.SUPPRESS)
26
+ self.version = version
27
+ super().__init__(option_strings=option_strings, **kwargs)
28
+
29
+ def __call__(self, parser, namespace, values, option_string=None):
30
+ if option_string == "-v":
31
+ warnings.warn(
32
+ "-v is deprecated for --version, use --version instead",
33
+ DeprecationWarning,
34
+ stacklevel=2,
35
+ )
36
+ print(self.version)
37
+ parser.exit()
38
+
39
+
40
+ def main():
41
+ """Main entry point for the CLI."""
42
+ parser = argparse.ArgumentParser(
43
+ description="SpiceCrypt - A tool for decrypting LTspice® encrypted files"
44
+ )
45
+ parser.add_argument(
46
+ "input_file",
47
+ help="Path to the encrypted file to decrypt (LTspice encrypted format or raw hex)",
48
+ )
49
+ parser.add_argument("-o", "--output", help="Output file path (default: print to stdout)")
50
+ parser.add_argument(
51
+ "-f", "--force", action="store_true", help="Overwrite output file if it exists"
52
+ )
53
+ parser.add_argument(
54
+ "-r",
55
+ "--raw",
56
+ action="store_true",
57
+ help="Treat input as raw hex data instead of LTspice® format",
58
+ )
59
+ parser.add_argument(
60
+ "-v",
61
+ "--version",
62
+ action=_DeprecatedShortVersionAction,
63
+ version=f"SpiceCrypt {__version__}",
64
+ help="Show program version and exit",
65
+ )
66
+
67
+ verbosity = parser.add_mutually_exclusive_group()
68
+ verbosity.add_argument("--verbose", action="store_true", help="Display additional information")
69
+ verbosity.add_argument("--quiet", action="store_true", help="Suppress all error messages")
70
+
71
+ args = parser.parse_args()
72
+
73
+ # Check if output file exists and handle accordingly
74
+ if args.output and Path(args.output).exists() and not args.force:
75
+ if not args.quiet:
76
+ sys.stderr.write(
77
+ f"Error: Output file '{args.output}' already exists. Use --force to overwrite.\n"
78
+ )
79
+ return 1
80
+
81
+ try:
82
+ # Process with streaming API
83
+ if args.verbose:
84
+ if args.raw:
85
+ print(f"Processing as raw hex data: {args.input_file}", file=sys.stderr)
86
+ else:
87
+ print(f"Processing file: {args.input_file}", file=sys.stderr)
88
+
89
+ # Stream processing - much more memory efficient
90
+ output_dest = (
91
+ args.output
92
+ if args.output
93
+ else (sys.stdout.buffer if hasattr(sys.stdout, "buffer") else sys.stdout)
94
+ )
95
+ is_ltspice = False if args.raw else None
96
+ _, verification = decrypt_stream(args.input_file, output_dest, is_ltspice_file=is_ltspice)
97
+ if args.verbose:
98
+ if args.output:
99
+ print(f"Decrypted content written to '{args.output}'", file=sys.stderr)
100
+ print(f"Verification values: {verification}", file=sys.stderr)
101
+
102
+ except FileNotFoundError:
103
+ if not args.quiet:
104
+ sys.stderr.write(f"Error: File not found: {args.input_file}\n")
105
+ return 1
106
+ except ValueError as e:
107
+ if not args.quiet:
108
+ sys.stderr.write(f"Error: {e}\n")
109
+ return 1
110
+ except Exception as e:
111
+ if not args.quiet:
112
+ sys.stderr.write(f"Error during decryption: {e}\n")
113
+ return 1
114
+
115
+ return 0
116
+
117
+
118
+ def main_deprecated():
119
+ """Deprecated entry point. Use ``spice-crypt`` instead."""
120
+ warnings.warn(
121
+ "spice-decrypt is deprecated, use spice-crypt instead",
122
+ DeprecationWarning,
123
+ stacklevel=2,
124
+ )
125
+ return main()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())
@@ -0,0 +1,147 @@
1
+ # SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. <joe.sylve@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ """
6
+ Cryptographic state management for LTspice text-based DES decryption.
7
+
8
+ This module implements :class:`CryptoState`, which derives the DES key and
9
+ stream cipher parameters from a 1024-byte crypto table and provides
10
+ per-block decryption combining the pre-DES XOR layer with the DES variant.
11
+ """
12
+
13
+ from spice_crypt.des import _MASK32, _MASK64, LTspiceDES
14
+
15
+
16
+ class CryptoState:
17
+ """Manages key derivation and per-block decryption for the text-based DES format.
18
+
19
+ The 1024-byte crypto table (the first 128 blocks of the hex payload) is
20
+ processed to derive three pieces of state:
21
+
22
+ - Two stream cipher parameters (``odd_byte_checksum`` and
23
+ ``even_byte_checksum``) used by the pre-DES XOR layer.
24
+ - A 64-bit DES key used by the modified DES block cipher.
25
+
26
+ See SPECIFICATION.md Sections 1.2 and 1.3 for the full derivation.
27
+ """
28
+
29
+ def __init__(self, table: bytes):
30
+ """
31
+ Initialize the crypto state from a 1024-byte crypto table.
32
+
33
+ Args:
34
+ table: The 1024-byte crypto table extracted from the file payload.
35
+
36
+ Raises:
37
+ ValueError: If *table* is not exactly 1024 bytes.
38
+ """
39
+ if len(table) != 1024:
40
+ raise ValueError("crypto table must be exactly 1024 bytes")
41
+
42
+ self.crypto_table = table
43
+ self.DES = LTspiceDES()
44
+ self.reset()
45
+
46
+ def reset(self):
47
+ """
48
+ Derives the cryptographic state from the 1024-byte crypto table.
49
+ """
50
+ table = self.crypto_table
51
+
52
+ # Pass 1: Compute checksums over even-indexed and odd-indexed bytes.
53
+ # Only the low 8 bits of each sum are kept.
54
+ even_byte_sum = 0
55
+ odd_byte_sum = 0
56
+ for i in range(0, 1024, 2):
57
+ even_byte_sum += table[i]
58
+ odd_byte_sum += table[i + 1]
59
+ even_byte_sum &= 0xFF
60
+ odd_byte_sum &= 0xFF
61
+
62
+ # Pass 2: Sum bytes by their position in 4-byte chunks.
63
+ # The table is treated as 256 groups of 4 bytes. Each of the 4
64
+ # positional accumulators receives bytes at the same offset within
65
+ # every group, and the totals are then summed together.
66
+ # DO NOT REMOVE — documents behaviour present in the original binary
67
+ # even though the results are unused. See SPECIFICATION.md.
68
+ # byte_group_sums = [0] * 4
69
+ # for i in range(0, 1024, 4):
70
+ # for j in range(4):
71
+ # byte_group_sums[j] = (byte_group_sums[j] + table[i + j]) & _MASK32
72
+ # byte_sum_result = sum(byte_group_sums) & _MASK32
73
+
74
+ # Pass 3: Sum 16-bit little-endian words by their position in
75
+ # 4-word (8-byte) chunks. Same idea as Pass 2 but operating on
76
+ # 16-bit units instead of bytes.
77
+ # DO NOT REMOVE — see Pass 2 comment above.
78
+ # word_group_sums = [0] * 4
79
+ # for i in range(0, 1024, 8):
80
+ # for j in range(4):
81
+ # word_group_sums[j] = (
82
+ # word_group_sums[j]
83
+ # + int.from_bytes(table[i + j * 2 : i + j * 2 + 2], "little")
84
+ # ) & _MASK32
85
+ # word_sum_result = sum(word_group_sums) & _MASK32
86
+
87
+ # Pass 4: Sum 64-bit little-endian qwords in 2-qword (16-byte)
88
+ # chunks. The table is treated as 64 groups of two qwords.
89
+ # Even-offset (0) and odd-offset (8) qwords are accumulated
90
+ # separately, then added together.
91
+ qword_sum_even = 0 # accumulator for qwords at offset 0 in each group
92
+ qword_sum_odd = 0 # accumulator for qwords at offset 8 in each group
93
+ for i in range(0, 1024, 16):
94
+ qword_sum_even += int.from_bytes(table[i : i + 8], "little")
95
+ qword_sum_odd += int.from_bytes(table[i + 8 : i + 16], "little")
96
+ qword_sum_even &= _MASK64
97
+ qword_sum_odd &= _MASK64
98
+
99
+ # Combine the two qword accumulators and extract the 16-bit words
100
+ # that will feed into the DES key: bits [15:0] and bits [47:32].
101
+ combined_qword = (qword_sum_even + qword_sum_odd) & _MASK64
102
+ qword_low_word = combined_qword & 0xFFFF
103
+ qword_high_word = (combined_qword >> 32) & 0xFFFF
104
+
105
+ # Final XOR transformation to produce the crypto state.
106
+ # The checksums are XOR'd with fixed constants and the two 16-bit
107
+ # qword-derived words are XOR'd with 32-bit constants to form the
108
+ # 64-bit DES key.
109
+ self.odd_byte_checksum = odd_byte_sum ^ 0x54
110
+ self.even_byte_checksum = even_byte_sum ^ 0xE7
111
+ key_low = (qword_low_word ^ 0x66E22120) & _MASK32
112
+ key_high = (qword_high_word ^ 0x20E905C8) & _MASK32
113
+ self.key_value = (key_high << 32) | key_low
114
+
115
+ def decrypt_block(self, data: bytes):
116
+ """
117
+ Decrypts a block of data using the cryptographic state and table.
118
+
119
+ Args:
120
+ data: 8-byte block to decrypt
121
+
122
+ Returns:
123
+ 32-bit decrypted result
124
+ """
125
+ if len(data) != 8:
126
+ raise ValueError("Data block must be 8 bytes")
127
+
128
+ crypto_table = self.crypto_table
129
+ data_copy = bytearray(data)
130
+
131
+ # For each byte in the block, advance the checksum state and XOR
132
+ # the ciphertext byte with a table byte selected by the running
133
+ # checksum. This acts as a pre-DES stream-cipher layer.
134
+ for i in range(8):
135
+ # Advance the odd checksum by adding the even checksum (mod 2^32)
136
+ self.odd_byte_checksum = (self.odd_byte_checksum + self.even_byte_checksum) & _MASK32
137
+
138
+ # Use the checksum to pick an index into the crypto table
139
+ # (range 1..0x3fd, i.e. avoiding the first byte)
140
+ table_index = (self.odd_byte_checksum % 0x3FD) + 1
141
+
142
+ # XOR the ciphertext byte with the selected table byte
143
+ data_copy[i] ^= crypto_table[table_index]
144
+
145
+ # Decrypt the XOR'd block with the DES variant (little-endian
146
+ # 64-bit input, returns the low 32-bit result)
147
+ return self.DES.crypt(int.from_bytes(data_copy, "little"), self.key_value, True)