Decode Console

Decrypt a link

Paste a happ://crypt… deep link and fire the decoder. Everything runs locally in this tab — no payload leaves your browser.

Ctrl + Enter to run

Reverse Engineering happ:// — Decryption Dossier

This write-up summarizes how the Happ link formats were reverse- engineered and how the browser decrypts each generation locally.

5 generations 36 crypt5 marker keys Payload-derived marker Local browser runtime

Background: The happ:// URI Scheme

The happ:// URI scheme is a custom deep-link protocol used by a mobile application to wrap destination URLs in encrypted payloads. Instead of exposing a plain https:// link, the app emits a payload that only the bundled client logic can decode. This project reproduces that decode path entirely in the browser — in JavaScript for the legacy RSA formats, and by emulating the original native library for crypt5 — across every currently bundled generation: crypt through crypt5.

Generations 1–4: Hardcoded RSA Keys

The first four generations are direct RSA-PKCS1v15 wrappers. A target URL is encrypted, split into fixed-size blocks matching the key modulus, and Base64-encoded:

happ://crypt/<base64( RSA_block1 || RSA_block2 || ... )>
SchemeKey sizeBlock size (bytes)Algorithm
cryptRSA-1024128PKCS1v15
crypt2RSA-4096512PKCS1v15
crypt3RSA-4096512PKCS1v15
crypt4RSA-4096512PKCS1v15

Decryption is symmetric: split the Base64 payload back into RSA-sized blocks, decrypt each block independently, and concatenate the plaintext fragments to recover the final URL. The implementation accepts both standard and URL-safe Base64 alphabets, so the browser path normalizes - and _ and restores missing padding before decoding.

Each legacy private key is embedded directly in the application bytecode.

Generation 5: Native Keying via In-Browser Emulation

crypt5 is the format that required actual reverse engineering. Its core — a marker-based RSA key lookup, a raw RSA-4096 decrypt, a proprietary 32-byte key derivation, and ChaCha20-Poly1305 for the URL ciphertext — lives inside a native Android library, liberror-code.so (ARM64). That library is deliberately obfuscated and anti-tamper hardened, and its internals shift between app versions, which is what kept breaking earlier hand-ported decoders.

Rather than re-implement that moving target, the app runs the real library in the browser on a CPU emulator (unicorn.js, Unicorn 2.1.4 shipped as self-contained asm.js). A small JavaScript host loads the .so, supplies a mock JNI environment and libc, and calls the same entry point the app uses (jniGetErrorMessageFromString2). The cryptography is the library's own code, executed instruction-for-instruction — so it stays correct even where the logic is intentionally unreadable.

The JavaScript-Visible Pipeline

Around the native core, only two reversible string transforms are applied in JS. m4831f permutes each 6-character block by the index pattern [1,3,5,0,2,4] (a 1–5 char tail passes through); m4842j swaps every adjacent character pair (ABCD → BADC). Everything between them — the wire layout, nonce, RSA field, and AEAD — is parsed and decrypted inside the emulated library, so the host never hand-parses the payload:

crypt5 URI payload ├─ strip happ://crypt5/ ├─ nativeIn = m4831f(payload) // 6-char block shuffle [1,3,5,0,2,4] ├─ emulate liberror-code.so on the nativeIn bytes: │ ├─ recover the 8-char marker, look up the RSA key (JNI getHelp) │ ├─ raw RSA-4096 decrypt │ ├─ derive the 32-byte ChaCha20 key │ └─ ChaCha20-Poly1305 decrypt │ └─ m4842j-obfuscated Base64 of the URL ├─ swapPairs(out) // m4842j: ABCD → BADC └─ base64-decode → final URL

Marker → Key, Exactly As On-Device

The library does not hold its RSA keys; on the phone it asks the Java layer for them through a JNI callback. The emulator answers that same callback (getHelp(marker)) from a bundled 36-entry table, public/data/keytable.json, mapping each marker to a Base64 PKCS#8 key. The key therefore enters the library by the identical path it does in the real app — no offline key reconstruction or PKCS#8 rebuilding is involved anymore.

Performance: Skipping the RSA Hot Spot

A full emulated decrypt is ~217M guest instructions, almost all of them the RSA-4096 modular exponentiation, which takes roughly 9 s on the asm.js interpreter. The host intercepts OpenSSL's Montgomery modexp, BN_mod_exp_mont(rr, a, p, m, …), reads the (base, exp, modulus) big integers at the call boundary, computes ap mod m with native JavaScript BigInt, writes the result back, and returns — replacing all four RSA exponentiations and cutting the run to about 2 s with identical output.

Because OpenSSL is statically linked and stripped, that function has no symbol; it was located by call-graph profiling and verified by checking, per call, that the JavaScript result matches the value the native code writes. The interception is trusted only when the function's prologue bytes match and each call carries a valid RSA-shaped argument triple; on any mismatch — for instance an updated .so — the app simply runs the library unmodified (slower, but never wrong).

Browser Implementation

The complete decode path — including the native key derivation and AEAD — runs locally in the browser tab, with no server-side help.