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
reimplements that client-side logic in JavaScript and supports 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: Payload Layout And Native Keying
crypt5 is the format that required actual reverse
engineering. Its decryption path combines a custom wire format,
marker-based RSA key lookup, RSA-PKCS1v15 for key unwrapping, and
ChaCha20-Poly1305 for the final URL ciphertext.
Payload Obfuscation: The Block-Pair Swap
The whole payload is shipped through a single fixed permutation we
call the block-pair swap. Every full 4-character block rotates
by two characters (ABCD → CDAB); any 1–3 byte tail passes
through unchanged. The transform is its own inverse, so encoding and
decoding use the same operation. Decoding the payload starts with one
invocation across the entire string:
shuffled = blockPairSwap(payload)
Payload Structure
After that single shuffle, the structure of shuffled falls
out by direct slicing. There is no per-field swap and no carry between
adjacent fields:
| Offset | Length | Field |
|---|---|---|
| 0 | 4 | First half of the 8-character marker (RSA key id) |
| 4 | 12 | ChaCha20 nonce |
| 16 | d ≥ 1 | ASCII decimal digits — length N of the URL segment |
| 16+d | 1 | Internal marker byte (discarded) |
| 17+d | N | URL ciphertext, Base64 |
| 17+d+N | varies | RSA ciphertext, Base64 — fills the gap to the last 4 chars (684 for RSA-4096, 344 for RSA-2048, etc.) |
| end−4 | 4 | Second half of the marker |
Parsing is a straightforward sequence:
shuffled = blockPairSwap(payload)
marker = shuffled[:4] + shuffled[-4:]
body = shuffled[4:-4]
nonce = body[:12]
N, d = parse_leading_decimal(body[12:])
packed = body[12 + d:]
url_b64 = packed[1 : 1 + N] # skip 1 marker byte
enc_str = packed[1 + N:] # RSA Base64 fills the remainder
Reading N from a digit run rather than a fixed-size header
block lets any size of URL segment ride through, including 4-digit
values. Sizing the RSA segment as rest of body rather than a
hardcoded 684 chars keeps the parser correct if a key uses a different
modulus.
Marker Derivation
The 8-character marker is the first and last quartets of the shuffled string concatenated together. It is used directly as a key into the bundled 34-entry RSA key table:
marker = shuffled[:4] + shuffled[-4:]
rsa_key = crypt5_keys[marker]
Key Expansion And Reconstruction
The RSA material referenced by the app is stored in an obfuscated form.
The smali strings are not complete PKCS#8 private keys on their own; the
native path expands them via sub_17b1d0 before the RSA
engine can consume them.
For the web build, that native expansion is performed offline. The raw
strings expose the RSA-CRT values q, dp,
dq, and qi. Recovering the missing prime
p from those fragments makes it possible to rebuild the full
RSA-4096 private key, serialize it as PKCS#8, and bundle the result as a
static JSON asset for the app's 34-key compatibility snapshot.
The Full crypt5 Decryption Pipeline
Two different string transforms appear in this pipeline and must not be
confused. blockPairSwap operates on each 4-character block and
swaps the two 2-character halves (ABCD → CDAB); it is
applied once to the whole payload to recover the wire layout.
swapPairs operates over the full string and swaps every
adjacent pair (ABCD → BADC); it is applied to the RSA
plaintext and the ChaCha output before Base64 decode.
Browser Implementation
The browser implementation uses two cryptographic dependencies:
- RSA-PKCS1v15 decryption uses node-forge, because the Web Crypto API does not expose PKCS1v15 RSA decryption.
- ChaCha20-Poly1305 uses @noble/ciphers.
The 34 expanded PKCS#8 keys, indexed by marker, are bundled in
public/data/expanded_rsa_keys.json, so the complete decrypt
path runs locally in the browser without APK assets, native bindings,
or server-side help.