Skip to content

Sync Exchange v2 algorithm encryption foundation work#8683

Open
CDRussell wants to merge 1 commit into
developfrom
feature/craig/sync_3p_browser_chat_sync_foundation
Open

Sync Exchange v2 algorithm encryption foundation work#8683
CDRussell wants to merge 1 commit into
developfrom
feature/craig/sync_3p_browser_chat_sync_foundation

Conversation

@CDRussell
Copy link
Copy Markdown
Member

@CDRussell CDRussell commented May 26, 2026

Task/Issue URL: https://app.asana.com/1/137249556945/project/1203822806345703/task/1215115673129510?focus=true

Description

Foundational encryption work for supporting duck chat syncing from 3rd party browsers.

The main goal of this PR is to add the building blocks we'll need for supporting the functionality in a way that:

  • can be tested in internal sync dev tooling
  • does not affect production code

Steps to test this PR

Ability to create protected keys and 3p access credentials

Tip

Logcat filter: message~:"Sync-Scoped"

Tip

There is a screen recording of the required setup provided as a reference in Video recording of setting up Android to sync with 3rd party browser using dev tools

Testing refusal when FF disabled

  • Fresh install internal variant
  • Launch Sync Dev Settings (SDS) screen
  • Scroll all the way down and tap on Create account; wait a few seconds and make sure account settings populate
  • Back up in the V2 connect flow flags section, tap on Fetch Access Credentials and verify you see id=ddg,scope=(unrestricted) as you don't currently have any 3rd party access credentials
  • Tap on Fetch Keys and verify you see No keys found as you don't have any created yet
  • Tap on Create Protected Key (leave purpose set to default ai_chats) and verify this fails because the flag is disabled.
  • Tap on Create 3party Credential and verify it fails because the flag is disabled

Creating protected key

  • Enable flag using the canUseV2ConnectFlow (master kill switch)
  • Tap on Create Protected Key (leave purpose set to default ai_chats) and verify it succeeds, verifying that under Fetch Keys you see something with purpose=ai_chats, encrypted_with=ddg

Creating 3party credential

  • Tap on Refresh 3party Credential from Server and verify there are none
  • Tap on Create 3party Credential and verify it's successful

Syncing chats with 3rd party browser using native's 3p recovery code

Note

You'll need a 3rd party browser to test this out alongside your Android device, e.g., Chrome on desktop.

Important

This builds on the above scenario so make sure that's been done

Android

  • Tap on Open Duck.ai Dev Screen -> URL Settings and enter duck.ai chat URL from Test Server details. Save and it'll restart some things.
  • Return to sync dev screen and tap on Open Duck.ai Setup URL. Authenticate with SSO. When prompted, choose DEVELOPMENT server.

3rd party browser (e.g., Chrome desktop)

  • Follow instructions for 3rd Party Setup in Test Server details, again choosing DEVELOPMENT server when prompted

Android

  • Return to sync dev screen, and tap on Copy 3party Recovery Code

3rd party browser (e.g., Chrome desktop)

  • Down the bottom left, choose Settings & More -> Settings -> Turn On Sync & Backup
  • Choose Sync With Another Device
  • Paste the 3party recovery code you got from Android. Verify you see the "New Device Added" success dialog.
  • Start a new chat, e.g., called "Hello from 3rd party browser"

Android

  • Open duck chat; verify you see the chat from 3rd party browser syncing in after a few seconds. Open it, and add a reply.
  • Start a new duck chat, e.g., called "Hello from Android"

3rd party browser (e.g., Chrome desktop)

  • After a few seconds to sync, make sure you see the new "Hello from Android" chat
  • Open the original "Hello from 3rd party browser" chat and verify you see the reply you typed on Android

Tip

If all of the above is working, then you have established duck chat between Android and a 3rd party browser 🎉. That's largely it for this PR for now, other than ensuring nothing in prod is broken (see below)

Ensuring prod behavior is ok

Below is some of the main smoke testing of prod that is required, but isn't exhaustive. Any further smoke testing of typical prod sync pairing is helpful to ensure no regressions.

Note

You'll need two emulators/devices for this

  • Fresh install internal variants on two emulators/devices
  • On Device A:
    • Open production Sync Settings, and tap on Sync and Back Up This Device
    • Complete the flow and verify it works successfully
    • Tap on Sync With Another Device
  • On Device B
    • Open production Sync Settings, and tap on on Sync With Another Device
    • Connect the two, ensuring they connect successfully and are syncing

