add webpush support for manually added apps - now requires for the prism server to be pre-configured, unregister apps on delete or server change

This commit is contained in:
lone-cloud 2026-02-03 16:34:29 -08:00
parent 5184aefcaf
commit fda60f3c77
17 changed files with 543 additions and 29 deletions

View file

@ -98,6 +98,7 @@ android {
dependencies { dependencies {
implementation(libs.unifiedpush.distributor) implementation(libs.unifiedpush.distributor)
implementation(libs.unifiedpush.distributor.ui) implementation(libs.unifiedpush.distributor.ui)
implementation(libs.tink.android)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)

View file

@ -27,7 +27,7 @@ object Distributor : UnifiedPushDistributor() {
context, context,
ClientMessage.Register( ClientMessage.Register(
channelID = uuid, channelID = uuid,
key = null key = vapid
) )
) )
} }

View file

@ -0,0 +1,64 @@
package app.lonecloud.prism
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import androidx.core.content.edit
class EncryptionKeyStore(context: Context) {
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(
PREF_NAME,
Context.MODE_PRIVATE
)
fun storeKeys(
channelId: String,
privateKey: ByteArray,
authSecret: ByteArray,
publicKey: String
) {
sharedPreferences.edit {
putString(keyFor(channelId, KEY_PRIVATE), base64Encode(privateKey))
putString(keyFor(channelId, KEY_AUTH), base64Encode(authSecret))
putString(keyFor(channelId, KEY_PUBLIC), publicKey)
}
}
fun getKeys(channelId: String): Triple<ByteArray, ByteArray, String>? {
val privateKeyB64 = sharedPreferences.getString(keyFor(channelId, KEY_PRIVATE), null)
val authSecretB64 = sharedPreferences.getString(keyFor(channelId, KEY_AUTH), null)
val publicKey = sharedPreferences.getString(keyFor(channelId, KEY_PUBLIC), null)
return if (privateKeyB64 != null && authSecretB64 != null && publicKey != null) {
Triple(base64Decode(privateKeyB64), base64Decode(authSecretB64), publicKey)
} else {
null
}
}
fun deleteKeys(channelId: String) {
sharedPreferences.edit {
remove(keyFor(channelId, KEY_PRIVATE))
remove(keyFor(channelId, KEY_AUTH))
remove(keyFor(channelId, KEY_PUBLIC))
}
}
fun hasKeys(channelId: String): Boolean = sharedPreferences.contains(keyFor(channelId, KEY_PRIVATE)) &&
sharedPreferences.contains(keyFor(channelId, KEY_AUTH)) &&
sharedPreferences.contains(keyFor(channelId, KEY_PUBLIC))
private fun keyFor(channelId: String, suffix: String): String = "$PREF_PREFIX$channelId$suffix"
private fun base64Encode(data: ByteArray): String = Base64.encodeToString(data, Base64.NO_WRAP)
private fun base64Decode(data: String): ByteArray = Base64.decode(data, Base64.NO_WRAP)
companion object {
private const val PREF_NAME = "WebPushEncryptionKeys"
private const val PREF_PREFIX = "channel_"
private const val KEY_PRIVATE = "_private"
private const val KEY_AUTH = "_auth"
private const val KEY_PUBLIC = "_public"
}
}

View file

