Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.js.messaging.api.SubscriptionEventData
import com.squareup.anvil.annotations.ContributesBinding
import kotlinx.coroutines.runBlocking
import logcat.logcat
import org.json.JSONObject
import java.util.regex.Pattern
import javax.inject.Inject
Expand Down Expand Up @@ -173,7 +174,8 @@ class RealDuckChatJSHelper @Inject constructor(
put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled())
put(SUPPORTS_STANDALONE_MIGRATION, duckChat.isStandaloneMigrationEnabled())
put(SUPPORTS_CHAT_FULLSCREEN_MODE, duckChat.isDuckChatFullScreenModeEnabled())
}
put(SUPPORTS_CHAT_SYNC, duckChat.isChatSyncFeatureEnabled())
}.also { logcat { "Duck.ai: getAIChatNativeConfigValues $it" } }
return JsCallbackData(jsonPayload, featureName, method, id)
}

Expand Down Expand Up @@ -237,6 +239,7 @@ class RealDuckChatJSHelper @Inject constructor(
private const val SUPPORTS_CHAT_ID_RESTORATION = "supportsURLChatIDRestoration"
private const val SUPPORTS_STANDALONE_MIGRATION = "supportsStandaloneMigration"
private const val SUPPORTS_CHAT_FULLSCREEN_MODE = "supportsAIChatFullMode"
private const val SUPPORTS_CHAT_SYNC = "supportsAIChatSync"
private const val REPORT_METRIC = "reportMetric"
private const val PLATFORM = "platform"
private const val ANDROID = "android"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.duckchat.impl.messaging.sync

/**
* Converts a standard Base64 string to URL-safe Base64 (base64url) format.
*
* This function performs the following transformations:
* - Replaces '+' with '-'
* - Replaces '/' with '_'
* - Removes trailing padding '=' characters
*
* This is useful when encoding data that needs to be safely transmitted in URLs
* or other contexts where standard Base64 characters might cause issues.
*
* @return A URL-safe Base64 encoded string
*/
internal fun String.applyUrlSafetyFromB64(): String {
return this
.replace('+', '-')
.replace('/', '_')
.trimEnd('=')
}

/**
* Converts a URL-safe Base64 (base64url) string back to standard Base64 format.
*
* This function performs the following transformations:
* - Replaces '-' with '+'
* - Replaces '_' with '/'
* - Restores padding '=' characters as needed
*
* This is the inverse of [applyUrlSafetyFromB64] and is needed because Android's
* Base64.URL_SAFE flag does not automatically restore missing padding on decode.
*
* @return A standard Base64 encoded string with proper padding
*/
internal fun String.removeUrlSafetyToRestoreB64(): String {
return this
.replace('-', '+')
.replace('_', '/')
.restoreBase64Padding()
}

private fun String.restoreBase64Padding(): String {
return when (length % 4) {
2 -> "$this=="
3 -> "$this="
else -> this
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.duckchat.impl.messaging.sync

import android.util.Base64
import com.duckduckgo.common.utils.AppUrl
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.js.messaging.api.JsMessage
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessageHandler
import com.duckduckgo.js.messaging.api.JsMessaging
import com.duckduckgo.sync.api.DeviceSyncState
import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn
import com.duckduckgo.sync.api.SyncCrypto
import com.squareup.anvil.annotations.ContributesMultibinding
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
import org.json.JSONObject
import javax.inject.Inject

@ContributesMultibinding(AppScope::class)
class DecryptWithSyncMasterKeyHandler @Inject constructor(
private val crypto: SyncCrypto,
private val deviceSyncState: DeviceSyncState,
) : ContentScopeJsMessageHandlersPlugin {

override fun getJsMessageHandler(): JsMessageHandler =
object : JsMessageHandler {
override fun process(
jsMessage: JsMessage,
jsMessaging: JsMessaging,
jsMessageCallback: JsMessageCallback?,
) {
if (jsMessage.id.isNullOrEmpty()) return

logcat(LogPriority.WARN) { "DuckChat-Sync: ${jsMessage.method} called" }

val responder = JavaScriptResponder(jsMessaging, jsMessage, featureName)

val syncError = validateSyncState()
if (syncError != null) {
responder.sendError(syncError)
return
}

// get encrypted data from params
val data = extractData(jsMessage)
if (data == null) {
responder.sendError(ERROR_INVALID_PARAMETERS)
return
}

// decode base64Url to get encrypted bytes
val encryptedBytes = decodeBase64Url(data)
if (encryptedBytes == null) {
responder.sendError(ERROR_INVALID_PARAMETERS)
return
}

// decrypt (input is bytes, output is bytes)
val decryptedBytes = decryptData(encryptedBytes)
if (decryptedBytes == null) {
responder.sendError(ERROR_DECRYPTION_FAILED)
return
}

// encode decrypted bytes as base64Url for response
val decryptedData = Base64.encodeToString(decryptedBytes, Base64.NO_WRAP).applyUrlSafetyFromB64()

// send decrypted data back to JS
responder.sendSuccess(decryptedData)
}

private fun validateSyncState(): String? {
if (!deviceSyncState.isFeatureEnabled()) {
return ERROR_SYNC_DISABLED
}
if (deviceSyncState.getAccountState() !is SignedIn) {
return ERROR_SYNC_OFF
}
return null
}

private fun extractData(jsMessage: JsMessage): String? {
val data = jsMessage.params.optString("data", "")
return data.takeIf { it.isNotEmpty() }
}

private fun decodeBase64Url(base64Url: String): ByteArray? {
return runCatching {
val standardB64 = base64Url.removeUrlSafetyToRestoreB64()
Base64.decode(standardB64, Base64.NO_WRAP)
}.onFailure { e ->
logcat(LogPriority.ERROR) { "Error decoding base64Url: $base64Url. ${e.asLog()}" }
}.getOrNull()
}

private fun decryptData(data: ByteArray): ByteArray? {
return runCatching {
crypto.decrypt(data)
}.onFailure { e ->
logcat(LogPriority.ERROR) { "Error decrypting data because ${e.asLog()}" }
}.getOrNull()
}

override val allowedDomains: List<String> = listOf(
AppUrl.Url.HOST,
HOST_DUCK_AI,
)

override val featureName: String = "aiChat"
override val methods: List<String> = listOf("decryptWithSyncMasterKey")
}

private class JavaScriptResponder(
private val jsMessaging: JsMessaging,
private val jsMessage: JsMessage,
private val featureName: String,
) {
fun sendSuccess(decryptedData: String) {
val payload = JSONObject().apply {
put("decryptedData", decryptedData)
}
val jsonPayload = JSONObject().apply {
put("ok", true)
put("payload", payload)
}
runCatching {
jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!))
}.onFailure { e ->
logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send success response: ${e.message}" }
}
}

fun sendError(error: String) {
val errorPayload = JSONObject().apply {
put("ok", false)
put("reason", error)
}
runCatching {
jsMessaging.onResponse(JsCallbackData(errorPayload, featureName, jsMessage.method, jsMessage.id!!))
logcat { "DuckChat-Sync: error: $error" }
}.onFailure { e ->
logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send error response: ${e.message}" }
}
}
}

private companion object {
private const val ERROR_SYNC_DISABLED = "sync unavailable"
private const val ERROR_SYNC_OFF = "sync off"
private const val ERROR_INVALID_PARAMETERS = "invalid parameters"
private const val ERROR_DECRYPTION_FAILED = "decryption failed"
}
}
Loading
Loading