From 6ab6523acb76103c847a35c5f16a04700170a9bf Mon Sep 17 00:00:00 2001 From: Sergey Ukolov Date: Fri, 18 Sep 2026 14:48:04 +0300 Subject: [PATCH 1/2] feat(keys): recover all three keys statically from bitwig.jar The Dag, nitro-image and nitro-std keys are ordinary byte[] literals in bitwig.jar. They are built by bytecode -- newarray byte followed by one bastore per element -- so the bytes sit one every four, interleaved with opcodes, and never appear as a contiguous run. Searching the jars for them finds nothing in any encoding, which is what the docs concluded from; reading the arrays out of the bytecode finds all three. examples/extract_keys_from_jar.py scans every class for byte[] literals of 48+ bytes -- the cipher factory rejects anything shorter with "Key too short" -- and verifies each candidate against your own installed archives before reporting it. A 6.1 jar holds five such arrays across ~17,000 classes: the three keys, one in the Skia shader filesystem, and one unrelated table. Nothing prints as hex without --hex, and no key material is committed. Verified on 5.1.9 and 6.1, which carry byte-identical values for all three keys: 517/517 nitro-image members decompile on 6.1, 121/121 nitro-std members decrypt to valid Nitro source, and factory 0004 document metadata parses. A 5.1.9 nitro-image decrypts correctly but does not decompile, because the nitrobin container gained a field between the two builds. The image check therefore looks for the member's own name in the plaintext rather than decompiling, so a format gap is not read as a bad key. nitro-std is the same Dag cipher as nitro-image under a 96-byte key with a 192-byte IV, not a runtime PRNG, so it decrypts offline as well. The live-JVM controllers are unchanged and stay documented as the live route; only the claim that they are the only route is removed. --- CHANGELOG.md | 28 ++++ README.md | 14 +- docs/KEY_EXTRACTION.md | 128 +++++++++++---- examples/extract_keys_from_jar.py | 238 ++++++++++++++++++++++++++++ src/bitwig_nitro/cli/decrypt_std.py | 11 +- 5 files changed, 379 insertions(+), 40 deletions(-) create mode 100644 examples/extract_keys_from_jar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cf9b5..f22902a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Static key extraction from `bitwig.jar`**, via + `examples/extract_keys_from_jar.py`. All three keys — Dag, `nitro-image` and + `nitro-std` — are `byte[]` literals in the jar, built by bytecode + (`newarray byte` plus one `bastore` per element) rather than stored as + contiguous bytes, which is why searching the jars for them finds nothing in + any encoding. The script reads the arrays out of the bytecode and verifies + each candidate against your own installed archives before reporting it; + nothing is printed as hex unless you pass `--hex`. Ships no keys. Verified on + Bitwig 5.1.9 and 6.1, which carry byte-identical values for all three: + 517/517 `nitro-image` members decompile on 6.1, 121/121 `nitro-std` members + decrypt to valid Nitro source, and factory `0004` document metadata parses. + +### Changed + +- **`docs/KEY_EXTRACTION.md` and the README no longer say static recovery is + impossible.** That conclusion came from searching for each key as a + contiguous byte string, which does fail. The live-JVM controllers are + unchanged and stay documented as the live route. +- `nitro-decrypt-std`'s docstring no longer describes `nitro-std` as wrapped in + a runtime PRNG cipher that cannot be reproduced offline. It uses the same Dag + cipher as `nitro-image`, under a 96-byte key with a 192-byte IV. The command + itself is unchanged. +- Recorded that a 5.1.9 `nitro-image` decrypts correctly under this key but + does not decompile: the nitrobin container gained a field between 5.1.9 and + 6.1. That is a format-version gap in the decompiler, not a key failure. + ## [0.2.0] - 2026-08-12 ### Added diff --git a/README.md b/README.md index af38ca1..f3cc244 100644 --- a/README.md +++ b/README.md @@ -168,14 +168,12 @@ A few things that surfaced while reverse-engineering the format: prove Bitwig's loader would accept a *modified* module. That is a separate, live, untested question. See [docs/NITRO_LOAD_MECHANISM.md](docs/NITRO_LOAD_MECHANISM.md). -- **Key extraction runs against your own install, not this repo.** Both keys are - materialized at runtime — neither appears anywhere in Bitwig's jars, in any - encoding — so there is no static or offline key recovery. Recovery goes - through a small controller extension this project bundles: you install it, add - it once in Bitwig, and it dumps the key for the CLI to pick up - (`nitro-extract-keys --install-controller`, then `--live`). That live step - loads inside your own licensed Bitwig and is yours to run and confirm; the Dag - key (for `0004` documents) is entered manually. See +- **Key extraction runs against your own install, not this repo.** All three + keys are static `byte[]` literals in `bitwig.jar`, built by bytecode rather + than stored as contiguous bytes, which is why a `grep` over the jars finds + nothing in any encoding. `examples/extract_keys_from_jar.py` reads them out + and verifies each against your own archives before reporting it. The bundled + controller extension still works and stays documented as the live route. See [docs/KEY_EXTRACTION.md](docs/KEY_EXTRACTION.md). - **Repacked-image loader acceptance is unproven.** Repacking the archive is byte-exact offline, but whether Bitwig loads your repack is one restart-gated diff --git a/docs/KEY_EXTRACTION.md b/docs/KEY_EXTRACTION.md index 530d868..01be1a2 100644 --- a/docs/KEY_EXTRACTION.md +++ b/docs/KEY_EXTRACTION.md @@ -10,18 +10,23 @@ If you do not have a licensed Bitwig install, this toolchain can still parse, serialize, edit, and pretty-print `.nitrobin` bytes you already have in plaintext; it just cannot decrypt anything for you. -## The two keys +## The three keys -There are two independent keys, for two independent cipher surfaces. +There are three independent keys, for three independent cipher surfaces. -**Both keys are materialized at runtime.** Neither the nitro-image key nor the -Dag key is stored as static data. Neither appears in `bitwig.jar`, `libs.jar`, -or `lwjgl.jar` in any form — not raw, not hex, not base64 — nor in the native -binaries; this was checked directly. A plain disassembly or a `grep` over the -jars will not hand you either key. **Static jar recovery is impossible.** The -only proven way to recover a key is to read it out of a running Bitwig JVM, -which is what the bundled controller extension does (see -[Running nitro-extract-keys](#running-nitro-extract-keys)). +**All three are static `byte[]` literals in `bitwig.jar`, and a script in this +repository recovers them.** See +[Recovering the keys from the jar](#recovering-the-keys-from-the-jar). The +live-JVM controllers still work and are still documented below, but they are no +longer the only route. + +Earlier revisions of this document said the opposite: that the keys are +materialized at runtime and static recovery is impossible. That conclusion came +from searching the jars for the key as a contiguous byte string, which does +fail — in every encoding. The arrays are built by *bytecode*: a `newarray byte` +followed by one `bastore` per element, so the key bytes are interleaved with +opcodes, one byte every four. No `grep` finds that. Reading the array out of +the bytecode finds all three. ### 1. The nitro-image key @@ -40,12 +45,17 @@ examined). A sibling entry carries the *same* key value with `iv_size_uEK == 0`, so select by the IV-size field (198), not by position in the chain — the order is not guaranteed stable across releases. -**This key is not in the jar.** The transform *classes* are defined in -`bitwig.jar`, but their key bytes are populated at runtime; the 99-byte value -does not appear anywhere in `bitwig.jar`, `libs.jar`, or `lwjgl.jar`, in any -encoding (this was checked). You recover it the same way you recover the Dag -key: by reading the live transform objects out of a running JVM. There is no -static shortcut. +**This key is in the jar**, as a 99-byte array literal in the class that +declares the nitro transform chains (`com/bitwig/nitro/NitroFile`, and two +obfuscated classes that carry the same array). The chain reads + +```java +new ya(new GNy[]{Krq.TUp(), new LQt(EwU, 0), new LQt(EwU, EwU.length * 2)}, 2) +``` + +so the `iv_size` of 198 is simply `99 * 2`, and the sibling entry with +`iv_size == 0` is the second `LQt` over the same array. Selecting by IV size +works because it is derived from the key length, not stored independently. ### 2. The Dag key @@ -55,12 +65,30 @@ Dag key recovers the readable metadata section; the file body may sit behind a further layer that this key does not open, so treat `0004` support as metadata-level. -Where it lives: the Dag key is reached in the running JVM through the field -chain `ZKE.uEK -> BIa.Xzy` (obfuscated class and field names, which shift -between releases). It is **not** stored as static class data. It is materialized -into Java runtime objects that are initialized by native code, which means a -plain static disassembly will not hand you the bytes; you recover it from a -live object graph. +Where it lives: a 128-byte array literal in a class under +`com/bitwig/base/serial/file/`. The class name is obfuscated and shifts between +releases — `Tl3` on 6.1, `q2p` on 5.1.9 — but the package path is not +obfuscated, and it is the only long array literal under it. The method holding +it ends in `return new LQt(var1, 16)`: the Dag cipher with a 16-byte IV, which +is the IV length the `0004` container prefixes each section with. + +It is also reachable from a live JVM through the field chain described in +earlier revisions, if you prefer that route. + +### 3. The nitro-std key + +Decrypts the stdlib *source* members inside `/Library/nitro-std`. + +Where it lives: a 96-byte array literal in the same class as the nitro-image +key, in the version-1 chain: + +```java +new ya(new GNy[]{Krq.TUp(), new LQt(jaQ, jaQ.length * 2)}, 1) +``` + +Each member is stored as `[version][iv:192][ciphertext]` and decrypts with the +same Dag routine the other two surfaces use. This surface does **not** require +a live JVM: `nitro-decrypt-std` and its controller predate this finding. ## The cipher, for context @@ -80,6 +108,44 @@ position (never on the data), the transform is its own inverse. That is why routine. `dag_decrypt(data, key, iv)` in `bitwig_nitro.dag_cipher` implements it; you provide `key` and `iv`. +## Recovering the keys from the jar + +```bash +python examples/extract_keys_from_jar.py # report +python examples/extract_keys_from_jar.py --write # write keys.json +python examples/extract_keys_from_jar.py --install /path/to/Bitwig +``` + +The script scans every class in the jar for `byte[]` literals of 48 bytes or +more — the cipher factory rejects anything shorter with `Key too short` — and +then **verifies each candidate against your own installed archives** before +reporting it. A wrong key yields high-entropy noise; a right one yields content +with the structure its container promises. Candidates that verify against +nothing are reported as unidentified rather than guessed at. + +There are very few candidates to begin with. A 6.1 jar holds five arrays of +48+ bytes across roughly 17,000 classes: the three keys, one in the Skia +shader filesystem, and one unrelated 257-byte table. + +Nothing is printed as hex unless you pass `--hex`. + +**Verified on two builds.** Bitwig 5.1.9 and 6.1 carry byte-identical values +for all three keys, and all three decrypt correctly on both. Checked by +decrypting every member of each archive: + +| Surface | Result | +| --- | --- | +| `nitro-image`, 6.1 | 517/517 members decompile with `decompile_nitrobin` | +| `nitro-std`, 6.1 | 121/121 members decrypt to valid UTF-8 Nitro source | +| `0004` documents | factory `.bwdevice` metadata sections decrypt and parse | + +One caveat worth recording: a 5.1.9 `nitro-image` decrypts correctly under this +key but does **not** decompile, because the nitrobin container gained a field +between 5.1.9 and 6.1. That is a format-version gap in the decompiler, not a +key problem — the decrypted plaintext carries the member's own name in clear +and its entropy drops from 7.8 to 4.1. The script's check looks for the member +name rather than decompiling, so it does not conflate the two. + ## keys.json Once you have the two keys as hex strings, put them in a `keys.json`: @@ -197,12 +263,18 @@ nitro-image entry. For the Dag key the live object is reached through the `ZKE.uEK -> BIa.Xzy` field chain (obfuscated names shift between releases; identify the classes by role, not by name, using the disassembly as a map). -What you will **not** find in the class files is either key value. On disk those -fields are empty/default; the bytes are staged in at runtime. Disassembly of the -audio engine binary shows the cipher (registered under the name `BIa`) is a -small object constructed by a thread-safe factory, with the key material staged -through runtime state rather than baked into the class file. That is why there -is no static route for either key, and why recovery goes through a live JVM. +The key values **are** in the class files, as the array literals described +above. The fields on the transform objects are empty on disk because the +constructor is handed the array; the array itself is a constant in the class +that declares the chain, not in the cipher class. Looking at the cipher class +alone is what makes the key look absent. + +A shortcut for locating the cipher class itself, if you want to confirm the +routine by eye: its rotate-right-by-`(n & 7)` step compiles to a distinctive +`iushr` / `bipush 8` / `isub` / `ishl` / `ior` window, and exactly one class in +the jar contains it — `QMl` on 6.1, `pPi` on 5.1.9. From there, the class that +constructs it is the chain wrapper, and the class that constructs *that* holds +the key. **Verify.** diff --git a/examples/extract_keys_from_jar.py b/examples/extract_keys_from_jar.py new file mode 100644 index 0000000..2199043 --- /dev/null +++ b/examples/extract_keys_from_jar.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Recover the cipher keys statically from your own ``bitwig.jar``. + +All three keys are ordinary ``byte[]`` literals in the jar. They are built by +bytecode -- ``newarray byte`` followed by one ``bastore`` per element -- so the +bytes are interleaved with opcodes and never appear as a contiguous run. A +``grep`` over the jar finds nothing in any encoding; reading the array out of +the bytecode finds all three. + + python examples/extract_keys_from_jar.py + python examples/extract_keys_from_jar.py --jar /path/to/bitwig.jar --write + +Every candidate is verified against your own installed archives before it is +reported: a wrong key yields high-entropy garbage, a right one yields content +with the structure its container promises. Nothing here prints key material +unless you ask for ``--hex``. + +This reads YOUR own licensed install and writes to YOUR own disk. It ships no +keys and no Bitwig content. +""" +from __future__ import annotations + +import argparse +import hashlib +import struct +import sys +import zipfile +from pathlib import Path + +from bitwig_nitro import write_keys_file +from bitwig_nitro.dag_cipher import dag_decrypt + +# Where a stock install keeps the jar and the two archives, per platform. +MAC_APP = Path("/Applications/Bitwig Studio.app") +JAR_RELATIVE = ("Contents/Java/bitwig.jar", "bin/bitwig.jar", "lib/bitwig/bitwig.jar") + +# The factory key is at least 48 bytes: the cipher factory rejects anything +# shorter with "Key too short". +MIN_KEY = 48 + + +# --------------------------------------------------------------------------- +# reading byte[] literals out of bytecode +# --------------------------------------------------------------------------- + + +def _push(code: bytes, i: int) -> tuple[int | None, int]: + """Decode one integer-push instruction. Returns (value, next offset).""" + op = code[i] + if 0x02 <= op <= 0x08: # iconst_m1 .. iconst_5 + return op - 0x03, i + 1 + if op == 0x10: # bipush + return struct.unpack(">b", code[i + 1 : i + 2])[0], i + 2 + if op == 0x11: # sipush + return struct.unpack(">h", code[i + 1 : i + 3])[0], i + 3 + return None, i + + +def byte_array_literals(class_bytes: bytes, minimum: int = MIN_KEY) -> list[bytes]: + """Every ``byte[]`` built by a run of ``bastore``, in class-file order. + + Scans the whole class rather than parsing the code attributes: the pattern + ``newarray byte`` then repeated ``dup / / / bastore`` is + unambiguous enough that a false positive would have to be a run of valid + pushes ending in 0x54, which does not occur in practice. + """ + out: list[bytes] = [] + i = 0 + while i < len(class_bytes) - 2: + if class_bytes[i] == 0xBC and class_bytes[i + 1] == 0x08: # newarray byte + j = i + 2 + values: dict[int, int] = {} + while j < len(class_bytes) - 1 and class_bytes[j] == 0x59: # dup + index, j2 = _push(class_bytes, j + 1) + if index is None: + break + value, j3 = _push(class_bytes, j2) + if value is None or j3 >= len(class_bytes) or class_bytes[j3] != 0x54: + break + values[index] = value & 0xFF + j = j3 + 1 + if len(values) >= minimum and set(values) == set(range(len(values))): + out.append(bytes(values[k] for k in range(len(values)))) + i = j + else: + i += 1 + return out + + +def candidates(jar_path: Path) -> dict[bytes, list[str]]: + """Every distinct long ``byte[]`` literal in the jar, and where it is.""" + found: dict[bytes, list[str]] = {} + with zipfile.ZipFile(jar_path) as jar: + for entry in jar.namelist(): + if not entry.endswith(".class"): + continue + for array in byte_array_literals(jar.read(entry)): + found.setdefault(array, []).append(entry) + return found + + +# --------------------------------------------------------------------------- +# verifying a candidate against your own install +# --------------------------------------------------------------------------- + + +def verify_nitro_image(key: bytes, image: Path) -> bool: + """Decrypt one member and look for its own name in the plaintext. + + Checked this way rather than by decompiling, so the test does not also + assert the nitrobin format version: a 5.1.9 image decrypts correctly under + this key but does not parse with a decompiler written for 6.x. The member + name is stored near the start of every entry, and a wrong key would have to + produce it by chance out of high-entropy noise. + """ + with zipfile.ZipFile(image) as archive: + name = archive.namelist()[0] + raw = archive.read(name) + stem = Path(name).stem.encode("utf-8") + return stem in _strip(raw, key)[:256] + + +def verify_nitro_std(key: bytes, std: Path) -> bool: + """Decrypt one member and check it is Nitro source rather than noise.""" + with zipfile.ZipFile(std) as archive: + raw = archive.read(archive.namelist()[0]) + try: + text = _strip(raw, key).decode("utf-8") + except UnicodeDecodeError: + return False + return any(word in text for word in ("template ", "import ", "struct ", "static const")) + + +def verify_dag(key: bytes, document: Path) -> bool: + """Decrypt a ``0004`` document's metadata section and look for its fields. + + The header is 42 ASCII bytes; the metadata section follows, prefixed by a + version byte and a 16-byte IV. + """ + raw = document.read_bytes() + if not raw.startswith(b"BtWg"): + return False + body = raw[42:] + plain = dag_decrypt(body[17:], key, body[1:17]) + return b"device_uuid" in plain or b"meta" in plain + + +def _strip(raw: bytes, key: bytes) -> bytes: + """Split a stored member into IV and ciphertext, then decrypt. + + Both archives store ``[version][iv][ciphertext]``, and the IV is twice the + key length -- which is what the chain's ``iv_size`` field reports: 198 for + the 99-byte nitro-image key, 192 for the 96-byte nitro-std key. + """ + iv_size = len(key) * 2 + return dag_decrypt(raw[1 + iv_size :], key, raw[1 : 1 + iv_size]) + + +# --------------------------------------------------------------------------- +# locating the install +# --------------------------------------------------------------------------- + + +def find_jar(install: Path) -> Path: + for relative in JAR_RELATIVE: + candidate = install / relative + if candidate.is_file(): + return candidate + raise SystemExit(f"no bitwig.jar under {install}") + + +def find_library(install: Path) -> Path: + for relative in ("Contents/Resources/Library", "Library", "lib/bitwig/Library"): + candidate = install / relative + if candidate.is_dir(): + return candidate + raise SystemExit(f"no Library directory under {install}") + + +def a_factory_document(library: Path) -> Path | None: + for path in sorted((library / "devices").glob("*.bwdevice")): + return path + return None + + +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--install", type=Path, default=MAC_APP, help="Bitwig install root") + parser.add_argument("--jar", type=Path, help="bitwig.jar (default: inside --install)") + parser.add_argument("--hex", action="store_true", help="print the key material") + parser.add_argument("--write", action="store_true", help="write keys.json") + args = parser.parse_args() + + jar = args.jar or find_jar(args.install) + library = find_library(args.install) + image, std = library / "nitro-image", library / "nitro-std" + document = a_factory_document(library) + + print(f"scanning {jar}") + found = candidates(jar) + print(f"{len(found)} byte[] literal(s) of {MIN_KEY}+ bytes\n") + + keys: dict[str, bytes] = {} + for array, where in sorted(found.items(), key=lambda kv: len(kv[0])): + role = "unidentified" + if image.is_file() and verify_nitro_image(array, image): + role, keys["nitro_image"] = "nitro-image key", array + elif std.is_file() and verify_nitro_std(array, std): + role, keys["nitro_std"] = "nitro-std key", array + elif document is not None and verify_dag(array, document): + role, keys["dag"] = "Dag key (0004 documents)", array + digest = hashlib.sha256(array).hexdigest()[:12] + print(f" {len(array):>4} bytes sha256:{digest} {role}") + print(f" in {where[0]}") + if args.hex and role != "unidentified": + print(f" {array.hex()}") + + if not keys: + print("\nnothing verified. Check that --install points at a real Bitwig.") + return 1 + + print(f"\nverified {len(keys)} key(s): {', '.join(sorted(keys))}") + if args.write: + path = write_keys_file( + dag_key_hex=keys["dag"].hex() if "dag" in keys else None, + image_key_hex=keys["nitro_image"].hex() if "nitro_image" in keys else None, + ) + print(f"wrote {path}") + else: + print("re-run with --write to store them in keys.json") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/bitwig_nitro/cli/decrypt_std.py b/src/bitwig_nitro/cli/decrypt_std.py index 8781bb4..a590bb3 100644 --- a/src/bitwig_nitro/cli/decrypt_std.py +++ b/src/bitwig_nitro/cli/decrypt_std.py @@ -1,12 +1,15 @@ """``nitro-decrypt-std``: decrypt your own ``Library/nitro-std`` via Bitwig. -Unlike ``nitro-image`` (a self-inverse Dag/XOR cipher that decrypts fully -offline — see ``nitro-decrypt-corpus``), each ``nitro-std`` stdlib *source* -member is wrapped in a runtime PRNG stream cipher that cannot be reproduced -offline. Decryption therefore runs inside a live Bitwig JVM, via the bundled +Decryption runs inside a live Bitwig JVM, via the bundled ``BitwigNitroStdDump`` controller, which decrypts the whole archive to a tree on your disk and writes a manifest. +``nitro-std`` uses the same self-inverse Dag/XOR cipher as ``nitro-image``, +under a 96-byte key with a 192-byte IV, so it decrypts offline too once you +have that key — see ``examples/extract_keys_from_jar.py`` and +``docs/KEY_EXTRACTION.md``. This command predates that finding and still takes +the live route. + Flow (mirrors ``nitro-extract-keys --live``):: nitro-decrypt-std --install-controller # copy the controller in From 7873b782acc316282ce2608a0ab08c45cb67cd6f Mon Sep 17 00:00:00 2001 From: Sergey Ukolov Date: Fri, 18 Sep 2026 15:19:15 +0300 Subject: [PATCH 2/2] perf(keys): only walk the classes that can hold an array literal Scanning the jar took 3.1s, nearly all of it a Python loop over the bytes of all 31,476 classes. A class with no `newarray byte` in it cannot hold an array literal, and that check is a substring search rather than a loop, so it runs first: 281 classes walked instead of 31,476, and the whole scan drops to 0.8s. No candidate can hide behind it. The pattern the walk looks for begins with those two bytes by definition, so the filter has no false negatives. Output is byte-identical on 5.1.9 and 6.1: the same five arrays, the same three keys, the same two unidentified. Every class is still looked at rather than narrowing to the packages the keys occupy today. The three sit in three different places, and a package filter would quietly stop finding them the release one moves. Also corrects the class count. The earlier figure of ~17,000 came from unpacking the jar to disk on a case-insensitive filesystem, which silently collapses Bitwig's obfuscated names: `Aa.class` and `aA.class` are two classes and become one file. The jar holds 31,476; unzip on macOS leaves 17,290 of them. The survey itself was read from the archive rather than from the unpacked tree, so its result was never affected - five arrays is still five arrays - but the number quoted beside it was wrong, and a reader unpacking the jar to check would have hit the same trap. That is now written down where it bites. --- CHANGELOG.md | 13 +++++++++---- docs/KEY_EXTRACTION.md | 10 ++++++++-- examples/extract_keys_from_jar.py | 24 ++++++++++++++++++++---- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f22902a..d02e37b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). contiguous bytes, which is why searching the jars for them finds nothing in any encoding. The script reads the arrays out of the bytecode and verifies each candidate against your own installed archives before reporting it; - nothing is printed as hex unless you pass `--hex`. Ships no keys. Verified on - Bitwig 5.1.9 and 6.1, which carry byte-identical values for all three: - 517/517 `nitro-image` members decompile on 6.1, 121/121 `nitro-std` members - decrypt to valid Nitro source, and factory `0004` document metadata parses. + nothing is printed as hex unless you pass `--hex`. Every class is looked at, + but only the 281 of 31,476 that contain a `newarray byte` opcode are walked, + which no candidate can hide from: the pattern being matched starts with those + two bytes. Ships no keys. Verified on Bitwig 5.1.9 and 6.1, which carry + byte-identical values for all three: 517/517 `nitro-image` members decompile + on 6.1, 121/121 `nitro-std` members decrypt to valid Nitro source, and factory + `0004` document metadata parses. Note that this supersedes the 0.1.1 removal + of `--from-jar`: a static route does exist, it was simply not a contiguous + byte string. ### Changed diff --git a/docs/KEY_EXTRACTION.md b/docs/KEY_EXTRACTION.md index 01be1a2..9cedb21 100644 --- a/docs/KEY_EXTRACTION.md +++ b/docs/KEY_EXTRACTION.md @@ -124,8 +124,14 @@ with the structure its container promises. Candidates that verify against nothing are reported as unidentified rather than guessed at. There are very few candidates to begin with. A 6.1 jar holds five arrays of -48+ bytes across roughly 17,000 classes: the three keys, one in the Skia -shader filesystem, and one unrelated 257-byte table. +48+ bytes across its 31,476 classes: the three keys, one in the Skia shader +filesystem, and one unrelated 257-byte table. + +A note if you unpack the jar to look around: do not trust a directory listing +on a case-insensitive filesystem. Bitwig's obfuscated names differ only by +case — `Aa.class` and `aA.class` are two classes — so `unzip` on macOS or +Windows silently collapses them and leaves you with 17,290 of the 31,476. Read +the archive directly, as the script does. Nothing is printed as hex unless you pass `--hex`. diff --git a/examples/extract_keys_from_jar.py b/examples/extract_keys_from_jar.py index 2199043..608af92 100644 --- a/examples/extract_keys_from_jar.py +++ b/examples/extract_keys_from_jar.py @@ -2,7 +2,7 @@ """Recover the cipher keys statically from your own ``bitwig.jar``. All three keys are ordinary ``byte[]`` literals in the jar. They are built by -bytecode -- ``newarray byte`` followed by one ``bastore`` per element -- so the +bytecode — ``newarray byte`` followed by one ``bastore`` per element — so the bytes are interleaved with opcodes and never appear as a contiguous run. A ``grep`` over the jar finds nothing in any encoding; reading the array out of the bytecode finds all three. @@ -38,6 +38,9 @@ # shorter with "Key too short". MIN_KEY = 48 +# ``newarray`` with the operand for ``byte``. Every array literal starts here. +NEWARRAY_BYTE = b"\xbc\x08" + # --------------------------------------------------------------------------- # reading byte[] literals out of bytecode @@ -88,13 +91,26 @@ def byte_array_literals(class_bytes: bytes, minimum: int = MIN_KEY) -> list[byte def candidates(jar_path: Path) -> dict[bytes, list[str]]: - """Every distinct long ``byte[]`` literal in the jar, and where it is.""" + """Every distinct long ``byte[]`` literal in the jar, and where it is. + + Every class is looked at — the keys sit in three different packages, and + narrowing to the ones they occupy today would quietly stop finding them the + release they move. What is skipped is the *scan*: a class with no + ``newarray byte`` in it cannot hold an array literal, and that check is a + substring search rather than a Python loop over every byte. On a 6.1 jar + that is 281 classes walked instead of 31,476, and no candidate can be + missed by it, because the pattern being looked for contains those two bytes + by definition. + """ found: dict[bytes, list[str]] = {} with zipfile.ZipFile(jar_path) as jar: for entry in jar.namelist(): if not entry.endswith(".class"): continue - for array in byte_array_literals(jar.read(entry)): + class_bytes = jar.read(entry) + if NEWARRAY_BYTE not in class_bytes: + continue + for array in byte_array_literals(class_bytes): found.setdefault(array, []).append(entry) return found @@ -149,7 +165,7 @@ def _strip(raw: bytes, key: bytes) -> bytes: """Split a stored member into IV and ciphertext, then decrypt. Both archives store ``[version][iv][ciphertext]``, and the IV is twice the - key length -- which is what the chain's ``iv_size`` field reports: 198 for + key length — which is what the chain's ``iv_size`` field reports: 198 for the 99-byte nitro-image key, 192 for the 96-byte nitro-std key. """ iv_size = len(key) * 2