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.
Files changed (66) hide show
  1. package/CHANGELOG.md +424 -42
  2. package/README.md +535 -250
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/automation-controller-proof.md +35 -0
  6. package/docs/curated-donor-registry.md +11 -0
  7. package/docs/generate-ready-pilot-design.md +30 -0
  8. package/docs/github-launch-ops-0.2.3.md +37 -0
  9. package/docs/github-metadata.md +6 -4
  10. package/docs/hero-demo-plan.md +1 -1
  11. package/docs/home-iot-donor-proof.md +4 -4
  12. package/docs/industrial-programming-proof.md +48 -0
  13. package/docs/ipv4-routing-management-proof.md +37 -0
  14. package/docs/l2-resiliency-bgp-proof.md +60 -0
  15. package/docs/l2-security-qos-proof.md +59 -0
  16. package/docs/packet-tracer-feature-gap-atlas.md +174 -17
  17. package/docs/post-launch-follow-up.md +9 -5
  18. package/docs/proof-readiness-dashboard.md +69 -0
  19. package/docs/publish-preview-roadmap.md +6 -5
  20. package/docs/release-checklist.md +27 -13
  21. package/docs/release-notes-0.2.2.md +1 -1
  22. package/docs/release-notes-0.2.3.md +59 -0
  23. package/docs/release-notes-0.2.4.md +20 -0
  24. package/docs/runtime-truth.md +33 -8
  25. package/docs/security-edge-deepening-proof.md +65 -0
  26. package/docs/voice-collaboration-proof.md +38 -0
  27. package/docs/wan-security-donor-proof.md +20 -3
  28. package/examples/README.md +98 -69
  29. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  30. package/examples/gallery.md +94 -6
  31. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  32. package/examples/index.json +932 -4
  33. package/examples/local-sample-evidence.json +24 -0
  34. package/examples/proof-cards.json +117 -0
  35. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  36. package/package.json +60 -44
  37. package/pytest.ini +9 -0
  38. package/references/packettracer-feature-atlas.json +67 -17
  39. package/references/packettracer-sample-catalog.json +45287 -4525
  40. package/references/packettracer-sample-catalog.md +599 -259
  41. package/references/proof-readiness-candidates.json +352 -0
  42. package/scripts/build_examples_index.py +228 -35
  43. package/scripts/build_sample_catalog.py +24 -44
  44. package/scripts/corpus_runner.py +430 -0
  45. package/scripts/coverage_matrix.py +1842 -1319
  46. package/scripts/donor_cache.py +354 -0
  47. package/scripts/donor_diagnostics.py +3 -1
  48. package/scripts/feature_atlas.py +65 -1
  49. package/scripts/generate_pkt.py +8762 -4070
  50. package/scripts/intent_parser.py +2242 -1138
  51. package/scripts/local_donors.py +340 -0
  52. package/scripts/packet_tracer_env.py +846 -391
  53. package/scripts/pkt_annotate.py +218 -0
  54. package/scripts/pkt_codec.py +420 -181
  55. package/scripts/pkt_editor.py +2405 -1226
  56. package/scripts/pkt_transformer.py +1072 -727
  57. package/scripts/pkt_verify.py +461 -0
  58. package/scripts/remote_search.py +197 -21
  59. package/scripts/runtime_doctor.py +80 -29
  60. package/scripts/sample_catalog.py +1372 -1195
  61. package/scripts/twofish_diagnostics.py +48 -31
  62. package/scripts/usage_ledger.py +218 -0
  63. package/scripts/vendor/README.md +44 -37
  64. package/scripts/vendor/twofish_pure.py +321 -0
  65. package/scripts/workspace_repair.py +548 -508
  66. package/templates/pt900/donors/README.md +15 -0
