Skip to content
Merged
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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2026 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.sync.impl

import android.util.Base64
import com.duckduckgo.sync.crypto.SyncLib
import com.duckduckgo.sync.impl.Result.Success
import com.duckduckgo.sync.impl.crypto.SyncJweCrypto
import java.util.UUID

/** A freshly-minted protected key with its ddg-wrap [entry] and the underlying raw private bytes,
* so callers can produce additional wrappings (e.g. 3party) under the same `kid` without
* decrypting the ddg-wrap a second time. */
internal data class MintedProtectedKey(
val entry: ProtectedKeyEntry,
val rawPrivateKeyBytes: ByteArray,
)

/**
* Generate a fresh RSA keypair and build its ddg-side [ProtectedKeyEntry] (libsodium-secretbox of
* the private key, base64url-encoded, matching duck.ai's `EncryptWithSyncMasterKeyHandler`).
*
* Pure helper: does not touch the network or the store. Used by the 3party credential flow,
* which bundles both ddg and 3party wraps into a single `POST /access-credentials/{id}`.
*/
internal fun mintDdgWrappedProtectedKey(
purpose: String,
accountSecretKey: String,
syncJweCrypto: SyncJweCrypto,
nativeLib: SyncLib,
errorPrefix: String,
): Result<MintedProtectedKey> {
val rsa = runCatching { syncJweCrypto.generateRsaKeyPair() }
.getOrElse { return it.asLoggedError("$errorPrefix: failed to generate RSA keypair") }
val (n, e) = runCatching { syncJweCrypto.extractJwkComponents(rsa.publicKeyBase64) }
.getOrElse { return it.asLoggedError("$errorPrefix: failed to extract JWK components") }

val privateKeyBytes = runCatching {
Base64.decode(rsa.privateKeyBase64, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}.getOrElse { return it.asLoggedError("$errorPrefix: failed to decode private key bytes") }

val ddgEncryptedPrivateKey = runCatching {
val result = nativeLib.encryptData(privateKeyBytes, accountSecretKey).also {
it.checkResult("$errorPrefix: libsodium encryption of private key failed")
}
Base64.encodeToString(result.encryptedData, Base64.NO_WRAP).applyUrlSafetyFromB64()
}.getOrElse { return it.asErrorResult() }

val entry = ProtectedKeyEntry(
kid = UUID.randomUUID().toString(),
purpose = purpose,
encryptedWith = CREDENTIAL_ID_DDG,
encryptedPrivateKey = ddgEncryptedPrivateKey,
publicKey = RsaJwk(n = n, e = e),
)
return Success(MintedProtectedKey(entry = entry, rawPrivateKeyBytes = privateKeyBytes))
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,6 @@ interface SyncAccountRepository {
*/
fun getThirdPartyRecoveryCode(): Result<AuthCode>

/**
* Creates a protected RSA keypair for the given purpose (e.g. "ai_chats") and uploads it
* to the server, encrypted with the ddg credential's stretchedPrimaryKey.
*
* No-op (returns existing key) if a key for the purpose already exists.
*/
fun createProtectedKey(purpose: String): Result<Boolean>

data class AuthCode(
/**
* A code that is suitable for displaying in a QR code.
Expand Down Expand Up @@ -176,7 +168,6 @@ class AppSyncAccountRepository @Inject constructor(
private val syncSetupWideEvent: SyncSetupWideEvent,
private val syncJweCrypto: SyncJweCrypto,
private val thirdPartyCredentialManager: ThirdPartyCredentialManager,
private val protectedKeyManager: ProtectedKeyManager,
private val thirdPartyDeviceListDecryptor: ThirdPartyDeviceListDecryptor,
) : SyncAccountRepository {

Expand Down Expand Up @@ -638,12 +629,6 @@ class AppSyncAccountRepository @Inject constructor(

override fun refreshThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.refresh()

override fun createProtectedKey(purpose: String): Result<Boolean> =
when (val r = protectedKeyManager.create(purpose)) {
is Result.Success -> Result.Success(true)
is Result.Error -> r
}

/**
* Bundle produced by [buildThirdPartyUpgradePackage] and consumed by the upgrade POST +
* SyncStore commit step. Keeps the locally-generated DDG account material together with the
Expand Down Expand Up @@ -714,7 +699,7 @@ class AppSyncAccountRepository @Inject constructor(
// Re-wrap each FE-written key from /sync/login. Decrypt via JWE using SP MEK, then
// re-encrypt with libsodium-secretbox using the new DDG secretKey, matching the Native
// wire format (base64-encoded encrypted bytes with URL safety applied — mirrors
// createProtectedKey at line ~955 and the reverse direction at line ~770).
// mintDdgWrappedProtectedKey in ProtectedKeyMinting.kt).
val rewrappedKeys = keysFromLogin.map { srcKey ->
kotlin.runCatching {
// FE-only accounts always write keys with encrypted_with="3party". A defensive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,6 @@ interface SyncService {
@Header("Authorization") token: String,
): Call<ProtectedKeysResponse>

@POST("$SYNC_PROD_ENVIRONMENT_URL/sync/keys/purpose/{purpose}/set-if-absent")
fun setProtectedKeyIfAbsent(
@Header("Authorization") token: String,
@Path("purpose") purpose: String,
@Body request: SetProtectedKeyIfAbsentRequest,
): Call<ProtectedKeysResponse>

@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/access-credentials")
fun getAccessCredentials(
@Header("Authorization") token: String,
Expand Down Expand Up @@ -281,11 +274,6 @@ data class ProtectedKeysResponse(
val keys: List<ProtectedKeyEntry>,
)

/** Body for POST /sync/keys/purpose/{purpose}/set-if-absent — adds a protected key only if no key exists for that purpose. */
data class SetProtectedKeyIfAbsentRequest(
val keys: List<ProtectedKeyEntry>,
)

data class AccessCredentialsResponse(
@field:Json(name = "access_credentials") val accessCredentials: List<AccessCredentialEntry>,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,6 @@ interface SyncApi {
/** List this account's protected keys across all purposes. */
fun getProtectedKeys(token: String): Result<List<ProtectedKeyEntry>>

/** Atomically claim [purpose] with [key] or no-op if one already exists. */
fun setProtectedKeyIfAbsent(token: String, purpose: String, keys: List<ProtectedKeyEntry>): Result<List<ProtectedKeyEntry>>

fun getAccessCredentials(token: String): Result<List<AccessCredentialEntry>>

fun createAccessCredential(
Expand Down Expand Up @@ -539,20 +536,6 @@ class SyncServiceRemote @Inject constructor(
}
}

override fun setProtectedKeyIfAbsent(token: String, purpose: String, keys: List<ProtectedKeyEntry>): Result<List<ProtectedKeyEntry>> {
val response = runCatching {
val call = syncService.setProtectedKeyIfAbsent("Bearer $token", purpose, SetProtectedKeyIfAbsentRequest(keys))
call.execute()
}.getOrElse { throwable ->
return Result.Error(reason = throwable.message.toString())
}

return onSuccess(response) {
val keys = response.body()?.keys ?: emptyList()
Result.Success(keys)
}
}

override fun getProtectedKeys(token: String): Result<List<ProtectedKeyEntry>> {
val response = runCatching {
val call = syncService.getProtectedKeys("Bearer $token")
Expand Down
Loading
Loading