@@ -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 ,
0 commit comments