Files
jellyfin-plugin-floatplane/doc/research/reference-sources/fn_DPoPManager.kt
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

235 lines
8.4 KiB
Kotlin

package com.coulterpeterson.floatnative.data
import android.content.Context
import android.util.Base64
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.UUID
class DPoPManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"floatnative_dpop_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
companion object {
private const val KEY_PRIVATE_KEY = "dpop_private_key"
private const val KEY_PUBLIC_KEY = "dpop_public_key"
private const val KEY_TIME_OFFSET = "dpop_time_offset"
}
// Time offset in seconds (Service Time - Local Time)
var timeOffsetSeconds: Long = prefs.getLong(KEY_TIME_OFFSET, 0L)
set(value) {
field = value
prefs.edit().putLong(KEY_TIME_OFFSET, value).apply()
}
private var keyPair: KeyPair? = null
@Synchronized
private fun getOrGenerateKeyPair(): KeyPair {
if (keyPair != null) return keyPair!!
// Try to load
val privString = prefs.getString(KEY_PRIVATE_KEY, null)
val pubString = prefs.getString(KEY_PUBLIC_KEY, null)
if (privString != null && pubString != null) {
try {
val privBytes = Base64.decode(privString, Base64.NO_WRAP)
val pubBytes = Base64.decode(pubString, Base64.NO_WRAP)
val kf = KeyFactory.getInstance("EC")
val privSpec = PKCS8EncodedKeySpec(privBytes)
val pubSpec = X509EncodedKeySpec(pubBytes)
val privateKey = kf.generatePrivate(privSpec)
val publicKey = kf.generatePublic(pubSpec)
keyPair = KeyPair(publicKey, privateKey)
return keyPair!!
} catch (e: Exception) {
e.printStackTrace()
}
}
// Generate new
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1")) // P-256
val map = kpg.generateKeyPair()
// Save
val privEnc = Base64.encodeToString(map.private.encoded, Base64.NO_WRAP)
val pubEnc = Base64.encodeToString(map.public.encoded, Base64.NO_WRAP)
prefs.edit()
.putString(KEY_PRIVATE_KEY, privEnc)
.putString(KEY_PUBLIC_KEY, pubEnc)
.apply()
keyPair = map
return map
}
fun generateProof(httpMethod: String, httpUrl: String, accessToken: String? = null): String {
val keys = getOrGenerateKeyPair()
val publicKey = keys.public as ECPublicKey
// 1. JWK Header
// We need to construct a JWK manually from the public key parameters (x, y)
// This is a bit tedious in Java/Kotlin without a library like Nimbus, but feasible.
// Or we can just include the "jwk" claim with basic kty/crv/x/y.
// Get X and Y coordinates from EC Public Key
val w = publicKey.w
val xBytes = toByteArray(w.affineX, 32)
val yBytes = toByteArray(w.affineY, 32)
val jwk = JSONObject()
jwk.put("kty", "EC")
jwk.put("crv", "P-256")
jwk.put("x", base64UrlEncode(xBytes))
jwk.put("y", base64UrlEncode(yBytes))
val header = JSONObject()
header.put("typ", "dpop+jwt")
header.put("alg", "ES256")
header.put("jwk", jwk)
// 2. Payload
// Strip query params from URL
var htu = httpUrl
if (httpUrl.contains("?")) {
htu = httpUrl.split("?")[0]
}
val payload = JSONObject()
payload.put("iat", (System.currentTimeMillis() / 1000) + timeOffsetSeconds)
payload.put("jti", UUID.randomUUID().toString())
payload.put("htm", httpMethod)
payload.put("htu", htu)
if (accessToken != null) {
// ath: base64url(sha256(access_token))
val md = MessageDigest.getInstance("SHA-256")
val hash = md.digest(accessToken.toByteArray(Charsets.UTF_8))
payload.put("ath", base64UrlEncode(hash))
}
// 3. Sign
val headerStr = base64UrlEncode(header.toString().toByteArray(Charsets.UTF_8))
val payloadStr = base64UrlEncode(payload.toString().toByteArray(Charsets.UTF_8))
val toSign = "$headerStr.$payloadStr"
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(keys.private)
signature.update(toSign.toByteArray(Charsets.UTF_8))
val sigBytes = signature.sign()
// Convert DER signature to JOSE (Client-side R+S concatenation) requires parsing DER.
// Android's typical crypto providers output DER.
// We need R and S as raw 32-byte integers concatenated used for JWT ES256.
val joseSig = derToJose(sigBytes)
return "$toSign.${base64UrlEncode(joseSig)}"
}
private fun base64UrlEncode(bytes: ByteArray): String {
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
private fun toByteArray(value: java.math.BigInteger, size: Int): ByteArray {
val array = value.toByteArray()
if (array.size == size) return array
// BigInteger might add a sign byte if MSB is set, or remove leading zeros.
// If > size, strip leading bytes (sign extension).
// If < size, pad with zeros.
if (array.size > size) {
return array.copyOfRange(array.size - size, array.size)
}
val padded = ByteArray(size)
System.arraycopy(array, 0, padded, size - array.size, array.size)
return padded
}
// Convert DER encoded signature (ASN.1) to Raw R|S concatenation (64 bytes)
private fun derToJose(der: ByteArray): ByteArray {
// DER Structure: 0x30 <len> 0x02 <lenR> <R> 0x02 <lenS> <S>
// Use a simple parser or generic ASN1 parser
// Assuming strictly correct DER from system provider
var pos = 0
if (der[pos++] != 0x30.toByte()) throw IllegalArgumentException("Not a DER sequence")
var len = der[pos++].toInt()
if (len < 0) { // multi-byte length, skip (unlikely for ECDSA P-256 sigs which are small)
// simplified for this context, usually < 127
}
// R
if (der[pos++] != 0x02.toByte()) throw IllegalArgumentException("Not an integer")
var rLen = der[pos++].toInt()
var rStart = pos
pos += rLen
// S
if (der[pos++] != 0x02.toByte()) throw IllegalArgumentException("Not an integer")
var sLen = der[pos++].toInt()
var sStart = pos
// Extract R and S, remove leading zero if present for unsigned BigInteger interpretation
val rBytes = Arrays_copyOfRange(der, rStart, rStart + rLen)
val sBytes = Arrays_copyOfRange(der, sStart, sStart + sLen)
// Normalize to 32 bytes
val rNorm = normalizeInt(rBytes)
val sNorm = normalizeInt(sBytes)
val result = ByteArray(64)
System.arraycopy(rNorm, 0, result, 0, 32)
System.arraycopy(sNorm, 0, result, 32, 32)
return result
}
private fun Arrays_copyOfRange(original: ByteArray, from: Int, to: Int): ByteArray {
val newLength = to - from
val copy = ByteArray(newLength)
System.arraycopy(original, from, copy, 0, newLength)
return copy
}
private fun normalizeInt(bytes: ByteArray): ByteArray {
var start = 0
// Skip leading zero if present (sign byte)
if (bytes.size > 32 && bytes[0] == 0.toByte()) {
start = 1
}
val len = bytes.size - start
val result = ByteArray(32)
val destPos = if (len < 32) 32 - len else 0
System.arraycopy(bytes, start, result, destPos, if (len > 32) 32 else len)
return result
}
}