@ -1,4 +1,4 @@
package app.lonecloud.prism.sup package app.lonecloud.prism
import android.content.Context import android.content.Context
import android.util.Base64 import android.util.Base64
@ -32,7 +32,10 @@ object PrismServerClient {
fun registerApp( fun registerApp(
context: Context, context: Context,
appName: String, appName: String,
upEndpoint: String, webpushUrl: String,
vapidPrivateKey: String? = null,
p256dh: String? = null,
auth: String? = null,
onSuccess: () -> Unit = {}, onSuccess: () -> Unit = {},
onError: (String) -> Unit = {} onError: (String) -> Unit = {}
) { ) {
@ -49,17 +52,20 @@ object PrismServerClient {
try { try {
val json = JSONObject().apply { val json = JSONObject().apply {
put("appName", appName) put("appName", appName)
put("upEndpoint", upEndpoint) put("webpushUrl", webpushUrl)
vapidPrivateKey?.let { put("vapidPrivateKey", it) }
p256dh?.let { put("p256dh", it) }
auth?.let { put("auth", it) }
} }
val request = Request.Builder() val request = Request.Builder()
.url("$serverUrl/api/webhook/register") .url("$serverUrl/webpush/app")
.addHeader("Authorization", getAuthHeader(apiKey)) .addHeader("Authorization", getAuthHeader(apiKey))
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.post(json.toString().toRequestBody("application/json".toMediaType())) .post(json.toString().toRequestBody("application/json".toMediaType()))
.build() .build()
Log.d(TAG, "Registering app with sup server: $appName -> $upEndpoint") Log.d(TAG, "Registering app with Prism server: $appName -> $webpushUrl")
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
@ -96,7 +102,7 @@ object PrismServerClient {
val manualApps = apps.filter { it.description?.startsWith("target:") == true } val manualApps = apps.filter { it.description?.startsWith("target:") == true }
Log.d(TAG, "Registering ${manualApps.size} manual apps with sup server") Log.d(TAG, "Registering ${manualApps.size} manual apps with Prism server")
manualApps.forEach { app -> manualApps.forEach { app ->
app.endpoint?.let { endpoint -> app.endpoint?.let { endpoint ->
@ -114,6 +120,84 @@ object PrismServerClient {
} }
} }
fun deleteApp(
context: Context,
appName: String,
serverUrl: String? = null,
apiKey: String? = null,
onSuccess: () -> Unit = {},
onError: (String) -> Unit = {}
) {
val store = AppStore(context)
val url = serverUrl ?: store.prismServerUrl
val key = apiKey ?: store.prismApiKey
if (url.isNullOrBlank() || key.isNullOrBlank()) {
Log.d(TAG, "Prism server not configured, skipping deletion")
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
val request = Request.Builder()
.url("$url/webpush/app/$appName")
.addHeader("Authorization", getAuthHeader(key))
.delete()
.build()
Log.d(TAG, "Deleting app from Prism server: $appName")
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully deleted app: $appName")
onSuccess()
} else {
val error = "Failed to delete app: ${response.code} ${response.message}"
Log.e(TAG, error)
onError(error)
}
}
} catch (e: IOException) {
val error = "Error deleting app: ${e.message}"
Log.e(TAG, error, e)
onError(error)
}
}
}
fun deleteAllApps(
context: Context,
serverUrl: String? = null,
apiKey: String? = null
) {
val store = AppStore(context)
val url = serverUrl ?: store.prismServerUrl
val key = apiKey ?: store.prismApiKey
if (url.isNullOrBlank() || key.isNullOrBlank()) {
Log.d(TAG, "Prism server not configured, skipping bulk deletion")
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
val db = DatabaseFactory.getDb(context)
val apps = db.listApps()
val manualApps = apps.filter { it.description?.startsWith("target:") == true }
Log.d(TAG, "Deleting ${manualApps.size} manual apps from Prism server")
manualApps.forEach { app ->
val appName = app.title ?: app.packageName
deleteApp(context, appName, url, key)
}
} catch (e: IOException) {
Log.e(TAG, "Error during bulk deletion: ${e.message}", e)
}
}
}
fun testConnection( fun testConnection(
serverUrl: String, serverUrl: String,
apiKey: String, apiKey: String,

View file

@ -5,8 +5,10 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.AppStore import app.lonecloud.prism.AppStore
import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.Distributor import app.lonecloud.prism.Distributor
import app.lonecloud.prism.EventBus import app.lonecloud.prism.EventBus
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.services.FgService import app.lonecloud.prism.services.FgService
import app.lonecloud.prism.services.MigrationManager import app.lonecloud.prism.services.MigrationManager
import app.lonecloud.prism.services.RestartWorker import app.lonecloud.prism.services.RestartWorker
@ -52,8 +54,16 @@ class AppAction(private val action: Action) {
} }
private fun deleteRegistration(context: Context, action: Action.DeleteRegistration) { private fun deleteRegistration(context: Context, action: Action.DeleteRegistration) {
action.registrations.forEach { action.registrations.forEach { token ->
Distributor.deleteApp(context, it) val db = DatabaseFactory.getDb(context)
val dbApp = db.listApps().find { it.connectorToken == token }
if (dbApp?.description?.startsWith("target:") == true) {
val appName = dbApp.title ?: dbApp.packageName
PrismServerClient.deleteApp(context, appName)
}
Distributor.deleteApp(context, token)
} }
} }
@ -85,7 +95,7 @@ class AppAction(private val action: Action) {
} }
private fun registerPrismServer(context: Context) { private fun registerPrismServer(context: Context) {
app.lonecloud.prism.sup.PrismServerClient.registerAllApps(context) app.lonecloud.prism.PrismServerClient.registerAllApps(context)
} }
} }

View file

@ -12,13 +12,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.DatabaseFactory import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.EncryptionKeyStore
import app.lonecloud.prism.PrismServerClient
import app.lonecloud.prism.activities.ui.InstalledApp import app.lonecloud.prism.activities.ui.InstalledApp
import app.lonecloud.prism.activities.ui.MainUiState import app.lonecloud.prism.activities.ui.MainUiState
import app.lonecloud.prism.api.MessageSender import app.lonecloud.prism.api.MessageSender
import app.lonecloud.prism.api.data.ClientMessage import app.lonecloud.prism.api.data.ClientMessage
import app.lonecloud.prism.sup.PrismServerClient
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.VapidKeyGenerator
import app.lonecloud.prism.utils.WebPushEncryptionKeys
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.unifiedpush.android.distributor.ui.compose.BatteryOptimisationViewModel import org.unifiedpush.android.distributor.ui.compose.BatteryOptimisationViewModel
@ -32,7 +36,10 @@ class MainViewModel(
val application: Application? = null val application: Application? = null
) : ViewModel() { ) : ViewModel() {
constructor(application: Application) : this( constructor(application: Application) : this(
mainUiState = MainUiState(), mainUiState = MainUiState(
prismServerConfigured = !AppStore(application).prismServerUrl.isNullOrBlank() &&
!AppStore(application).prismApiKey.isNullOrBlank()
),
batteryOptimisationViewModel = BatteryOptimisationViewModel(application), batteryOptimisationViewModel = BatteryOptimisationViewModel(application),
registrationsViewModel = RegistrationsViewModel( registrationsViewModel = RegistrationsViewModel(
getRegistrationListState(application) getRegistrationListState(application)
@ -41,7 +48,10 @@ class MainViewModel(
) )
var mainUiState by mutableStateOf(mainUiState) var mainUiState by mutableStateOf(mainUiState)
private set
fun updatePrismServerConfigured(configured: Boolean) {
mainUiState = mainUiState.copy(prismServerConfigured = configured)
}
private var lastDebugClickTime by mutableLongStateOf(0L) private var lastDebugClickTime by mutableLongStateOf(0L)
@ -178,32 +188,34 @@ class MainViewModel(
Log.d(TAG, "Creating manual app: $name, token: $connectorToken") Log.d(TAG, "Creating manual app: $name, token: $connectorToken")
val vapidKeys = VapidKeyGenerator.generateKeyPair()
val encryptionKeys = WebPushEncryptionKeys.generateKeySet()
val keyStore = EncryptionKeyStore(app)
keyStore.storeKeys(channelId, encryptionKeys.privateKey, encryptionKeys.authBytes, encryptionKeys.p256dh)
val db = DatabaseFactory.getDb(app) val db = DatabaseFactory.getDb(app)
db.registerApp( db.registerApp(
app.packageName, app.packageName,
connectorToken, connectorToken,
channelId, channelId,
name, name,
null, vapidKeys.publicKey,
fullDescription fullDescription
) )
Log.d(TAG, "App registered in DB, sending register message to push server")
// Send register message directly to existing websocket connection
MessageSender.send( MessageSender.send(
app, app,
ClientMessage.Register( ClientMessage.Register(
channelID = channelId, channelID = channelId,
key = null key = vapidKeys.publicKey
) )
) )
refreshRegistrations() refreshRegistrations()
hideAddAppDialog() hideAddAppDialog()
// Wait for endpoint and register with sup server var endpoint: String?
var endpoint: String? = null
var attempts = 0 var attempts = 0
repeat(60) { repeat(60) {
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
@ -214,11 +226,13 @@ class MainViewModel(
} }
if (endpoint != null) { if (endpoint != null) {
Log.d(TAG, "Endpoint received after $attempts attempts: $endpoint") Log.d(TAG, "Endpoint received after $attempts attempts: $endpoint")
// Register with sup server
PrismServerClient.registerApp( PrismServerClient.registerApp(
app, app,
name, name,
endpoint!! endpoint!!,
vapidPrivateKey = vapidKeys.privateKey,
p256dh = encryptionKeys.p256dh,
auth = encryptionKeys.auth
) )
return@launch return@launch
} }

View file

@ -36,6 +36,8 @@ class SettingsViewModel(state: SettingsState, val application: Application? = nu
if (trimmedUrl.isNotBlank() && state.prismApiKey.isNotBlank()) { if (trimmedUrl.isNotBlank() && state.prismApiKey.isNotBlank()) {
publishAction(AppAction(AppAction.Action.RegisterPrismServer)) publishAction(AppAction(AppAction.Action.RegisterPrismServer))
} }
UiAction.publish(UiAction.Action.UpdatePrismServerConfigured)
} }
} }
} }
@ -50,6 +52,8 @@ class SettingsViewModel(state: SettingsState, val application: Application? = nu
if (state.prismServerUrl.isNotBlank() && trimmedKey.isNotBlank()) { if (state.prismServerUrl.isNotBlank() && trimmedKey.isNotBlank()) {
publishAction(AppAction(AppAction.Action.RegisterPrismServer)) publishAction(AppAction(AppAction.Action.RegisterPrismServer))
} }
UiAction.publish(UiAction.Action.UpdatePrismServerConfigured)
} }
} }
} }