Note

High Risk
Touches account signup/login, secret derivation, and new encrypted credential/key handling; mitigated by a default-off master flag but any mis-gating could affect sync auth or key material.

Overview
Adds v2 sync scoped-access plumbing behind canUseV2ConnectFlow (default off): JWE/HKDF crypto (SyncJweCrypto, jjwt), new sync API models and remote calls for access credentials and protected keys, plus ThirdPartyCredentialManager and ProtectedKeyManager wired through SyncAccountRepository.

When the flag is on, signup/login send/store credential_id=ddg, persist 3party SP and protected keys from login, and support creating/adopting the 3party credential, v2 3party recovery codes, and purpose-based protected RSA keys (e.g. ai_chats). SyncStore gains credentialId, scopedPassword, and protectedKeysJson.

Sync internal dev settings expose toggles and buttons to exercise credentials, keys, scoped tokens, and Duck.ai setup—intended for internal testing, not production UI changes.

Reviewed by Cursor Bugbot for commit cfc8a4d. Configure here.

Copy link
Copy Markdown
Member Author

CDRussell commented May 26, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@CDRussell CDRussell force-pushed the feature/craig/sync_3p_browser_chat_sync_foundation branch 5 times, most recently from fddeb98 to cfc8a4d Compare May 26, 2026 11:45
@CDRussell CDRussell marked this pull request as ready for review May 26, 2026 11:51
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cfc8a4d. Configure here.

Comment thread sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/crypto/SyncJweCrypto.kt Outdated
@CDRussell CDRussell force-pushed the feature/craig/sync_3p_browser_chat_sync_foundation branch from cfc8a4d to 31d26c9 Compare May 26, 2026 13:28
@CDRussell CDRussell requested a review from cmonfortep May 26, 2026 13:45
Copy link
Copy Markdown
Contributor

@cmonfortep cmonfortep left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing happy path works, and approving as this is first version and nothing blocking.

There are a few things we need to discuss and decide next steps, so they might be easier on follow up PRs. Not specific suggestions as they rely on specifications and here we are just doing foundation work:

  • I'm interested on error handling and how to clear store. Right now we have a few new endpoints, they can error, or BE can return some specific errors. It's not clear how to react to each of them.
  • I'm curious what's the plan after enabling the flag for accounts that already exist. They won't have credential Id, SP, etc.


