____ _ _______ __
/ __ \ | /| / / __/ |/ /
/ /_/ / |/ |/ / _// /
\____/|__/|__/___/_||_/
owenclave / app/src/main/java/io/nekohasekai/sagernet/ktx/HappLink.kt
package io.nekohasekai.sagernet.ktx
import com.google.gson.Gson
import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.interfaces.RSAPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Decryption for `happ://crypt`, `crypt2`, `crypt3`, `crypt4` and `crypt5`
* deep links, as used by the Happ Android client (`su.happ.proxyutility`) to
* hide a subscription's real URL behind an RSA / ChaCha20-Poly1305-wrapped
* opaque token.
*
* The scheme was recovered by decompiling Happ v4.3.0 (jadx) and
* cross-checking the result against the independent, already-public
* write-ups of the same format:
* - https://github.com/LeeeeT/happ-decryptor (JS reference implementation
* + the 36 published `crypt5` marker keys bundled in `happ-keys.json`)
* - https://github.com/amurcanov/happ-decrypt-universal (independent
* confirmation of the RSA -> ChaCha20-Poly1305 pipeline)
*
* Deliberately pure JVM crypto (`java.security` / `javax.crypto`) with a
* single small-footprint dependency (Gson, already used elsewhere in this
* app) so it needs no network access, no native library, and no Android
* `Context` -- it is fully unit-testable on the plain JVM.
*/
object HappLink {
private val CRYPT_PREFIXES = listOf("crypt5/", "crypt4/", "crypt3/", "crypt2/", "crypt/")
/** True for any `happ://crypt*` link this class knows how to attempt decrypting. */
fun isHappCryptLink(link: String): Boolean {
if (!link.startsWith("happ://", ignoreCase = true)) return false
val path = link.substring("happ://".length)
return CRYPT_PREFIXES.any { path.startsWith(it, ignoreCase = true) }
}
/**
* Returns the plain URL/text behind a `happ://crypt*` link, or `null` if
* [link] doesn't look like one, its marker/key isn't recognised, or the
* payload fails to decrypt/authenticate. Never throws.
*/
fun decrypt(link: String): String? {
if (!link.startsWith("happ://", ignoreCase = true)) return null
val path = link.substring("happ://".length)
return try {
when {
path.startsWith("crypt5/", ignoreCase = true) -> decryptCrypt5(path.substring(7))
path.startsWith("crypt4/", ignoreCase = true) -> decryptLegacy(3, keys.crypt4, path.substring(7))
path.startsWith("crypt3/", ignoreCase = true) -> decryptLegacy(2, keys.crypt3, path.substring(7))
path.startsWith("crypt2/", ignoreCase = true) -> decryptLegacy(1, keys.crypt2, path.substring(7))
path.startsWith("crypt/", ignoreCase = true) -> decryptLegacy(0, keys.crypt, path.substring(6))
else -> null
}
} catch (e: Exception) {
null
}
}
// ---------------------------------------------------------------------
// Embedded key material
// ---------------------------------------------------------------------
/** `crypt`/`crypt2`/`crypt3`/`crypt4` are PKCS#1 RSA keys (one each); `crypt5` is 36 PKCS#8 keys keyed by an 8-char "marker". */
private data class KeyBundle(
val crypt: String,
val crypt2: String,
val crypt3: String,
val crypt4: String,
val crypt5: Map,
)
private val keys: KeyBundle by lazy {
val stream = HappLink::class.java.getResourceAsStream("/happ/happ-keys.json")
?: error("happ/happ-keys.json resource missing from classpath")
stream.use { input ->
Gson().fromJson(input.reader(StandardCharsets.UTF_8), KeyBundle::class.java)
}
}
private val crypt5KeyCache = ConcurrentHashMap()
private val legacyKeyCache = ConcurrentHashMap()
private fun crypt5Key(marker: String): PrivateKey = crypt5KeyCache.getOrPut(marker) {
val b64 = keys.crypt5[marker] ?: throw IllegalArgumentException("unknown crypt5 marker: $marker")
val der = Base64.getMimeDecoder().decode(b64)
KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der))
}
private fun legacyKey(ordinal: Int, keyB64: String): PrivateKey = legacyKeyCache.getOrPut(ordinal) {
val pkcs1Der = Base64.getMimeDecoder().decode(keyB64)
val pkcs8Der = wrapPkcs1ToPkcs8(pkcs1Der)
KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8Der))
}
// ---------------------------------------------------------------------
// crypt5: RSA(marker key) -> ChaCha20-Poly1305, wrapped in two byte
// shuffles recovered from the Java side of the Happ app.
//
// swapBlockHalves(payload) // every 4 bytes: ABCD -> CDAB
// marker = shuffled[0:4] + shuffled[-4:] // selects the RSA key
// body = shuffled[4:-4]
// body = nonce(12) [+ salt(8) at offset 14, "salted" layout only]
// + ASCII-decimal segment length
// + 0x01
// + Base64(ChaCha20-Poly1305 ciphertext)
// + Base64(RSA-PKCS1v1.5(ChaCha20 key))
// RSA plaintext and the ChaCha20-Poly1305 plaintext are themselves
// Base64 text with every adjacent character pair swapped.
// ---------------------------------------------------------------------
private fun decryptCrypt5(payload: String): String {
val shuffled = swapBlockHalves(payload.toByteArray(StandardCharsets.UTF_8))
require(shuffled.size >= 8) { "crypt5 payload is too short" }
val markerBytes = ByteArray(8)
System.arraycopy(shuffled, 0, markerBytes, 0, 4)
System.arraycopy(shuffled, shuffled.size - 4, markerBytes, 4, 4)
val marker = String(markerBytes, StandardCharsets.US_ASCII)
val privateKey = crypt5Key(marker)
val body = shuffled.copyOfRange(4, shuffled.size - 4)
val preferSalted = body.size > 12 && !isAsciiDigit(body[12])
var firstError: Exception? = null
for (salted in listOf(preferSalted, !preferSalted)) {
try {
return decryptCrypt5Body(body, privateKey, salted)
} catch (e: Exception) {
if (firstError == null) firstError = e
}
}
throw firstError ?: IllegalStateException("crypt5 decryption failed")
}
private fun decryptCrypt5Body(body: ByteArray, privateKey: PrivateKey, salted: Boolean): String {
require(body.size >= 13) { "crypt5 body is too short" }
val nonce = body.copyOfRange(0, 12)
var salt: ByteArray? = null
var lengthStart = 12
if (salted) {
require(body.size >= 22) { "crypt5 salted header is too short" }
salt = body.copyOfRange(14, 22)
lengthStart = 22
}
var lengthEnd = lengthStart
while (lengthEnd < body.size && isAsciiDigit(body[lengthEnd])) lengthEnd++
require(lengthEnd != lengthStart) { "crypt5 segment length is missing" }
val segmentLength = String(body, lengthStart, lengthEnd - lengthStart, StandardCharsets.US_ASCII).toInt()
val packed = body.copyOfRange(lengthEnd, body.size)
require(packed.isNotEmpty() && segmentLength <= packed.size - 1) { "crypt5 segment is truncated" }
val encryptedUrlAscii = packed.copyOfRange(1, segmentLength + 1)
val rsaCiphertextAscii = packed.copyOfRange(segmentLength + 1, packed.size)
val rsaCiphertext = decodeBase64Ascii(rsaCiphertextAscii)
val rsaPlaintext = rsaPkcs1Decrypt(privateKey, rsaCiphertext)
val rsaValue = decodeBase64Ascii(swapAdjacent(rsaPlaintext))
require(rsaValue.size == 32) { "unexpected crypt5 key length: ${rsaValue.size}" }
val chachaKey = rsaValue.copyOf()
if (salt != null) {
for (i in chachaKey.indices) {
chachaKey[i] = (chachaKey[i].toInt() xor salt[i % salt.size].toInt()).toByte()
}
}
val ciphertext = decodeBase64Ascii(encryptedUrlAscii)
val intermediate = chaCha20Poly1305Decrypt(chachaKey, nonce, ciphertext)
val plaintext = decodeBase64Ascii(swapAdjacent(intermediate))
return String(plaintext, StandardCharsets.UTF_8)
}
// ---------------------------------------------------------------------
// crypt / crypt2 / crypt3 / crypt4: plain Base64 payload, split into
// RSA-modulus-sized chunks, each decrypted independently with RSA
// PKCS#1 v1.5 (no chaining/IV).
// ---------------------------------------------------------------------
private fun decryptLegacy(ordinal: Int, keyB64: String, payload: String): String {
val privateKey = legacyKey(ordinal, keyB64)
val keySize = ((privateKey as RSAPrivateKey).modulus.bitLength() + 7) / 8
val cipherBytes = decodeBase64Url(payload)
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, privateKey)
val out = ByteArrayOutputStream()
var i = 0
while (i < cipherBytes.size) {
val end = minOf(i + keySize, cipherBytes.size)
out.writeBytes(cipher.doFinal(cipherBytes, i, end - i))
i += keySize
}
return String(out.toByteArray(), StandardCharsets.UTF_8)
}
// ---------------------------------------------------------------------
// Shared byte-shuffle / Base64 / cipher helpers
// ---------------------------------------------------------------------
private fun isAsciiDigit(b: Byte) = b in '0'.code.toByte()..'9'.code.toByte()
/** Swaps every adjacent byte pair: `b0 b1 b2 b3 ... -> b1 b0 b3 b2 ...`. Self-inverse. */
private fun swapAdjacent(bytes: ByteArray): ByteArray {
val out = bytes.copyOf()
var i = 0
while (i + 1 < out.size) {
val tmp = out[i]
out[i] = out[i + 1]
out[i + 1] = tmp
i += 2
}
return out
}
/** Swaps the two halves of every complete 4-byte block: `A B C D -> C D A B`. Self-inverse. */
private fun swapBlockHalves(bytes: ByteArray): ByteArray {
val out = bytes.copyOf()
val full = out.size - out.size % 4
var i = 0
while (i < full) {
val a = out[i]
val b = out[i + 1]
out[i] = out[i + 2]
out[i + 1] = out[i + 3]
out[i + 2] = a
out[i + 3] = b
i += 4
}
return out
}
/** Decodes a Base64 string that may use the URL-safe alphabet and/or be missing `=` padding. */
private fun decodeBase64Url(value: String): ByteArray {
var clean = value.replace(Regex("\\s"), "").replace('-', '+').replace('_', '/')
clean = clean.trimEnd('=')
val pad = (4 - clean.length % 4) % 4
return Base64.getDecoder().decode(clean + "=".repeat(pad))
}
private fun decodeBase64Ascii(bytes: ByteArray): ByteArray =
decodeBase64Url(String(bytes, StandardCharsets.ISO_8859_1))
private fun rsaPkcs1Decrypt(key: PrivateKey, ciphertext: ByteArray): ByteArray {
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, key)
return cipher.doFinal(ciphertext)
}
private fun chaCha20Poly1305Decrypt(key: ByteArray, nonce: ByteArray, ciphertextAndTag: ByteArray): ByteArray {
val cipher = Cipher.getInstance("ChaCha20-Poly1305")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(ciphertextAndTag)
}
// ---------------------------------------------------------------------
// PKCS#1 -> PKCS#8 DER wrapping: java.security.KeyFactory only accepts
// PKCS#8, but the legacy crypt/crypt2/crypt3/crypt4 keys are raw PKCS#1.
//
// PrivateKeyInfo ::= SEQUENCE {
// version INTEGER (0),
// privateKeyAlgorithm AlgorithmIdentifier { rsaEncryption, NULL },
// privateKey OCTET STRING (the PKCS#1 DER bytes)
// }
// ---------------------------------------------------------------------
// SEQUENCE { OID 1.2.840.113549.1.1.1 (rsaEncryption), NULL }
private val RSA_ALGORITHM_ID = byteArrayOf(
0x30, 0x0D,
0x06, 0x09, 0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x01,
0x05, 0x00,
)
private fun wrapPkcs1ToPkcs8(pkcs1Der: ByteArray): ByteArray {
val version = byteArrayOf(0x02, 0x01, 0x00)
val privateKeyOctetString = derTlv(0x04, pkcs1Der)
val body = version + RSA_ALGORITHM_ID + privateKeyOctetString
return derTlv(0x30, body)
}
private fun derTlv(tag: Int, content: ByteArray): ByteArray {
val length = derLength(content.size)
return byteArrayOf(tag.toByte()) + length + content
}
private fun derLength(len: Int): ByteArray {
if (len < 0x80) return byteArrayOf(len.toByte())
var numBytes = 1
var tmp = len
while (true) {
tmp = tmp shr 8
if (tmp == 0) break
numBytes++
}
val out = ByteArray(1 + numBytes)
out[0] = (0x80 or numBytes).toByte()
var l = len
for (i in numBytes downTo 1) {
out[i] = (l and 0xFF).toByte()
l = l shr 8
}
return out
}
}