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.
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 || ... )>
| Scheme | Key size | Block size (bytes) | Algorithm |
|---|---|---|---|
| crypt | RSA-1024 | 128 | PKCS1v15 |
| crypt2 | RSA-4096 | 512 | PKCS1v15 |
| crypt3 | RSA-4096 | 512 | PKCS1v15 |
| crypt4 | RSA-4096 | 512 | PKCS1v15 |
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:
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
- Generations 1–4 decrypt directly in JavaScript with node-forge (RSA-PKCS1v15; the Web Crypto API does not expose PKCS1v15 decryption).
- Generation 5 runs
liberror-code.soon unicorn.js. The library and emulator are bundled inpublic/emu/, and the 36 marker keys inpublic/data/keytable.json.
The complete decode path — including the native key derivation and AEAD — runs locally in the browser tab, with no server-side help.