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 across every bundled
generation, crypt through crypt5. JavaScript is
the default for every known layout; native emulation is retained as a
crypt5 compatibility fallback.
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 |
To decrypt, the browser splits the Base64 payload back into RSA-sized
blocks, decrypts each block independently, and concatenates 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: Direct RSA + ChaCha20-Poly1305
crypt5 combines a payload-derived marker, RSA-4096 key
recovery, and authenticated ChaCha20-Poly1305 encryption. The browser
now parses and decrypts this format directly, avoiding ARM64 instruction
emulation during the normal path. Both the legacy body layout and the
newer salted/XOR layout are recognized automatically.
Direct Pipeline
First, each complete four-character block has its halves swapped
(ABCD → CDAB); a short tail is left unchanged. The first and
last four characters of that result form an eight-character marker that
selects a PKCS#8 private key. The middle body holds a 12-byte nonce,
optional two-byte tag and eight-byte salt, a decimal segment length, a
one-byte separator, the Base64 ChaCha ciphertext, and the Base64 RSA
ciphertext.
In the legacy layout, the decimal length follows the nonce immediately. The salted layout inserts the tag and salt first. The parser tries the layout indicated by the first post-nonce byte, then tries the other layout if any parse, RSA, authentication, or decode step fails.
Compatibility Fallback
Direct decryption and its PKCS#8 table are attempted first. If loading
that table, key lookup, parsing, RSA, authentication, or final decoding
fails, the app lazily loads the existing liberror-code.so
ARM64 emulator and retries the original native pipeline. Links handled
directly never fetch the native library or unicorn.js runtime and never
pay their CPU-emulation cost.
The direct path uses public/data/crypt5-keys.json for its 36
PKCS#8 keys. The fallback retains public/data/keytable.json,
whose marker values are injected through the native library's JNI
callback exactly as before. It also retains the native path's outer
transforms: a six-character [1,3,5,0,2,4] shuffle before
emulation, followed by an adjacent-pair swap and Base64 decode of the
emulator's output.
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 uses node-forge for RSA and noble-ciphers for ChaCha20-Poly1305.
liberror-code.soon unicorn.js is fetched only if the direct path fails.
Both decode paths run locally in the browser tab, with no server-side help.