@@ -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
- from vendor.twofish import Twofish
10
-
11
-
12
- BLOCK_SIZE = 16
13
- TAG_LEN = 16
14
- NEW_KEY = bytes([0x89]) * 16
15
- NEW_IV = bytes([0x10]) * 16
16
-
17
-
18
- def _twofish_cls() -> type["Twofish"]:
19
- try:
20
- from vendor.twofish import Twofish
21
- except ImportError as exc:
22
- raise ImportError(
23
- "Packet Tracer modern codec requires a local Twofish bridge. "
24
- "Set PKT_TWOFISH_LIBRARY or place a local _twofish binary next to scripts/vendor/twofish.py."
25
- ) from exc
26
- return Twofish
27
-
28
-
29
- def qcompress(xml_bytes: bytes) -> bytes:
30
- if not isinstance(xml_bytes, bytes):
31
- raise TypeError("xml_bytes must be bytes")
32
- return struct.pack(">I", len(xml_bytes)) + zlib.compress(xml_bytes, 9)
33
-
34
-
35
- def quncompress(blob: bytes) -> bytes:
36
- if len(blob) < 4:
37
- raise ValueError("qCompress blob is too short")
38
- size = struct.unpack(">I", blob[:4])[0]
39
- out = zlib.decompress(blob[4:])
40
- return out[:size]
41
-
42
-
43
- def stage2_xor(data: bytes) -> bytes:
44
- length = len(data)
45
- return bytes(byte ^ ((length - index) & 0xFF) for index, byte in enumerate(data))
46
-
47
-
48
- def stage1_obfuscate(clear: bytes) -> bytes:
49
- length = len(clear)
50
- out = bytearray(length)
51
- for index, byte in enumerate(clear):
52
- out[length - 1 - index] = byte ^ ((length - index * length) & 0xFF)
53
- return bytes(out)
54
-
55
-
56
- def stage1_deobfuscate(obfuscated: bytes) -> bytes:
57
- length = len(obfuscated)
58
- return bytes(
59
- obfuscated[length - 1 - index] ^ ((length - index * length) & 0xFF)
60
- for index in range(length)
61
- )
62
-
63
-
64
- def _xor_bytes(left: bytes, right: bytes) -> bytes:
65
- return bytes(a ^ b for a, b in zip(left, right))
66
-
67
-
68
- def _gf_double(block: bytes) -> bytes:
69
- value = int.from_bytes(block, "big")
70
- carry = (value >> 127) & 1
71
- value = ((value << 1) & ((1 << 128) - 1))
72
- if carry:
73
- value ^= 0x87
74
- return value.to_bytes(16, "big")
75
-
76
-
77
- def _pad_cmac(block: bytes) -> bytes:
78
- return block + b"\x80" + b"\x00" * (BLOCK_SIZE - len(block) - 1)
79
-
80
-
81
- def _iterate_blocks(data: bytes) -> list[bytes]:
82
- return [data[index : index + BLOCK_SIZE] for index in range(0, len(data), BLOCK_SIZE)]
83
-
84
-
85
- def _cmac(cipher: "Twofish", data: bytes) -> bytes:
86
- zero = b"\x00" * BLOCK_SIZE
87
- l_val = cipher.encrypt(zero)
88
- k1 = _gf_double(l_val)
89
- k2 = _gf_double(k1)
90
-
91
- blocks = _iterate_blocks(data)
92
- if not blocks:
93
- blocks = [b""]
94
-
95
- if len(blocks[-1]) == BLOCK_SIZE:
96
- last = _xor_bytes(blocks[-1], k1)
97
- else:
98
- last = _xor_bytes(_pad_cmac(blocks[-1]), k2)
99
- blocks[-1] = last
100
-
101
- state = zero
102
- for block in blocks:
103
- if len(block) != BLOCK_SIZE:
104
- raise ValueError("CMAC internal block must be 16 bytes")
105
- state = cipher.encrypt(_xor_bytes(state, block))
106
- return state
107
-
108
-
109
- def _omac(cipher: "Twofish", domain: int, data: bytes) -> bytes:
110
- prefix = b"\x00" * 15 + bytes([domain & 0xFF])
111
- return _cmac(cipher, prefix + data)
112
-
113
-
114
- def _ctr_crypt(cipher: "Twofish", initial_counter: bytes, data: bytes) -> bytes:
115
- counter = int.from_bytes(initial_counter, "big")
116
- out = bytearray()
117
- for offset in range(0, len(data), BLOCK_SIZE):
118
- block = data[offset : offset + BLOCK_SIZE]
119
- keystream = cipher.encrypt(counter.to_bytes(16, "big"))
120
- out.extend(bytes(a ^ b for a, b in zip(block, keystream)))
121
- counter = (counter + 1) % (1 << 128)
122
- return bytes(out)
123
-
124
-
125
- def eax_twofish_encrypt(plaintext: bytes, nonce: bytes = NEW_IV, header: bytes = b"") -> tuple[bytes, bytes]:
126
- cipher = _twofish_cls()(NEW_KEY)
127
- nonce_mac = _omac(cipher, 0, nonce)
128
- header_mac = _omac(cipher, 1, header)
129
- ciphertext = _ctr_crypt(cipher, nonce_mac, plaintext)
130
- body_mac = _omac(cipher, 2, ciphertext)
131
- tag = _xor_bytes(_xor_bytes(nonce_mac, header_mac), body_mac)
132
- return ciphertext, tag
133
-
134
-
135
- def eax_twofish_decrypt(ciphertext: bytes, tag: bytes, nonce: bytes = NEW_IV, header: bytes = b"") -> bytes:
136
- if len(tag) != TAG_LEN:
137
- raise ValueError("invalid EAX tag length")
138
- cipher = _twofish_cls()(NEW_KEY)
139
- nonce_mac = _omac(cipher, 0, nonce)
140
- header_mac = _omac(cipher, 1, header)
141
- body_mac = _omac(cipher, 2, ciphertext)
142
- expected_tag = _xor_bytes(_xor_bytes(nonce_mac, header_mac), body_mac)
143
- if expected_tag != tag:
144
- raise ValueError("EAX authentication tag verification failed")
145
- return _ctr_crypt(cipher, nonce_mac, ciphertext)
146
-
147
-
148
- def encode_pkt_modern(xml_bytes: bytes) -> bytes:
149
- payload = qcompress(xml_bytes)
150
- stage2 = stage2_xor(payload)
151
- ciphertext, tag = eax_twofish_encrypt(stage2)
152
- return stage1_obfuscate(ciphertext + tag)
153
-
154
-
155
- def decode_pkt_modern(pkt_bytes: bytes) -> bytes:
156
- if len(pkt_bytes) < TAG_LEN:
157
- raise ValueError("pkt blob is too short")
158
- stage1 = stage1_deobfuscate(pkt_bytes)
159
- ciphertext = stage1[:-TAG_LEN]
160
- tag = stage1[-TAG_LEN:]
161
- stage2 = eax_twofish_decrypt(ciphertext, tag)
162
- payload = stage2_xor(stage2)
163
- return quncompress(payload)
164
-
165
-
166
- def encode_xml_file(xml_path: str | Path, output_path: str | Path) -> Path:
167
- xml_path = Path(xml_path)
168
- output_path = Path(output_path)
169
- pkt_bytes = encode_pkt_modern(xml_path.read_bytes())
170
- output_path.parent.mkdir(parents=True, exist_ok=True)
171
- output_path.write_bytes(pkt_bytes)
172
- return output_path
173
-
174
-
175
- def decode_pkt_file(pkt_path: str | Path, xml_out_path: str | Path) -> Path:
176
- pkt_path = Path(pkt_path)
177
- xml_out_path = Path(xml_out_path)
178
- xml_bytes = decode_pkt_modern(pkt_path.read_bytes())
179
- xml_out_path.parent.mkdir(parents=True, exist_ok=True)
180
- xml_out_path.write_bytes(xml_bytes)
181
- return xml_out_path
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