Skip to content

Commit db6a0bf

Browse files
committed
Sync exchange v2: Fix password deriation mismatch
1 parent 39ebade commit db6a0bf

2 files changed

Lines changed: 51 additions & 96 deletions

File tree

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

Lines changed: 30 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,20 @@ interface SyncAccountRepository {
112112
/**
113113
* Joins this device to an existing account using a 3party recovery code, executing the
114114
* "Native joining a 3party account" upgrade flow per the Unified Algorithm (Asana
115-
* 1214739740392701). Two network calls:
115+
* 1214739740392701). Three network calls:
116116
*
117117
* 1. POST /sync/login with scope=ai_chats — authenticates against the existing 3party
118118
* credential and returns a short-lived token + the protected keys to re-wrap.
119119
* 2. POST /access-credentials/ddg — mints a fresh DDG credential on the account, attached
120120
* alongside the existing 3party entry (which gets decorated with encrypted_3party_credential
121121
* so future ddg-side logins can re-derive SP).
122+
* 3. POST /sync/login (unscoped) — commits the new credential (without this inside the 5-min
123+
* TTL the BE auto-removes it), exchanges the ai_chats-only token for the unrestricted
124+
* ddg-scoped token, and atomically writes the SyncStore via [performLogin].
122125
*
123126
* On success the device ends in a normal Native signed-in state: full primaryKey / secretKey
124-
* populated, credentialId=ddg, scopedPassword populated with SP. SyncStore is written
125-
* atomically only after both network calls succeed; observers never see an intermediate state.
127+
* populated, credentialId=ddg, scopedPassword populated with SP. SyncStore is written only
128+
* after all three network calls succeed; observers never see an intermediate state.
126129
*/
127130
fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>
128131

@@ -750,16 +753,12 @@ class AppSyncAccountRepository @Inject constructor(
750753
val hashedPasswordForReauth = kotlin.runCatching { syncJweCrypto.hkdfDeriveBase64Url(spStandardB64, hkdfSalt, "Password", 32) }
751754
.getOrElse { return Error(reason = "Upgrade: failed to derive 3party hashed_password: ${it.message}") }
752755

753-
// credentialHashedPassword for the new DDG credential — HKDF(MP, salt=user_id, info="Password", 32).
754-
// The server stores twice_hash(this) and validates future /login submissions against it.
755-
// Pinned against the TD's test1 vector in SyncJweCryptoTdVectorsTest.
756-
val credentialHashedPassword = kotlin.runCatching {
757-
syncJweCrypto.hkdfDeriveBase64Url(newDdgKeys.primaryKey, hkdfSalt, "Password", 32)
758-
}.getOrElse { return Error(reason = "Upgrade: failed to derive new DDG credential_hashed_password: ${it.message}") }
759-
756+
// credentialHashedPassword for the new DDG credential — libsodium/Argon2, the same derivation
757+
// performCreateAccount uses for standard signup. Keeping every ddg credential libsodium-hashed
758+
// means the standard performLogin path works for both signup-created and upgrade-created
760759
val request = CreateAccessCredentialRequest(
761760
hashedPassword = hashedPasswordForReauth,
762-
credentialHashedPassword = credentialHashedPassword,
761+
credentialHashedPassword = newDdgKeys.passwordHash,
763762
protectedEncryptionKey = newDdgKeys.protectedSecretKey,
764763
encrypted3partyCredential = encryptedThreePartyCredential,
765764
keys = rewrappedKeysList.ifEmpty { null },
@@ -843,52 +842,37 @@ class AppSyncAccountRepository @Inject constructor(
843842
return postResult.copy(reason = "JoinFrom3party: ${postResult.reason}")
844843
}
845844

846-
// Step 7a — Flow 4 native login with the new ddg credential. This is the BE-side commit
847-
// for the credential just POSTed: without a login inside the 5-minute TTL the server
848-
// auto-removes the credential. It also yields the unrestricted ddg-scoped token that
849-
// device-management endpoints need (the 3party login token at this point is ai_chats-only).
850-
val ddgLoginResponse = when (
851-
val ddgLogin = performDdgLoginForUpgrade(
845+
// Step 7 — Native login as the new ddg credential. Required: without a login inside the
846+
// 5-minute TTL the BE auto-removes the credential, and the 3party token from Step 2 is
847+
// ai_chats-scoped so it can't drive device-management endpoints.
848+
val ddgLoginResult = retryingOnTransientError {
849+
performLogin(
852850
userId = parsed.userId,
853851
deviceId = deviceId,
854852
deviceName = deviceName,
855853
primaryKey = upgradePackage.newDdgKeys.primaryKey,
856854
)
857-
) {
858-
is Error -> {
859-
if (ddgLogin.code == API_CODE.INVALID_LOGIN_CREDENTIALS.code) {
860-
logcat(ERROR) {
861-
"Sync-ScopedToken: ddg login after upgrade returned 401 — credential 5-minute TTL likely expired"
862-
}
863-
} else {
864-
logcat(ERROR) { "Sync-ScopedToken: ddg login after upgrade failed: ${ddgLogin.reason}" }
855+
}
856+
if (ddgLoginResult is Error) {
857+
if (ddgLoginResult.code == API_CODE.INVALID_LOGIN_CREDENTIALS.code) {
858+
logcat(ERROR) {
859+
"Sync-ScopedToken: ddg login after upgrade returned 401 — credential 5-minute TTL likely expired"
865860
}
866-
return ddgLogin.copy(reason = "JoinFrom3party: post-upgrade ddg login failed: ${ddgLogin.reason}")
861+
} else {
862+
logcat(ERROR) { "Sync-ScopedToken: ddg login after upgrade failed: ${ddgLoginResult.reason}" }
867863
}
868-
is Success -> ddgLogin.data
864+
return ddgLoginResult.copy(reason = "JoinFrom3party: post-upgrade ddg login failed: ${ddgLoginResult.reason}")
869865
}
870866

871-
// Step 7b — atomic SyncStore commit. Everything above has either failed (returning Error
872-
// without mutating SyncStore) or succeeded. External observers see the device as
873-
// pre-upgrade until this block runs.
874-
val spStandardB64 = kotlin.runCatching { base64UrlStringToStandardBase64(parsed.secret) }
875-
.getOrElse { return Error(reason = "JoinFrom3party: failed to decode SP: ${it.message}") }
876-
syncStore.storeCredentials(
877-
userId = parsed.userId,
878-
deviceId = deviceId,
879-
deviceName = deviceName,
880-
primaryKey = upgradePackage.newDdgKeys.primaryKey,
881-
secretKey = upgradePackage.newDdgKeys.secretKey,
882-
token = ddgLoginResponse.token,
883-
)
884-
syncStore.credentialId = CREDENTIAL_ID_DDG
885-
syncStore.scopedPassword = ScopedPassword(spStandardB64)
867+
// Defensive: performLogin will also set scopedPassword if the BE echoes back the new
868+
// credential's encrypted_3party_credential, but we have the SP in hand from the pasted
869+
// recovery code. Writing it locally guarantees scopedPassword is populated independent
870+
// of the server response shape.
871+
kotlin.runCatching { base64UrlStringToStandardBase64(parsed.secret) }
872+
.onSuccess { syncStore.scopedPassword = ScopedPassword(it) }
873+
.onFailure { logcat(ERROR) { "Sync-ScopedToken: failed to write local SP after upgrade: ${it.message}" } }
886874

887875
logcat { "Sync-ScopedToken: 3party→ddg upgrade complete; account joined as ddg" }
888-
889-
appCoroutineScope.launch(dispatcherProvider.io()) {
890-
syncEngine.triggerSync(ACCOUNT_LOGIN)
891-
}
892876
return Success(true)
893877
}
894878

@@ -1230,54 +1214,6 @@ class AppSyncAccountRepository @Inject constructor(
12301214
)
12311215
}
12321216

1233-
/**
1234-
* This login acts as the BE-side *commit* for the newly minted credential —
1235-
* without it, the server removes the credential after a 5-minute TTL. It is also what
1236-
* yields an unrestricted ddg-scoped token; the 3party token from [performThirdPartyLogin]
1237-
* is `scope=ai_chats` and cannot drive device-management endpoints.
1238-
*
1239-
* Returns the [LoginResponse] for the caller to commit alongside the new local key
1240-
* material.
1241-
*/
1242-
private fun performDdgLoginForUpgrade(
1243-
userId: String,
1244-
deviceId: String,
1245-
deviceName: String,
1246-
primaryKey: String,
1247-
): Result<LoginResponse> {
1248-
// HKDF-derived hashed_password matching the `credentialHashedPassword` we POSTed in
1249-
// [buildThirdPartyUpgradePackage] — the upgrade-created ddg credential was registered with
1250-
// the v2 cross-platform algorithm (Encryption Algorithms TD: HKDF(MP, salt=user_id,
1251-
// info="Password", 32)), NOT v1's, using nativeLib.prepareForLogin(...) would 401
1252-
val hkdfSalt = userId.toByteArray(Charsets.UTF_8)
1253-
val hashedPassword = kotlin.runCatching {
1254-
syncJweCrypto.hkdfDeriveBase64Url(primaryKey, hkdfSalt, "Password", 32)
1255-
}.getOrElse { return Error(reason = "Upgrade ddg login: derive hashed_password failed: ${it.message}") }
1256-
1257-
val deviceType = syncDeviceIds.deviceType()
1258-
val encryptedDeviceName = kotlin.runCatching {
1259-
nativeLib.encryptData(deviceName, primaryKey).also {
1260-
it.checkResult("Upgrade ddg login: encrypt device name failed")
1261-
}.encryptedData
1262-
}.getOrElse { return it.asErrorResult() }
1263-
val encryptedDeviceType = kotlin.runCatching {
1264-
nativeLib.encryptData(deviceType.deviceFactor, primaryKey).also {
1265-
it.checkResult("Upgrade ddg login: encrypt device type failed")
1266-
}.encryptedData
1267-
}.getOrElse { return it.asErrorResult() }
1268-
1269-
return retryingOnTransientError {
1270-
syncApi.login(
1271-
userID = userId,
1272-
hashedPassword = hashedPassword,
1273-
deviceId = deviceId,
1274-
deviceName = encryptedDeviceName,
1275-
deviceType = encryptedDeviceType,
1276-
scope = null,
1277-
)
1278-
}
1279-
}
1280-
12811217
private fun performLogin(
12821218
userId: String,
12831219
deviceId: String,

sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,20 @@ class AppSyncAccountRepositoryTest {
13241324
)
13251325
}
13261326

1327+
@Test
1328+
fun whenJoinAccountFromThirdPartySucceedsThenScopedPasswordIsWrittenFromLocalRecoveryCode() {
1329+
// Defensive: even if the BE response omits encrypted_3party_credential (so performLogin's
1330+
// server-decoded SP path is a no-op), the join should still leave scopedPassword populated
1331+
// from the locally-parsed recovery code.
1332+
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
1333+
1334+
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
1335+
1336+
assertEquals(Success(true), result)
1337+
val expectedSp = base64UrlStringToStandardBase64("rUzlGqLLlbonAC_zIeh1nrCmuDsDAn6UooUUDz-6x3o")
1338+
verify(syncStore).scopedPassword = ScopedPassword(expectedSp)
1339+
}
1340+
13271341
@Test
13281342
fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() {
13291343
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
@@ -1524,7 +1538,7 @@ class AppSyncAccountRepositoryTest {
15241538
private val step7aLoginSuccess = Success(
15251539
LoginResponse(
15261540
token = "ddg_token_step7a",
1527-
protected_encryption_key = null,
1541+
protected_encryption_key = protectedEncryptionKey,
15281542
devices = emptyList(),
15291543
accessCredentials = null,
15301544
keys = null,
@@ -1551,6 +1565,11 @@ class AppSyncAccountRepositoryTest {
15511565
whenever(syncJweCrypto.jweEncryptSymmetric(any(), any(), any())).thenReturn("encrypted_3party_credential")
15521566
whenever(nativeLib.generateAccountKeys(userId = eq(userId), password = anyString())).thenReturn(accountKeys)
15531567

1568+
// Step 7's performLogin uses libsodium derivations on the new ddg primaryKey, then decrypts
1569+
// the protected_encryption_key the server echoes back to recover the secretKey.
1570+
whenever(nativeLib.prepareForLogin(primaryKey = primaryKey)).thenReturn(validLoginKeys)
1571+
whenever(nativeLib.decrypt(encryptedData = protectedEncryptionKey, secretKey = stretchedPrimaryKey)).thenReturn(decryptedSecretKey)
1572+
15541573
// Step 2 response; optionally includes ddg to drive the "already upgraded" path.
15551574
val accessCredentialsInResponse = buildList {
15561575
add(AccessCredentialEntry(id = "3party", scope = "ai_chats"))
@@ -1567,7 +1586,7 @@ class AppSyncAccountRepositoryTest {
15671586
val step7aResponse = step7aResult ?: Success(
15681587
LoginResponse(
15691588
token = "ddg_token_step7a",
1570-
protected_encryption_key = null,
1589+
protected_encryption_key = protectedEncryptionKey,
15711590
devices = emptyList(),
15721591
accessCredentials = null,
15731592
keys = null,

0 commit comments

Comments
 (0)