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 34 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 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 || ... )>
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: 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:

OffsetLengthField
04First half of the 8-character marker (RSA key id)
412ChaCha20 nonce
16d ≥ 1ASCII decimal digits — length N of the URL segment
16+d1Internal marker byte (discarded)
17+dNURL ciphertext, Base64
17+d+NvariesRSA ciphertext, Base64 — fills the gap to the last 4 chars (684 for RSA-4096, 344 for RSA-2048, etc.)
end−44Second 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

crypt5 URI payload ├─ strip happ://crypt5/ ├─ shuffled = blockPairSwap(payload) ├─ marker = shuffled[:4] + shuffled[-4:] │ └─ 8-char RSA key id ├─ body = shuffled[4:-4] ├─ nonce = body[:12] │ └─ 12-byte ChaCha nonce ├─ N = parse leading decimal in body[12:] ├─ skip 1 marker byte ├─ url_b64 = next N chars │ └─ Base64 URL ciphertext ├─ enc_str = remaining body chars │ └─ Base64 RSA field (684 chars for RSA-4096) ├─ base64-decode → RSA ciphertext (512 bytes for RSA-4096) ├─ look marker up in the bundled 34-key table ├─ load bundled PKCS#8 RSA private key ├─ RSA-PKCS1v15.decrypt(ciphertext, key) │ └─ returns 44-char Base64 string ├─ swapPairs(rsa_plaintext) ├─ base64-decode → 32-byte ChaCha20 key ├─ base64-decode(url_b64) │ └─ yields ciphertext || 16-byte Poly1305 tag ├─ ChaCha20-Poly1305.decrypt(ciphertext, nonce, key) │ └─ returns intermediate string ├─ swapPairs(intermediate) └─ base64-decode → final URL

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:

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.