View file

@ -7,7 +7,8 @@ import kotlinx.coroutines.launch
class UiAction(val action: Action) { class UiAction(val action: Action) {
enum class Action { enum class Action {
RefreshRegistrations RefreshRegistrations,
UpdatePrismServerConfigured
} }
fun handle(action: (Action) -> Unit) { fun handle(action: (Action) -> Unit) {

View file

@ -111,7 +111,7 @@ fun App(
) )
}, },
floatingActionButton = { floatingActionButton = {
if (currentScreen == AppScreen.Main) { if (currentScreen == AppScreen.Main && mainViewModel.mainUiState.prismServerConfigured) {
FloatingActionButton( FloatingActionButton(
onClick = { mainViewModel.showAddAppDialog() } onClick = { mainViewModel.showAddAppDialog() }
) { ) {

View file

@ -24,6 +24,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import app.lonecloud.prism.AppStore
import app.lonecloud.prism.EventBus import app.lonecloud.prism.EventBus
import app.lonecloud.prism.R import app.lonecloud.prism.R
import app.lonecloud.prism.activities.DistribMigrationViewModel import app.lonecloud.prism.activities.DistribMigrationViewModel
@ -83,6 +84,15 @@ fun MainScreen(viewModel: MainViewModel, migrationViewModel: DistribMigrationVie
it.handle { type -> it.handle { type ->
when (type) { when (type) {
UiAction.Action.RefreshRegistrations -> viewModel.refreshRegistrations() UiAction.Action.RefreshRegistrations -> viewModel.refreshRegistrations()
UiAction.Action.UpdatePrismServerConfigured -> {
viewModel.application?.let { app ->
val store = AppStore(app)
viewModel.updatePrismServerConfigured(
!store.prismServerUrl.isNullOrBlank() &&
!store.prismApiKey.isNullOrBlank()
)
}
}
} }
} }
} }
@ -103,12 +113,12 @@ fun MainScreen(viewModel: MainViewModel, migrationViewModel: DistribMigrationVie
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) innerColumn@{
if (migrationViewModel.state.migrated) { if (migrationViewModel.state.migrated) {
CardDisabledForMigration { CardDisabledForMigration {
migrationViewModel.reactivateUnifiedPush() migrationViewModel.reactivateUnifiedPush()
} }
return@Column return@innerColumn
} }
CardDisableBatteryOptimisation(viewModel.batteryOptimisationViewModel) CardDisableBatteryOptimisation(viewModel.batteryOptimisationViewModel)

View file

@ -13,5 +13,6 @@ data class MainUiState(
val isLoadingEndpoint: Boolean = false, val isLoadingEndpoint: Boolean = false,
val currentEndpoint: String = "", val currentEndpoint: String = "",
val showAddAppDialog: Boolean = false, val showAddAppDialog: Boolean = false,
val installedApps: List<InstalledApp> = emptyList() val installedApps: List<InstalledApp> = emptyList(),
val prismServerConfigured: Boolean = false
) )

View file

@ -80,6 +80,7 @@ fun PrismServerConfigDialog(
var apiKey by remember { mutableStateOf("") } var apiKey by remember { mutableStateOf("") }
var isTesting by remember { mutableStateOf(false) } var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) } var testResult by remember { mutableStateOf<String?>(null) }
var showServerChangeWarning by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
AlertDialog( AlertDialog(
@ -149,8 +150,28 @@ fun PrismServerConfigDialog(
onDismiss() onDismiss()
return@Button return@Button
} }
val isServerChanging = initialUrl.isNotBlank() && url.trim() != initialUrl
if (isServerChanging) {
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
if (manualAppsCount > 0) {
val oldUrl = initialUrl
val oldKey = app.lonecloud.prism.AppStore(context).prismApiKey
if (!oldUrl.isNullOrBlank() && !oldKey.isNullOrBlank()) {
app.lonecloud.prism.PrismServerClient.deleteAllApps(
context,
serverUrl = oldUrl,
apiKey = oldKey
)
}
showServerChangeWarning = true
return@Button
}
}
isTesting = true isTesting = true
app.lonecloud.prism.sup.PrismServerClient.testConnection( app.lonecloud.prism.PrismServerClient.testConnection(
url, url,
apiKey, apiKey,
onSuccess = { onSuccess = {
@ -175,4 +196,51 @@ fun PrismServerConfigDialog(
} }
} }
) )
if (showServerChangeWarning) {
val db = app.lonecloud.prism.DatabaseFactory.getDb(context)
val manualAppsCount = db.listApps()
.count { it.description?.startsWith("target:") == true }
AlertDialog(
onDismissRequest = { showServerChangeWarning = false },
title = { Text("Change Prism Server?") },
text = {
Text(
"You have $manualAppsCount manual app${if (manualAppsCount == 1) "" else "s"}" +
" registered with the current server.\n\n" +
"Changing to $url will delete registrations from the old server" +
" and re-register with the new one."
)
},
confirmButton = {
Button(
onClick = {
showServerChangeWarning = false
isTesting = true
app.lonecloud.prism.PrismServerClient.testConnection(
url,
apiKey,
onSuccess = {
isTesting = false
testResult = "Connection successful"
onSave(url, apiKey)
},
onError = { error ->
isTesting = false
testResult = "Connection failed: $error"
}
)
}
) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = { showServerChangeWarning = false }) {
Text("Cancel")
}
}
)
}
} }

View file

@ -10,6 +10,7 @@ import app.lonecloud.prism.AppStore
import app.lonecloud.prism.DatabaseFactory import app.lonecloud.prism.DatabaseFactory
import app.lonecloud.prism.Distributor import app.lonecloud.prism.Distributor
import app.lonecloud.prism.Distributor.sendMessage import app.lonecloud.prism.Distributor.sendMessage
import app.lonecloud.prism.EncryptionKeyStore
import app.lonecloud.prism.api.data.ClientMessage import app.lonecloud.prism.api.data.ClientMessage
import app.lonecloud.prism.api.data.ServerMessage import app.lonecloud.prism.api.data.ServerMessage
import app.lonecloud.prism.callback.NetworkCallbackFactory import app.lonecloud.prism.callback.NetworkCallbackFactory
@ -17,6 +18,7 @@ import app.lonecloud.prism.services.FgService
import app.lonecloud.prism.services.RestartWorker import app.lonecloud.prism.services.RestartWorker
import app.lonecloud.prism.services.SourceManager import app.lonecloud.prism.services.SourceManager
import app.lonecloud.prism.utils.TAG import app.lonecloud.prism.utils.TAG
import app.lonecloud.prism.utils.WebPushDecryptor
import java.util.Calendar import java.util.Calendar
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -122,10 +124,50 @@ class ServerConnection(private val context: Context, private val releaseLock: ()
} }
private fun onNotification(webSocket: WebSocket, message: ServerMessage.Notification) { private fun onNotification(webSocket: WebSocket, message: ServerMessage.Notification) {
val encryptedData = Base64.decode(message.data, Base64.URL_SAFE)
val db = DatabaseFactory.getDb(context)
val app = db.listApps().find { app ->
app.description?.startsWith("target:") == true
}
val decryptedData = if (app != null) {
val keyStore = EncryptionKeyStore(context)
val keys = keyStore.getKeys(message.channelID)
if (keys != null) {
val (privateKeyBytes, authSecret, publicKey) = keys
try {
val publicKeyBytes = Base64.decode(publicKey, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val decrypted = WebPushDecryptor.decrypt(
encryptedData,
privateKeyBytes,
publicKeyBytes,
authSecret
)
decrypted ?: run {
Log.e(TAG, "Decryption failed for channel ${message.channelID}")
encryptedData
}
} catch (e: Exception) {
Log.e(TAG, "Decryption error for channel ${message.channelID}: ${e.message}")
encryptedData
}
} else {
Log.d(TAG, "No encryption keys found for manual app ${message.channelID}, message may be unencrypted")
encryptedData
}
} else {
encryptedData
}
sendMessage( sendMessage(
context, context,
message.channelID, message.channelID,
Base64.decode(message.data, Base64.URL_SAFE) decryptedData
) )
ClientMessage.Ack( ClientMessage.Ack(
arrayOf(ClientMessage.ClientAck(message.channelID, message.version)) arrayOf(ClientMessage.ClientAck(message.channelID, message.version))

View file

@ -0,0 +1,54 @@
package app.lonecloud.prism.utils
import android.util.Base64
import java.security.KeyPairGenerator
import java.security.interfaces.ECPrivateKey
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* VAPID key generation for Web Push (RFC 8292).
* Uses NIST P-256 (secp256r1) elliptic curve.
*/
object VapidKeyGenerator {
data class VapidKeyPair(val publicKey: String, val privateKey: String)
fun generateKeyPair(): VapidKeyPair {
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
val ecSpec = ECGenParameterSpec("secp256r1")
keyPairGenerator.initialize(ecSpec)
val keyPair = keyPairGenerator.generateKeyPair()
val publicKey = keyPair.public as ECPublicKey
val privateKey = keyPair.private as ECPrivateKey
val xCoord = publicKey.w.affineX.toByteArray().trimLeadingZeros()
val yCoord = publicKey.w.affineY.toByteArray().trimLeadingZeros()
val publicKeyBytes = ByteArray(65)
publicKeyBytes[0] = 0x04
xCoord.copyInto(publicKeyBytes, 1 + (32 - xCoord.size))
yCoord.copyInto(publicKeyBytes, 33 + (32 - yCoord.size))
val privateKeyBytes = privateKey.s.toByteArray().trimLeadingZeros()
val privateKeyPadded = ByteArray(32)
privateKeyBytes.copyInto(privateKeyPadded, 32 - privateKeyBytes.size)
return VapidKeyPair(
publicKey = base64UrlEncode(publicKeyBytes),
privateKey = base64UrlEncode(privateKeyPadded)
)
}
private fun base64UrlEncode(data: ByteArray): String =
Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
private fun ByteArray.trimLeadingZeros(): ByteArray {
var i = 0
while (i < size && this[i] == 0.toByte()) {
i++
}
return copyOfRange(i, size)
}
}

View file

@ -0,0 +1,116 @@
package app.lonecloud.prism.utils
import com.google.crypto.tink.subtle.EllipticCurves
import com.google.crypto.tink.subtle.Hkdf
import java.nio.ByteBuffer
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* WebPush message decryption (RFC 8291 - aes128gcm).
* Uses Tink for all cryptographic primitives (ECDH, HKDF, AES-GCM).
*/
object WebPushDecryptor {
private const val SALT_SIZE = 16
private const val RECORD_SIZE_LEN = 4
private const val PUBLIC_KEY_SIZE_LEN = 1
private const val PUBLIC_KEY_SIZE = 65
private const val CONTENT_CODING_HEADER_SIZE = SALT_SIZE + RECORD_SIZE_LEN + PUBLIC_KEY_SIZE_LEN + PUBLIC_KEY_SIZE
private const val TAG_SIZE = 16
private const val CIPHERTEXT_OVERHEAD = CONTENT_CODING_HEADER_SIZE + 1 + TAG_SIZE
private const val AUTH_SECRET_SIZE = 16
/**
* Decrypt a WebPush message.
*
* @param ciphertext The encrypted message
* @param privateKeyBytes The recipient's private key (PKCS8 encoded)
* @param publicKeyBytes The recipient's public key (uncompressed P-256 point, 65 bytes)
* @param authSecret The auth secret (16 bytes)
* @return Decrypted plaintext or null on failure
*/
fun decrypt(
ciphertext: ByteArray,
privateKeyBytes: ByteArray,
publicKeyBytes: ByteArray,
authSecret: ByteArray
): ByteArray? {
try {
if (ciphertext.size < CIPHERTEXT_OVERHEAD) return null
if (publicKeyBytes.size != PUBLIC_KEY_SIZE) return null
if (authSecret.size != AUTH_SECRET_SIZE) return null
val record = ByteBuffer.wrap(ciphertext)
val salt = ByteArray(SALT_SIZE)
record.get(salt)
val recordSize = record.int
val publicKeySize = record.get().toInt()
if (publicKeySize != PUBLIC_KEY_SIZE) return null
val ephemeralPublicKeyBytes = ByteArray(PUBLIC_KEY_SIZE)
record.get(ephemeralPublicKeyBytes)
val payload = ByteArray(ciphertext.size - CONTENT_CODING_HEADER_SIZE)
record.get(payload)
val keyFactory = KeyFactory.getInstance("EC")
val privateKeySpec = PKCS8EncodedKeySpec(privateKeyBytes)
val privateKey = keyFactory.generatePrivate(privateKeySpec) as ECPrivateKey
val ephemeralPublicPoint = EllipticCurves.pointDecode(
EllipticCurves.CurveType.NIST_P256,
EllipticCurves.PointFormatType.UNCOMPRESSED,
ephemeralPublicKeyBytes
)
val ecdhSecret = EllipticCurves.computeSharedSecret(privateKey, ephemeralPublicPoint)
val ikm = computeIkm(ecdhSecret, authSecret, publicKeyBytes, ephemeralPublicKeyBytes)
val cek = computeCek(ikm, salt)
val nonce = computeNonce(ikm, salt)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val params = GCMParameterSpec(8 * TAG_SIZE, nonce)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cek, "AES"), params)
val plaintext = cipher.doFinal(payload)
var index = plaintext.size - 1
while (index > 0 && plaintext[index] == 0.toByte()) {
index--
}
if (plaintext[index] != 0x02.toByte()) return null
return plaintext.copyOf(index)
} catch (e: Exception) {
return null
}
}
private fun computeIkm(
ecdhSecret: ByteArray,
authSecret: ByteArray,
uaPublic: ByteArray,
asPublic: ByteArray
): ByteArray {
val ikmInfo = "WebPush: info\u0000".toByteArray(Charsets.UTF_8)
val keyInfo = ikmInfo + uaPublic + asPublic
return Hkdf.computeHkdf("HMACSHA256", ecdhSecret, authSecret, keyInfo, 32)
}
private fun computeCek(ikm: ByteArray, salt: ByteArray): ByteArray {
val cekInfo = "Content-Encoding: aes128gcm\u0000".toByteArray(Charsets.UTF_8)
return Hkdf.computeHkdf("HMACSHA256", ikm, salt, cekInfo, 16)
}
private fun computeNonce(ikm: ByteArray, salt: ByteArray): ByteArray {
val nonceInfo = "Content-Encoding: nonce\u0000".toByteArray(Charsets.UTF_8)
return Hkdf.computeHkdf("HMACSHA256", ikm, salt, nonceInfo, 12)
}
}

View file

@ -0,0 +1,43 @@
package app.lonecloud.prism.utils
import android.util.Base64
import com.google.crypto.tink.subtle.EllipticCurves
import java.security.KeyPairGenerator
import java.security.SecureRandom
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
object WebPushEncryptionKeys {
data class EncryptionKeySet(
val p256dh: String,
val auth: String,
val privateKey: ByteArray,
val authBytes: ByteArray
)
fun generateKeySet(): EncryptionKeySet {
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
keyPairGenerator.initialize(ECGenParameterSpec("secp256r1"))
val keyPair = keyPairGenerator.generateKeyPair()
val publicKeyBytes = EllipticCurves.pointEncode(
EllipticCurves.CurveType.NIST_P256,
EllipticCurves.PointFormatType.UNCOMPRESSED,
(keyPair.public as ECPublicKey).w
)
val authBytes = ByteArray(16)
SecureRandom().nextBytes(authBytes)
return EncryptionKeySet(
p256dh = base64UrlEncode(publicKeyBytes),
auth = base64UrlEncode(authBytes),
privateKey = keyPair.private.encoded,
authBytes = authBytes
)
}
private fun base64UrlEncode(data: ByteArray): String =
Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}

View file

@ -6,6 +6,7 @@ androidx-work = "2.11.0"
appcompat = "1.7.1" appcompat = "1.7.1"
unifiedpush_distributor = "0.5.6" unifiedpush_distributor = "0.5.6"
unifiedpush_distributor_ui = "0.5.5" unifiedpush_distributor_ui = "0.5.5"
tink = "1.15.0"
kotlin = "2.2.20" kotlin = "2.2.20"
kotlinx_serializationJson = "1.9.0" kotlinx_serializationJson = "1.9.0"
ktlint = "14.0.1" ktlint = "14.0.1"
@ -29,6 +30,7 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
unifiedpush-distributor = { module = "org.unifiedpush.android:distributor", version.ref = "unifiedpush_distributor" } unifiedpush-distributor = { module = "org.unifiedpush.android:distributor", version.ref = "unifiedpush_distributor" }
unifiedpush-distributor-ui = { module = "org.unifiedpush.android:distributor-ui", version.ref = "unifiedpush_distributor_ui" } unifiedpush-distributor-ui = { module = "org.unifiedpush.android:distributor-ui", version.ref = "unifiedpush_distributor_ui" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serializationJson" }
ktlint-gradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint"} ktlint-gradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint"}