Skip to content

Commit ab87bae

Browse files
committed
ync exchange v2: stop directly integrating with credential creation
1 parent 3bc7c78 commit ab87bae

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
@@ -137,14 +137,6 @@ interface SyncAccountRepository {
137137
*/
138138
fun getThirdPartyRecoveryCode(): Result<AuthCode>
139139

140-
/**
141-
* Creates a protected RSA keypair for the given purpose (e.g. "ai_chats") and uploads it
142-
* to the server, encrypted with the ddg credential's stretchedPrimaryKey.
143-
*
144-
* No-op (returns existing key) if a key for the purpose already exists.
145-
*/
146-
fun createProtectedKey(purpose: String): Result<Boolean>
147-
148140
data class AuthCode(
149141
/**
150142
* A code that is suitable for displaying in a QR code.
@@ -177,7 +169,6 @@ class AppSyncAccountRepository @Inject constructor(
177169
private val syncSetupWideEvent: SyncSetupWideEvent,
178170
private val syncJweCrypto: SyncJweCrypto,
179171
private val thirdPartyCredentialManager: ThirdPartyCredentialManager,
180-
private val protectedKeyManager: ProtectedKeyManager,
181172
private val thirdPartyDeviceListDecryptor: ThirdPartyDeviceListDecryptor,
182173
) : SyncAccountRepository {
183174

@@ -639,12 +630,6 @@ class AppSyncAccountRepository @Inject constructor(
639630

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

642-
override fun createProtectedKey(purpose: String): Result<Boolean> =
643-
when (val r = protectedKeyManager.create(purpose)) {
644-
is Result.Success -> Result.Success(true)
645-
is Result.Error -> r
646-
}
647-
648633
/**
649634
* Bundle produced by [buildThirdPartyUpgradePackage] and consumed by the upgrade POST +
650635
* SyncStore commit step. Keeps the locally-generated DDG account material together with the
@@ -715,7 +700,7 @@ class AppSyncAccountRepository @Inject constructor(
715700
// Re-wrap each FE-written key from /sync/login. Decrypt via JWE using SP MEK, then
716701
// re-encrypt with libsodium-secretbox using the new DDG secretKey, matching the Native
717702
// wire format (base64-encoded encrypted bytes with URL safety applied — mirrors
718-
// createProtectedKey at line ~955 and the reverse direction at line ~770).
703+
// mintDdgWrappedProtectedKey in ProtectedKeyMinting.kt).
719704
val rewrappedKeys = keysFromLogin.map { srcKey ->
720705
kotlin.runCatching {
721706
// 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)