diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cf9b5..d02e37b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ 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`. 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 + +- **`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..9cedb21 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,50 @@ 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 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`. + +**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 +269,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..608af92 --- /dev/null +++ b/examples/extract_keys_from_jar.py @@ -0,0 +1,254 @@ +#!/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 + +# ``newarray`` with the operand for ``byte``. Every array literal starts here. +NEWARRAY_BYTE = b"\xbc\x08" + + +# --------------------------------------------------------------------------- +# 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. + + 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 + 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 + + +# --------------------------------------------------------------------------- +# 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