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.
- spice_crypt/__init__.py +23 -0
- spice_crypt/binary_file.py +282 -0
- spice_crypt/cli.py +129 -0
- spice_crypt/crypto_state.py +147 -0
- spice_crypt/decrypt.py +314 -0
- spice_crypt/des.py +385 -0
- spice_crypt-1.2.0.dist-info/METADATA +211 -0
- spice_crypt-1.2.0.dist-info/RECORD +10 -0
- spice_crypt-1.2.0.dist-info/WHEEL +4 -0
- spice_crypt-1.2.0.dist-info/entry_points.txt +3 -0
spice_crypt/__init__.py
ADDED
|
@@ -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)
|