Skip to content

Commit e0ac7f9

Browse files
committed
ync exchange v2: stop directly integrating with credential creation
1 parent 0dbde5a commit e0ac7f9

9 files changed

Lines changed: 210 additions & 489 deletions

File tree

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ProtectedKeyManager.kt

Lines changed: 0 additions & 127 deletions
This file was deleted.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.sync.impl
18+
19+
import android.util.Base64
20+
import com.duckduckgo.sync.crypto.SyncLib
21+
import com.duckduckgo.sync.impl.Result.Success
22+
import com.duckduckgo.sync.impl.crypto.SyncJweCrypto
23+
import java.util.UUID
24+
25+
/** A freshly-minted protected key with its ddg-wrap [entry] and the underlying raw private bytes,
26+
* so callers can produce additional wrappings (e.g. 3party) under the same `kid` without
27+
* decrypting the ddg-wrap a second time. */
28+
internal data class MintedProtectedKey(
29+
val entry: ProtectedKeyEntry,
30+
val rawPrivateKeyBytes: ByteArray,
31+
)
32+
33+
/**
34+
* Generate a fresh RSA keypair and build its ddg-side [ProtectedKeyEntry] (libsodium-secretbox of
35+
* the private key, base64url-encoded, matching duck.ai's `EncryptWithSyncMasterKeyHandler`).
36+
*
37+
* Pure helper: does not touch the network or the store. Used by the 3party credential flow,
38+
* which bundles both ddg and 3party wraps into a single `POST /access-credentials/{id}`.
39+
*/
40+
internal fun mintDdgWrappedProtectedKey(
41+
purpose: String,
42+
accountSecretKey: String,
43+
syncJweCrypto: SyncJweCrypto,
44+
nativeLib: SyncLib,
45+
errorPrefix: String,
46+
): Result<MintedProtectedKey> {
47+
val rsa = kotlin.runCatching { syncJweCrypto.generateRsaKeyPair() }
48+
.getOrElse { return it.asLoggedError("$errorPrefix: failed to generate RSA keypair") }
49+
val (n, e) = kotlin.runCatching { syncJweCrypto.extractJwkComponents(rsa.publicKeyBase64) }
50+
.getOrElse { return it.asLoggedError("$errorPrefix: failed to extract JWK components") }
51+
52+
val privateKeyBytes = kotlin.runCatching {
53+
Base64.decode(rsa.privateKeyBase64, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
54+
}.getOrElse { return it.asLoggedError("$errorPrefix: failed to decode private key bytes") }
55+
56+
val ddgEncryptedPrivateKey = kotlin.runCatching {
57+
val result = nativeLib.encryptData(privateKeyBytes, accountSecretKey).also {
58+
it.checkResult("$errorPrefix: libsodium encryption of private key failed")
59+
}
60+
Base64.encodeToString(result.encryptedData, Base64.NO_WRAP).applyUrlSafetyFromB64()
61+
}.getOrElse { return it.asErrorResult() }
62+
63+
val entry = ProtectedKeyEntry(
64+
kid = UUID.randomUUID().toString(),
65+
purpose = purpose,
66+
encryptedWith = CREDENTIAL_ID_DDG,
67+
encryptedPrivateKey = ddgEncryptedPrivateKey,
68+
publicKey = RsaJwk(n = n, e = e),
69+
)
70+
return Success(MintedProtectedKey(entry = entry, rawPrivateKeyBytes = privateKeyBytes))
71+
}

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,6 @@ interface SyncAccountRepository {
134134
*/
135135
fun getThirdPartyRecoveryCode(): Result<AuthCode>
136136

137-
/**
138-
* Creates a protected RSA keypair for the given purpose (e.g. "ai_chats") and uploads it
139-
* to the server, encrypted with the ddg credential's stretchedPrimaryKey.
140-
*
141-
* No-op (returns existing key) if a key for the purpose already exists.
142-
*/
143-
fun createProtectedKey(purpose: String): Result<Boolean>
144-
145137
data class AuthCode(
146138
/**
147139
* A code that is suitable for displaying in a QR code.
@@ -174,7 +166,6 @@ class AppSyncAccountRepository @Inject constructor(
174166
private val syncSetupWideEvent: SyncSetupWideEvent,
175167
private val syncJweCrypto: SyncJweCrypto,
176168
private val thirdPartyCredentialManager: ThirdPartyCredentialManager,
177-
private val protectedKeyManager: ProtectedKeyManager,
178169
private val thirdPartyDeviceListDecryptor: ThirdPartyDeviceListDecryptor,
179170
) : SyncAccountRepository {
180171

@@ -636,12 +627,6 @@ class AppSyncAccountRepository @Inject constructor(
636627

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

639-
override fun createProtectedKey(purpose: String): Result<Boolean> =
640-
when (val r = protectedKeyManager.create(purpose)) {
641-
is Result.Success -> Result.Success(true)
642-
is Result.Error -> r
643-
}
644-
645630
/**
646631
* Bundle produced by [buildThirdPartyUpgradePackage] and consumed by the upgrade POST +
647632
* SyncStore commit step. Keeps the locally-generated DDG account material together with the
@@ -712,7 +697,7 @@ class AppSyncAccountRepository @Inject constructor(
712697
// Re-wrap each FE-written key from /sync/login. Decrypt via JWE using SP MEK, then
713698
// re-encrypt with libsodium-secretbox using the new DDG secretKey, matching the Native
714699
// wire format (base64-encoded encrypted bytes with URL safety applied — mirrors
715-
// createProtectedKey at line ~955 and the reverse direction at line ~770).
700+
// mintDdgWrappedProtectedKey in ProtectedKeyMinting.kt).
716701
val rewrappedKeys = keysFromLogin.map { srcKey ->
717702
kotlin.runCatching {
718703
// FE-only accounts always write keys with encrypted_with="3party". A defensive

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,6 @@ interface SyncService {
136136
@Header("Authorization") token: String,
137137
): Call<ProtectedKeysResponse>
138138

139-
@POST("$SYNC_PROD_ENVIRONMENT_URL/sync/keys/purpose/{purpose}/set-if-absent")
140-
fun setProtectedKeyIfAbsent(
141-
@Header("Authorization") token: String,
142-
@Path("purpose") purpose: String,
143-
@Body request: SetProtectedKeyIfAbsentRequest,
144-
): Call<ProtectedKeysResponse>
145-
146139
@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/access-credentials")
147140
fun getAccessCredentials(
148141
@Header("Authorization") token: String,
@@ -281,11 +274,6 @@ data class ProtectedKeysResponse(
281274
val keys: List<ProtectedKeyEntry>,
282275
)
283276

284-
/** Body for POST /sync/keys/purpose/{purpose}/set-if-absent — adds a protected key only if no key exists for that purpose. */
285-
data class SetProtectedKeyIfAbsentRequest(
286-
val keys: List<ProtectedKeyEntry>,
287-
)
288-
289277
data class AccessCredentialsResponse(
290278
@field:Json(name = "access_credentials") val accessCredentials: List<AccessCredentialEntry>,
291279
)

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,6 @@ interface SyncApi {
119119
/** List this account's protected keys across all purposes. */
120120
fun getProtectedKeys(token: String): Result<List<ProtectedKeyEntry>>
121121

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

127124
fun createAccessCredential(
@@ -539,20 +536,6 @@ class SyncServiceRemote @Inject constructor(
539536
}
540537
}
541538

542-
override fun setProtectedKeyIfAbsent(token: String, purpose: String, keys: List<ProtectedKeyEntry>): Result<List<ProtectedKeyEntry>> {
543-
val response = runCatching {
544-
val call = syncService.setProtectedKeyIfAbsent("Bearer $token", purpose, SetProtectedKeyIfAbsentRequest(keys))
545-
call.execute()
546-
}.getOrElse { throwable ->
547-
return Result.Error(reason = throwable.message.toString())
548-
}
549-
550-
return onSuccess(response) {
551-
val keys = response.body()?.keys ?: emptyList()
552-
Result.Success(keys)
553-
}
554-
}
555-
556539
override fun getProtectedKeys(token: String): Result<List<ProtectedKeyEntry>> {
557540
val response = runCatching {
558541
val call = syncService.getProtectedKeys("Bearer $token")

0 commit comments

Comments
 (0)