is Success -> {
syncStore.storeCredentials(account.userId, deviceId, deviceName, account.primaryKey, account.secretKey, result.data.token)
if (syncFeature.canUseV2ConnectFlow().isEnabled()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should move this above storeCredentials or inside it. storeCrendentials will emit signed in and best if all the store has all the expected data before emit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot; makes sense to re-order as you propose.


syncStore.storeCredentials(userId, deviceId, deviceName, preLogin.primaryKey, decryptResult.decryptedData, result.data.token)

if (syncFeature.canUseV2ConnectFlow().isEnabled()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should move this above storeCredentials or inside it. storeCrendentials will emit signed in and best if all the store has all the expected data before emit.

I think is better to compute values before, and passing new params to the method so we can do the write internally.

val privateKeyBase64: String,
)

@ContributesBinding(AppScope::class)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add @SingleInstanceIn(AppScope::class) since this is stateless and it can be a singleton

val builder = Jwts.builder()
if (kid != null) builder.header().keyId(kid).and()
return builder
.content(plaintext, JWE_CONTENT_TYPE_OCTET_STREAM)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was raised by claude, posting here for review. Seems not harmful to keep, but checking if intentional (if so, we should guard with test)

jweEncryptSymmetric calls Jwts.builder()...content(plaintext, JWE_CONTENT_TYPE_OCTET_STREAM).... Per jjwt's API, the
two-argument .content(bytes, contentType) form sets the cty header. The TD reference envelope header in tdTest3 is:
{"alg":"dir","enc":"A256GCM","kid":"ddg"}
— no cty. Android's produced header is likely:
{"alg":"dir","enc":"A256GCM","kid":"ddg","cty":"application/octet-stream"}

This isn't a decryption problem — the protected-header bytes are AAD for AES-GCM, and the decryptor uses the
received bytes (not an expected shape). So FE decrypting an Android-produced envelope will compute the same AAD
Android wrote, and decryption succeeds, if the FE impl tolerates unknown headers (which RFC 7516 requires).

But: tdTest3_envelopeHeaderHasExpectedFields only asserts alg/enc/kid are present + segment count = 5. It doesn't
assert cty is absent, nor that the keyset is exactly those three. The "Android-produces an envelope FE can decrypt"
direction isn't unit-tested anywhere — only verified at integration level via the PR's manual test plan with a
3rd-party browser.

Two cheap improvements:

  • Switch to single-argument content(bytes) so jjwt doesn't emit cty, making Android's header byte-for-byte identical
    to the TD reference.
  • Tighten tdTest3_envelopeHeaderHasExpectedFields to assert the exact keyset (e.g.
    assertEquals(setOf("alg","enc","kid"), header.keys().asSequence().toSet())) so any future header drift is caught.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it shouldn't matter, but that dropping cty would be fine. as long as it works against the reference impl i'm good with it

private const val JWE_CONTENT_TYPE_OCTET_STREAM = "application/octet-stream"

interface SyncJweCrypto {
fun generateRsaKeyPair(): RsaKeyPair
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a @workerthread annotation on generateRsaKeyPair() (or the whole interface)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 whole interface works for me


val existing = when (val r = syncApi.getAccessCredentials(token)) {
is Success -> r.data.find { it.id == CREDENTIAL_ID_3PARTY } ?: run {
logcat { "Sync-ScopedToken: no 3party credential on server — nothing to refresh" }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the server says "no 3party credential exists" but syncStore.scopedPassword is non-null locally, the local state is stale. A subsequent
getRecoveryCode() would emit a recovery code that no browser could use to sync with the account (the credential is gone server-side).

Probably should clear local SP on this branch:
syncStore.scopedPassword = null
return Success(false)

companion object {
private val moshi = Moshi.Builder().build()
val recoveryCodeAdapter: JsonAdapter<LinkCode> = moshi.adapter(LinkCode::class.java)
val thirdPartyRecoveryCodeAdapter: JsonAdapter<ThirdPartyRecoveryCodeWrapper> = moshi.adapter(ThirdPartyRecoveryCodeWrapper::class.java)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unused as you have the lazy adapter in ThirdPartyCredentialManager

return Success(base64UrlStringToStandardBase64(decryptedBase64Url))
}

override fun refresh(): Result<Boolean> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is refresh only expected to refresh scoped password? or also the keys? while this is documented, I'd consider adding it into the name. But I'm nor sure if SP and keys should be in sync, meaning they need to be refreshed at the same time.

// ddg-side wrap: libsodium-secretbox(privateKey, secretKey), base64url-encoded.
// Matches duck.ai's EncryptWithSyncMasterKeyHandler so native clients read keys uniformly.
val privateKeyBytes = kotlin.runCatching {
Base64.decode(rsaKeyPair.privateKeyBase64, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think will be best if we can reuse Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING and give it a name. Also to understand why sometimes we do Base64.NO_WRAP).applyUrlSafetyFromB64()

Copy link
Copy Markdown
Member Author

@CDRussell CDRussell May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summarizing from our call for posterity:

depends what you're starting out with, whether you have a plain string needing encoded or already starting out with a base64 (not base64url) encoded string:

  • applyUrlSafetyFromB64 was added for when we already receive a base64 encoded string that we want to take that one step further and convert to base64url encoding
  • if you're starting with the plain string rather than a base64-encoded one already, this should one-shot it: Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING

val encryptedSp = thirdParty?.encryptedCredential
if (encryptedSp != null) {
// Decrypt with the DDG MEK; convert wire base64url to standard base64 for storage.
val decryptedSp = kotlin.runCatching {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the logic related to SP decrypt should live inside ThirdPartyCredentialManager, which is where you already have similar logic. we should expose a method for that.

Asked claude recommendation for this:

Better shape: have the manager expose applyLoginResponse(response, primaryKey, userId) that handles SP decrypt, keys filter, and (in the
proposed atomic-refactor world) returns a V2Credentials bundle. The repo then:
val v2 = thirdPartyCredentialManager.buildV2FromLoginResponse(result.data, preLogin.primaryKey, userId)
syncStore.storeCredentials(userId, deviceId, deviceName, preLogin.primaryKey, decryptResult.decryptedData, result.data.token, v2 = v2)

This consolidates the SP-decrypt logic, makes the manager the single source of truth, and naturally enables the atomic-write refactor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants