packet-tracer-skill 0.2.2 → 0.3.0
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.
- package/CHANGELOG.md +424 -42
- package/README.md +535 -250
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/automation-controller-proof.md +35 -0
- package/docs/curated-donor-registry.md +11 -0
- package/docs/generate-ready-pilot-design.md +30 -0
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/industrial-programming-proof.md +48 -0
- package/docs/ipv4-routing-management-proof.md +37 -0
- package/docs/l2-resiliency-bgp-proof.md +60 -0
- package/docs/l2-security-qos-proof.md +59 -0
- package/docs/packet-tracer-feature-gap-atlas.md +174 -17
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +27 -13
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +65 -0
- package/docs/voice-collaboration-proof.md +38 -0
- package/docs/wan-security-donor-proof.md +20 -3
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -44
- package/pytest.ini +9 -0
- package/references/packettracer-feature-atlas.json +67 -17
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1319
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +8762 -4070
- package/scripts/intent_parser.py +2242 -1138
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1226
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1195
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
package/scripts/pkt_codec.py
CHANGED
|
@@ -1,181 +1,420 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import struct
|
|
4
|
-
import zlib
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
|
-
|
|
8
|
-
if TYPE_CHECKING:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if len(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
import zlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from vendor.twofish import Twofish
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BLOCK_SIZE = 16
|
|
14
|
+
TAG_LEN = 16
|
|
15
|
+
NEW_KEY = bytes([0x89]) * 16
|
|
16
|
+
NEW_IV = bytes([0x10]) * 16
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_TWOFISH_CLS: type["Twofish"] | None = None
|
|
20
|
+
_TWOFISH_BACKEND = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _twofish_cls() -> type["Twofish"]:
|
|
24
|
+
"""Resolve a Twofish engine.
|
|
25
|
+
|
|
26
|
+
The compiled ctypes bridge is preferred when present because it is ~12x
|
|
27
|
+
faster, but it is optional: the vendored pure-Python implementation is
|
|
28
|
+
always available, so decode/edit/generate work on a clean checkout with no
|
|
29
|
+
binaries and no environment variables.
|
|
30
|
+
"""
|
|
31
|
+
global _TWOFISH_CLS, _TWOFISH_BACKEND
|
|
32
|
+
if _TWOFISH_CLS is not None:
|
|
33
|
+
return _TWOFISH_CLS
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from vendor.twofish import Twofish # compiled accelerator
|
|
37
|
+
|
|
38
|
+
_TWOFISH_BACKEND = "compiled"
|
|
39
|
+
except Exception:
|
|
40
|
+
from vendor.twofish_pure import Twofish # always-available fallback
|
|
41
|
+
|
|
42
|
+
_TWOFISH_BACKEND = "pure_python"
|
|
43
|
+
|
|
44
|
+
_TWOFISH_CLS = Twofish
|
|
45
|
+
return Twofish
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def twofish_backend() -> str:
|
|
49
|
+
"""Return `compiled` or `pure_python` for the engine actually in use."""
|
|
50
|
+
if _TWOFISH_CLS is None:
|
|
51
|
+
_twofish_cls()
|
|
52
|
+
return _TWOFISH_BACKEND
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def qcompress(xml_bytes: bytes) -> bytes:
|
|
56
|
+
if not isinstance(xml_bytes, bytes):
|
|
57
|
+
raise TypeError("xml_bytes must be bytes")
|
|
58
|
+
return struct.pack(">I", len(xml_bytes)) + zlib.compress(xml_bytes, 9)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def quncompress(blob: bytes) -> bytes:
|
|
62
|
+
if len(blob) < 4:
|
|
63
|
+
raise ValueError("qCompress blob is too short")
|
|
64
|
+
size = struct.unpack(">I", blob[:4])[0]
|
|
65
|
+
out = zlib.decompress(blob[4:])
|
|
66
|
+
return out[:size]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _xor_with_mask(data: bytes, mask: bytes) -> bytes:
|
|
70
|
+
"""XOR two equal-length byte strings in one operation.
|
|
71
|
+
|
|
72
|
+
Python's big-integer XOR runs in C, so folding a multi-megabyte payload into
|
|
73
|
+
a single `int` and back beats any per-byte loop by an order of magnitude.
|
|
74
|
+
Decoding a 2.8 MB lab spent seconds in byte-at-a-time comprehensions before
|
|
75
|
+
this.
|
|
76
|
+
"""
|
|
77
|
+
if not data:
|
|
78
|
+
return b""
|
|
79
|
+
length = len(data)
|
|
80
|
+
return (
|
|
81
|
+
int.from_bytes(data, "big") ^ int.from_bytes(mask, "big")
|
|
82
|
+
).to_bytes(length, "big")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _tiled_mask(period: bytes, length: int) -> bytes:
|
|
86
|
+
return (period * (length // len(period) + 1))[:length]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stage2_mask(length: int) -> bytes:
|
|
90
|
+
"""Mask byte `i` is `(length - i) & 0xFF`.
|
|
91
|
+
|
|
92
|
+
That descends through the byte range and wraps, so it repeats every 256
|
|
93
|
+
positions and only one period ever needs building.
|
|
94
|
+
"""
|
|
95
|
+
return _tiled_mask(bytes((length - index) & 0xFF for index in range(256)), length)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _stage1_mask(length: int) -> bytes:
|
|
99
|
+
"""Mask byte `i` is `(length - i * length) & 0xFF`.
|
|
100
|
+
|
|
101
|
+
Also 256-periodic: stepping `i` by 256 adds `256 * length`, which is zero
|
|
102
|
+
modulo 256 whatever the length.
|
|
103
|
+
"""
|
|
104
|
+
return _tiled_mask(
|
|
105
|
+
bytes((length - index * length) & 0xFF for index in range(256)), length
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def stage2_xor(data: bytes) -> bytes:
|
|
110
|
+
return _xor_with_mask(data, _stage2_mask(len(data)))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def stage1_obfuscate(clear: bytes) -> bytes:
|
|
114
|
+
# Byte `i` of the input lands at position `length - 1 - i`, which is simply
|
|
115
|
+
# the reversal of the masked payload.
|
|
116
|
+
return _xor_with_mask(clear, _stage1_mask(len(clear)))[::-1]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def stage1_deobfuscate(obfuscated: bytes) -> bytes:
|
|
120
|
+
return _xor_with_mask(obfuscated[::-1], _stage1_mask(len(obfuscated)))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _xor_bytes(left: bytes, right: bytes) -> bytes:
|
|
124
|
+
if len(left) != len(right):
|
|
125
|
+
left, right = left[: len(right)], right[: len(left)]
|
|
126
|
+
return _xor_with_mask(left, right)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _gf_double(block: bytes) -> bytes:
|
|
130
|
+
value = int.from_bytes(block, "big")
|
|
131
|
+
carry = (value >> 127) & 1
|
|
132
|
+
value = ((value << 1) & ((1 << 128) - 1))
|
|
133
|
+
if carry:
|
|
134
|
+
value ^= 0x87
|
|
135
|
+
return value.to_bytes(16, "big")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _pad_cmac(block: bytes) -> bytes:
|
|
139
|
+
return block + b"\x80" + b"\x00" * (BLOCK_SIZE - len(block) - 1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _iterate_blocks(data: bytes) -> list[bytes]:
|
|
143
|
+
return [data[index : index + BLOCK_SIZE] for index in range(0, len(data), BLOCK_SIZE)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _cmac(cipher: "Twofish", data: bytes) -> bytes:
|
|
147
|
+
zero = b"\x00" * BLOCK_SIZE
|
|
148
|
+
l_val = cipher.encrypt(zero)
|
|
149
|
+
k1 = _gf_double(l_val)
|
|
150
|
+
k2 = _gf_double(k1)
|
|
151
|
+
|
|
152
|
+
blocks = _iterate_blocks(data)
|
|
153
|
+
if not blocks:
|
|
154
|
+
blocks = [b""]
|
|
155
|
+
|
|
156
|
+
if len(blocks[-1]) == BLOCK_SIZE:
|
|
157
|
+
last = _xor_bytes(blocks[-1], k1)
|
|
158
|
+
else:
|
|
159
|
+
last = _xor_bytes(_pad_cmac(blocks[-1]), k2)
|
|
160
|
+
blocks[-1] = last
|
|
161
|
+
|
|
162
|
+
state = zero
|
|
163
|
+
for block in blocks:
|
|
164
|
+
if len(block) != BLOCK_SIZE:
|
|
165
|
+
raise ValueError("CMAC internal block must be 16 bytes")
|
|
166
|
+
state = cipher.encrypt(_xor_bytes(state, block))
|
|
167
|
+
return state
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _omac(cipher: "Twofish", domain: int, data: bytes) -> bytes:
|
|
171
|
+
prefix = b"\x00" * 15 + bytes([domain & 0xFF])
|
|
172
|
+
return _cmac(cipher, prefix + data)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ctr_crypt(cipher: "Twofish", initial_counter: bytes, data: bytes) -> bytes:
|
|
176
|
+
"""CTR mode, generating the whole keystream before XORing once.
|
|
177
|
+
|
|
178
|
+
The previous version XORed each 16-byte block with a generator expression,
|
|
179
|
+
which cost more per block than a big-integer XOR costs for the entire
|
|
180
|
+
payload.
|
|
181
|
+
"""
|
|
182
|
+
counter = int.from_bytes(initial_counter, "big")
|
|
183
|
+
encrypt = cipher.encrypt # hoisted: this runs once per block
|
|
184
|
+
keystream = bytearray()
|
|
185
|
+
for _ in range(0, len(data), BLOCK_SIZE):
|
|
186
|
+
keystream += encrypt(counter.to_bytes(16, "big"))
|
|
187
|
+
counter = (counter + 1) % (1 << 128)
|
|
188
|
+
return _xor_with_mask(data, bytes(keystream[: len(data)]))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def eax_twofish_encrypt(plaintext: bytes, nonce: bytes = NEW_IV, header: bytes = b"") -> tuple[bytes, bytes]:
|
|
192
|
+
cipher = _twofish_cls()(NEW_KEY)
|
|
193
|
+
nonce_mac = _omac(cipher, 0, nonce)
|
|
194
|
+
header_mac = _omac(cipher, 1, header)
|
|
195
|
+
ciphertext = _ctr_crypt(cipher, nonce_mac, plaintext)
|
|
196
|
+
body_mac = _omac(cipher, 2, ciphertext)
|
|
197
|
+
tag = _xor_bytes(_xor_bytes(nonce_mac, header_mac), body_mac)
|
|
198
|
+
return ciphertext, tag
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def eax_twofish_decrypt(
|
|
202
|
+
ciphertext: bytes,
|
|
203
|
+
tag: bytes,
|
|
204
|
+
nonce: bytes = NEW_IV,
|
|
205
|
+
header: bytes = b"",
|
|
206
|
+
verify: bool = True,
|
|
207
|
+
) -> bytes:
|
|
208
|
+
"""Decrypt an EAX payload, optionally without authenticating it.
|
|
209
|
+
|
|
210
|
+
EAX runs the block cipher twice over the data: once for CTR and once for the
|
|
211
|
+
CMAC that produces the tag. Verification therefore accounts for about half
|
|
212
|
+
the work, and on a 2.8 MB lab that is seconds.
|
|
213
|
+
|
|
214
|
+
`verify=False` is for reading a file whose contents are about to be parsed
|
|
215
|
+
anyway -- inventory, version probes, donor indexing. Corruption still shows
|
|
216
|
+
up immediately, as XML that will not parse. Every path that writes a file
|
|
217
|
+
keeps verification on.
|
|
218
|
+
"""
|
|
219
|
+
if len(tag) != TAG_LEN:
|
|
220
|
+
raise ValueError("invalid EAX tag length")
|
|
221
|
+
cipher = _twofish_cls()(NEW_KEY)
|
|
222
|
+
nonce_mac = _omac(cipher, 0, nonce)
|
|
223
|
+
if verify:
|
|
224
|
+
header_mac = _omac(cipher, 1, header)
|
|
225
|
+
body_mac = _omac(cipher, 2, ciphertext)
|
|
226
|
+
expected_tag = _xor_bytes(_xor_bytes(nonce_mac, header_mac), body_mac)
|
|
227
|
+
if expected_tag != tag:
|
|
228
|
+
raise ValueError("EAX authentication tag verification failed")
|
|
229
|
+
return _ctr_crypt(cipher, nonce_mac, ciphertext)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def encode_pkt_modern(xml_bytes: bytes) -> bytes:
|
|
233
|
+
payload = qcompress(xml_bytes)
|
|
234
|
+
stage2 = stage2_xor(payload)
|
|
235
|
+
ciphertext, tag = eax_twofish_encrypt(stage2)
|
|
236
|
+
return stage1_obfuscate(ciphertext + tag)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def decode_pkt_modern(pkt_bytes: bytes, verify: bool = True) -> bytes:
|
|
240
|
+
if len(pkt_bytes) < TAG_LEN:
|
|
241
|
+
raise ValueError("pkt blob is too short")
|
|
242
|
+
stage1 = stage1_deobfuscate(pkt_bytes)
|
|
243
|
+
ciphertext = stage1[:-TAG_LEN]
|
|
244
|
+
tag = stage1[-TAG_LEN:]
|
|
245
|
+
stage2 = eax_twofish_decrypt(ciphertext, tag, verify=verify)
|
|
246
|
+
payload = stage2_xor(stage2)
|
|
247
|
+
return quncompress(payload)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Packet Tracer writes raw control bytes into element text — a Cisco banner
|
|
251
|
+
# delimiter is literally `banner motd \x03`. XML 1.0 forbids those, so a strict
|
|
252
|
+
# parser rejects the whole document even though Packet Tracer reads it happily.
|
|
253
|
+
# Map them into the Unicode private use area for parsing and map them back on
|
|
254
|
+
# the way out, so the round trip is faithful rather than lossy.
|
|
255
|
+
_XML_SAFE_CONTROL_BYTES = {0x09, 0x0A, 0x0D}
|
|
256
|
+
_CONTROL_PLACEHOLDER_BASE = 0xE000
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def xml_escape_control_bytes(xml_bytes: bytes) -> bytes:
|
|
260
|
+
"""Replace XML-forbidden control bytes with private-use placeholders."""
|
|
261
|
+
if not any(byte < 0x20 and byte not in _XML_SAFE_CONTROL_BYTES for byte in xml_bytes):
|
|
262
|
+
return xml_bytes
|
|
263
|
+
out = bytearray()
|
|
264
|
+
for byte in xml_bytes:
|
|
265
|
+
if byte < 0x20 and byte not in _XML_SAFE_CONTROL_BYTES:
|
|
266
|
+
out.extend(chr(_CONTROL_PLACEHOLDER_BASE + byte).encode("utf-8"))
|
|
267
|
+
else:
|
|
268
|
+
out.append(byte)
|
|
269
|
+
return bytes(out)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def xml_restore_control_bytes(xml_bytes: bytes) -> bytes:
|
|
273
|
+
"""Inverse of `xml_escape_control_bytes`."""
|
|
274
|
+
text = xml_bytes.decode("utf-8", "surrogatepass")
|
|
275
|
+
if not any(_CONTROL_PLACEHOLDER_BASE <= ord(char) < _CONTROL_PLACEHOLDER_BASE + 0x20 for char in text):
|
|
276
|
+
return xml_bytes
|
|
277
|
+
restored = "".join(
|
|
278
|
+
chr(ord(char) - _CONTROL_PLACEHOLDER_BASE)
|
|
279
|
+
if _CONTROL_PLACEHOLDER_BASE <= ord(char) < _CONTROL_PLACEHOLDER_BASE + 0x20
|
|
280
|
+
else char
|
|
281
|
+
for char in text
|
|
282
|
+
)
|
|
283
|
+
return restored.encode("utf-8", "surrogatepass")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_pkt_xml(xml_bytes: bytes) -> "ET.Element":
|
|
287
|
+
"""Parse Packet Tracer XML, tolerating the control bytes it really writes."""
|
|
288
|
+
import xml.etree.ElementTree as ET
|
|
289
|
+
|
|
290
|
+
return ET.fromstring(xml_escape_control_bytes(xml_bytes))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def serialize_pkt_xml(root: "ET.Element") -> bytes:
|
|
294
|
+
"""Serialize a tree parsed by `parse_pkt_xml`, restoring control bytes."""
|
|
295
|
+
import xml.etree.ElementTree as ET
|
|
296
|
+
|
|
297
|
+
return xml_restore_control_bytes(ET.tostring(root, encoding="utf-8", xml_declaration=False))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def legacy_xor(data: bytes) -> bytes:
|
|
301
|
+
"""The pre-Twofish `.pkt` obfuscation, which is its own inverse.
|
|
302
|
+
|
|
303
|
+
Packet Tracer 5.x and 6.x wrote saves as qCompress output XORed byte-wise
|
|
304
|
+
with `(length - index)`. No Twofish, no EAX tag, no reversal. 18 of the 292
|
|
305
|
+
samples bundled with Packet Tracer 9.0 are still in this format, including
|
|
306
|
+
the QoS, SNMP, NAT, TFTP, IPsec, AAA, CBAC, ZFW and VoIP labs.
|
|
307
|
+
"""
|
|
308
|
+
length = len(data)
|
|
309
|
+
return bytes(byte ^ ((length - index) & 0xFF) for index, byte in enumerate(data))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def decode_pkt_legacy(pkt_bytes: bytes) -> bytes:
|
|
313
|
+
"""Decode a pre-Twofish `.pkt`."""
|
|
314
|
+
return quncompress(legacy_xor(pkt_bytes))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def encode_pkt_legacy(xml_bytes: bytes) -> bytes:
|
|
318
|
+
return legacy_xor(qcompress(xml_bytes))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def detect_pkt_format(pkt_bytes: bytes) -> str:
|
|
322
|
+
"""Return `modern`, `legacy`, or `unknown` without raising."""
|
|
323
|
+
try:
|
|
324
|
+
decode_pkt_modern(pkt_bytes)
|
|
325
|
+
return "modern"
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
try:
|
|
329
|
+
decode_pkt_legacy(pkt_bytes)
|
|
330
|
+
return "legacy"
|
|
331
|
+
except Exception:
|
|
332
|
+
return "unknown"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def decode_pkt_auto(pkt_bytes: bytes, verify: bool = True) -> tuple[bytes, str]:
|
|
336
|
+
"""Decode either container variant, reporting which one matched.
|
|
337
|
+
|
|
338
|
+
Pass `verify=False` when the result is about to be parsed as XML anyway --
|
|
339
|
+
it skips the CMAC pass, roughly halving the block-cipher work.
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
return decode_pkt_modern(pkt_bytes, verify=verify), "modern"
|
|
343
|
+
except Exception as modern_error:
|
|
344
|
+
try:
|
|
345
|
+
return decode_pkt_legacy(pkt_bytes), "legacy"
|
|
346
|
+
except Exception:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"not a readable Packet Tracer save: modern decode failed ({modern_error}), "
|
|
349
|
+
"and the legacy container did not match either"
|
|
350
|
+
) from modern_error
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def peek_pkt_header(pkt_bytes: bytes, max_xml_bytes: int = 8192) -> bytes:
|
|
354
|
+
"""Decrypt just enough of a `.pkt` to read the start of its XML.
|
|
355
|
+
|
|
356
|
+
Donor scanning reads `<VERSION>` from dozens of files per run. Doing that
|
|
357
|
+
with `decode_pkt_modern` costs a full EAX pass over every byte plus three
|
|
358
|
+
OMACs, which measured at ~3 s per file and dominated the runtime.
|
|
359
|
+
|
|
360
|
+
CTR mode is seekable and the header sits at the front, so only the first few
|
|
361
|
+
blocks need decrypting. The zlib stream is fed incrementally and abandoned
|
|
362
|
+
as soon as enough plaintext is out.
|
|
363
|
+
|
|
364
|
+
This intentionally skips tag verification: it is a read-only probe used to
|
|
365
|
+
decide whether a file is worth considering, and it never produces content
|
|
366
|
+
that is written back out. Anything that matters goes through
|
|
367
|
+
`decode_pkt_modern`, which does authenticate.
|
|
368
|
+
"""
|
|
369
|
+
if len(pkt_bytes) < TAG_LEN + BLOCK_SIZE:
|
|
370
|
+
raise ValueError("pkt blob is too short")
|
|
371
|
+
|
|
372
|
+
blob_length = len(pkt_bytes)
|
|
373
|
+
total_length = blob_length - TAG_LEN
|
|
374
|
+
|
|
375
|
+
# 4 bytes of qCompress header + a compressed prefix that is generously
|
|
376
|
+
# larger than the plaintext we want back out.
|
|
377
|
+
wanted = 4 + max_xml_bytes
|
|
378
|
+
prefix_len = min(total_length, ((wanted + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE)
|
|
379
|
+
|
|
380
|
+
# Stage 1 reverses the buffer, so the prefix we need comes from the *tail* of
|
|
381
|
+
# the file. Computing only those bytes keeps this O(prefix) rather than
|
|
382
|
+
# O(file), which matters because the largest labs are several megabytes.
|
|
383
|
+
stage1_prefix = bytes(
|
|
384
|
+
pkt_bytes[blob_length - 1 - index] ^ ((blob_length - index * blob_length) & 0xFF)
|
|
385
|
+
for index in range(prefix_len)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
cipher = _twofish_cls()(NEW_KEY)
|
|
389
|
+
nonce_mac = _omac(cipher, 0, NEW_IV)
|
|
390
|
+
stage2_prefix = _ctr_crypt(cipher, nonce_mac, stage1_prefix)
|
|
391
|
+
|
|
392
|
+
# stage2_xor's key stream depends on the *full* payload length, which is
|
|
393
|
+
# known from the blob size without decrypting the rest.
|
|
394
|
+
payload_prefix = bytes(
|
|
395
|
+
byte ^ ((total_length - index) & 0xFF) for index, byte in enumerate(stage2_prefix)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
decompressor = zlib.decompressobj()
|
|
399
|
+
try:
|
|
400
|
+
return decompressor.decompress(payload_prefix[4:], max_xml_bytes)
|
|
401
|
+
except zlib.error as exc:
|
|
402
|
+
raise ValueError(f"could not decompress pkt header: {exc}") from exc
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def encode_xml_file(xml_path: str | Path, output_path: str | Path) -> Path:
|
|
406
|
+
xml_path = Path(xml_path)
|
|
407
|
+
output_path = Path(output_path)
|
|
408
|
+
pkt_bytes = encode_pkt_modern(xml_path.read_bytes())
|
|
409
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
output_path.write_bytes(pkt_bytes)
|
|
411
|
+
return output_path
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def decode_pkt_file(pkt_path: str | Path, xml_out_path: str | Path) -> Path:
|
|
415
|
+
pkt_path = Path(pkt_path)
|
|
416
|
+
xml_out_path = Path(xml_out_path)
|
|
417
|
+
xml_bytes = decode_pkt_modern(pkt_path.read_bytes())
|
|
418
|
+
xml_out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
xml_out_path.write_bytes(xml_bytes)
|
|
420
|
+
return xml_out_path
|