From 5029fbface577c9f46764108a66138e2d2ff4784 Mon Sep 17 00:00:00 2001 From: Barry Deen Date: Fri, 15 May 2026 22:34:22 -0400 Subject: [PATCH] feat(auth): require recovery PIN and strip identifying metadata from Drive backups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google-account-only custody meant anyone with the Google login could decrypt the nsec; the filename leaked the npub to Drive; the chooser's profile prefetch told relays which npubs were on this device. - Derive the backup key from PBKDF2-HMAC-SHA256(PIN, salt=HMAC(sub)) with 600k iterations. PIN is a 4–8 digit numeric set during sign-in with a confirm step; mismatch and wrong-PIN paths surface inline. - Pull `sub` from the signed ID token's JWT instead of GoogleIdTokenCredential.id (which is the email, not stable across Workspace renames). - Use opaque `wisp_bk_.bin` filenames and recover the npub by decrypting. Drop the delete-then-upload race since there's no longer a replace path. - Seed the chooser's profile REQ with 10 decoy pubkeys pulled from a popular relay so observers can't pick the real backups out of the query. --- .../kotlin/com/wisp/app/auth/BackupCrypto.kt | 43 ++- .../com/wisp/app/auth/DriveBackupService.kt | 42 +- .../com/wisp/app/auth/GoogleSignInManager.kt | 45 ++- .../wisp/app/ui/screen/GoogleAuthScreen.kt | 224 +++++++++-- .../wisp/app/viewmodel/GoogleAuthViewModel.kt | 358 +++++++++++++----- app/src/main/res/values/strings.xml | 16 +- 6 files changed, 555 insertions(+), 173 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/auth/BackupCrypto.kt b/app/src/main/kotlin/com/wisp/app/auth/BackupCrypto.kt index 5693548..94a2d45 100644 --- a/app/src/main/kotlin/com/wisp/app/auth/BackupCrypto.kt +++ b/app/src/main/kotlin/com/wisp/app/auth/BackupCrypto.kt @@ -4,25 +4,50 @@ import com.wisp.app.nostr.Nip44 import com.wisp.app.nostr.hexToByteArray import com.wisp.app.nostr.toHex import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec /** * Backup encryption for the Google Drive nsec backup blob. * - * The encryption key is derived from the Google ID token's `sub` claim, which is - * stable per (Google account, OAuth client). This means the same Google account - * always produces the same key, enabling passphrase-less restore. Tradeoff: anyone - * with access to the Google account can decrypt the backup. + * Two factors gate decryption: + * 1. The Google ID token's `sub` claim — stable per Google account, used + * only to derive a per-account salt. By itself it is not a secret; we + * assume Google can see the user's `sub`. + * 2. A 4–8 digit numeric PIN the user sets at first sign-in. PBKDF2 with + * 600k iterations stretches it. ~26 bits of raw PIN entropy is not + * enough on its own, but combined with Drive access control and the + * slow KDF an attacker needs ~weeks of compute *after* compromising + * the Google account. * - * The encrypted payload is just NIP-44 v2 over the hex-encoded nsec, with the - * derived key in place of the usual ECDH conversation key. Reuses Nip44 verbatim - * so we don't introduce new crypto code. + * The encrypted payload is NIP-44 v2 over the hex-encoded nsec, with the + * PBKDF2 output in place of the usual ECDH conversation key. */ object BackupCrypto { - private const val SALT = "wisp-google-backup-v1" + private const val SALT = "wisp-google-backup" + private const val PBKDF2_ITERATIONS = 600_000 + private const val KEY_BITS = 256 - fun deriveBackupKey(sub: String): ByteArray { + fun isValidPin(pin: String): Boolean = + pin.length in 4..8 && pin.all { it.isDigit() } + + fun deriveBackupKey(sub: String, pin: String): ByteArray { require(sub.isNotEmpty()) { "Google sub claim must not be empty" } + require(isValidPin(pin)) { "PIN must be 4–8 digits" } + + val salt = perAccountSalt(sub) + val spec = PBEKeySpec(pin.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_BITS) + return try { + SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(spec) + .encoded + } finally { + spec.clearPassword() + } + } + + private fun perAccountSalt(sub: String): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(SALT.toByteArray(Charsets.UTF_8), "HmacSHA256")) return mac.doFinal(sub.toByteArray(Charsets.UTF_8)) diff --git a/app/src/main/kotlin/com/wisp/app/auth/DriveBackupService.kt b/app/src/main/kotlin/com/wisp/app/auth/DriveBackupService.kt index 1003781..57aafdd 100644 --- a/app/src/main/kotlin/com/wisp/app/auth/DriveBackupService.kt +++ b/app/src/main/kotlin/com/wisp/app/auth/DriveBackupService.kt @@ -30,10 +30,10 @@ class DriveAuthorizationExpiredException( /** * Minimal Drive REST v3 client targeted at the user's appDataFolder. * - * One backup file per Nostr account. Filenames follow `wisp_nsec_.bin` - * so we can list every account on the user's Drive and surface the npub - * without downloading each file. Legacy single-file backups (`wisp_nsec.bin`, - * from before multi-account support) are also surfaced in the list. + * Filenames are opaque (`wisp_bk_.bin`) so Drive cannot see the user's + * npub — that link would otherwise let anyone with Google account access tie + * the Nostr identity to the Google identity. The npub is recovered by + * decrypting the file with the user's PIN-derived key. */ class DriveBackupService( private val httpClient: OkHttpClient = OkHttpClient() @@ -49,20 +49,10 @@ class DriveBackupService( } } - data class BackupFile(val fileId: String, val name: String) { - /** `npub1…` parsed from the filename, or null for the legacy unnamed backup. */ - val npubFromName: String? - get() = when { - name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_SUFFIX) -> { - name.removePrefix(BACKUP_PREFIX).removeSuffix(BACKUP_SUFFIX) - .takeIf { it.isNotEmpty() && it.startsWith("npub1") } - } - else -> null - } - } + data class BackupFile(val fileId: String, val name: String) suspend fun listBackups(accessToken: String): List = withContext(Dispatchers.IO) { - val nameQuery = "name = '$LEGACY_FILENAME' or name contains '$BACKUP_PREFIX'" + val nameQuery = "name contains '$BACKUP_PREFIX'" val url = "https://www.googleapis.com/drive/v3/files" + "?spaces=appDataFolder" + "&q=" + java.net.URLEncoder.encode(nameQuery, "UTF-8") + @@ -87,6 +77,7 @@ class DriveBackupService( val obj = element as? JsonObject ?: return@mapNotNull null val id = obj["id"]?.jsonPrimitive?.content ?: return@mapNotNull null val name = obj["name"]?.jsonPrimitive?.content ?: return@mapNotNull null + if (!name.startsWith(BACKUP_PREFIX) || !name.endsWith(BACKUP_SUFFIX)) return@mapNotNull null BackupFile(id, name) } } @@ -110,19 +101,13 @@ class DriveBackupService( } /** - * Creates a new backup file in appDataFolder. If a backup for the same npub - * already exists, deletes it first so we keep one file per account rather - * than accumulating duplicate revisions. + * Creates a new backup file with a fresh random UUID filename. Each call + * creates a distinct file — there is no replace path, which sidesteps the + * delete-then-upload race that an in-place update would introduce. */ - suspend fun uploadBackup(accessToken: String, npub: String, payload: String) = + suspend fun uploadBackup(accessToken: String, payload: String) = withContext(Dispatchers.IO) { - require(npub.startsWith("npub1")) { "npub must be bech32-encoded" } - val filename = "$BACKUP_PREFIX$npub$BACKUP_SUFFIX" - - // Remove any existing file with the same name to avoid duplicates. - listBackups(accessToken).filter { it.name == filename }.forEach { existing -> - deleteBackup(accessToken, existing.fileId) - } + val filename = "$BACKUP_PREFIX${UUID.randomUUID()}$BACKUP_SUFFIX" val metadata = """{"name":"$filename","parents":["$APP_DATA_FOLDER"]}""" val boundary = "wisp-${UUID.randomUUID()}" @@ -166,8 +151,7 @@ class DriveBackupService( companion object { private const val APP_DATA_FOLDER = "appDataFolder" - private const val BACKUP_PREFIX = "wisp_nsec_" + private const val BACKUP_PREFIX = "wisp_bk_" private const val BACKUP_SUFFIX = ".bin" - private const val LEGACY_FILENAME = "wisp_nsec.bin" } } diff --git a/app/src/main/kotlin/com/wisp/app/auth/GoogleSignInManager.kt b/app/src/main/kotlin/com/wisp/app/auth/GoogleSignInManager.kt index f17d47c..48f4e3d 100644 --- a/app/src/main/kotlin/com/wisp/app/auth/GoogleSignInManager.kt +++ b/app/src/main/kotlin/com/wisp/app/auth/GoogleSignInManager.kt @@ -2,6 +2,7 @@ package com.wisp.app.auth import android.app.PendingIntent import android.content.Context +import android.util.Base64 import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.result.IntentSenderRequest @@ -20,15 +21,20 @@ import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingExcept import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine /** * Two-step Google sign-in: - * 1. Credential Manager returns a GoogleIdTokenCredential whose `id` field is - * stable per (Google account, OAuth client). We treat it as the `sub` and - * derive the backup encryption key from it. + * 1. Credential Manager returns a GoogleIdTokenCredential. We pull the `sub` + * claim out of its signed JWT — that's the stable Google account ID + * (`GoogleIdTokenCredential.id` is the email, which can change for + * workspace renames). Play Services has already validated the JWT, so we + * only decode it; we don't re-verify the signature. * 2. AuthorizationClient requests an OAuth access token with the * drive.appdata scope. May return a token directly, or a PendingIntent * requiring the user to grant consent the first time. @@ -42,18 +48,13 @@ class GoogleSignInManager( data class GoogleAuthResult( val sub: String, - val accessToken: String, - val email: String? + val accessToken: String ) suspend fun signIn(activity: ComponentActivity): GoogleAuthResult { val sub = getGoogleSubFromCredentialManager(activity) val accessToken = getDriveAccessToken(activity) - return GoogleAuthResult( - sub = sub, - accessToken = accessToken, - email = sub.takeIf { it.contains("@") } - ) + return GoogleAuthResult(sub = sub, accessToken = accessToken) } private suspend fun getGoogleSubFromCredentialManager(activity: ComponentActivity): String { @@ -85,7 +86,28 @@ class GoogleSignInManager( } catch (e: GoogleIdTokenParsingException) { throw GoogleSignInException("Failed to parse Google ID token", e) } - return parsed.id + return extractSubFromJwt(parsed.idToken) + } + + private fun extractSubFromJwt(idToken: String): String { + val parts = idToken.split('.') + if (parts.size < 2) { + throw GoogleSignInException("Malformed ID token: expected at least two JWT segments") + } + val payloadJson = try { + String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)) + } catch (e: IllegalArgumentException) { + throw GoogleSignInException("Malformed ID token payload encoding", e) + } + val sub = try { + jsonParser.parseToJsonElement(payloadJson).jsonObject["sub"]?.jsonPrimitive?.content + } catch (e: Exception) { + throw GoogleSignInException("ID token payload is not valid JSON", e) + } + if (sub.isNullOrBlank()) { + throw GoogleSignInException("ID token missing sub claim") + } + return sub } /** @@ -161,6 +183,7 @@ class GoogleSignInManager( companion object { private const val TAG = "GoogleSignInManager" private const val DRIVE_APPDATA_SCOPE = "https://www.googleapis.com/auth/drive.appdata" + private val jsonParser = Json { ignoreUnknownKeys = true } } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/GoogleAuthScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/GoogleAuthScreen.kt index 032b597..5b5c0db 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/GoogleAuthScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/GoogleAuthScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button @@ -29,8 +31,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade @@ -38,18 +42,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -139,10 +151,31 @@ fun GoogleAuthScreen( ) } + is GoogleAuthViewModel.State.SetupPin -> SetupPinBlock( + state = s, + onSubmitEntry = { pin -> viewModel.submitSetupPinEntry(pin) }, + onSubmitConfirm = { pin -> + val activity = context as? ComponentActivity ?: return@SetupPinBlock + viewModel.submitSetupPinConfirm(pin, activity) + }, + onBackToEntry = { viewModel.backToSetupEntry() } + ) + + is GoogleAuthViewModel.State.EnterPinForRestore -> RestorePinBlock( + attemptFailed = s.attemptFailed, + onSubmit = { pin -> + val activity = context as? ComponentActivity ?: return@RestorePinBlock + viewModel.submitRestorePin(pin, activity) + } + ) + is GoogleAuthViewModel.State.Choose -> ChooseBlock( backups = s.backups, onRestore = { viewModel.restoreAccount(it.fileId) }, - onCreate = { viewModel.createNewAccount() } + onCreate = { + val activity = context as? ComponentActivity ?: return@ChooseBlock + viewModel.createAnotherAccount(activity) + } ) is GoogleAuthViewModel.State.Error -> { @@ -225,43 +258,183 @@ private fun LoadingBlock(label: String) { } @Composable -private fun ChooseBlock( - backups: List, - onRestore: (GoogleAuthViewModel.BackupSummary) -> Unit, - onCreate: () -> Unit +private fun SetupPinBlock( + state: GoogleAuthViewModel.State.SetupPin, + onSubmitEntry: (String) -> Unit, + onSubmitConfirm: (String) -> Unit, + onBackToEntry: () -> Unit ) { - val titleRes = if (backups.isEmpty()) - R.string.google_auth_choose_title_empty - else - R.string.google_auth_choose_title_with_backups + var pin by remember(state.step) { mutableStateOf("") } + val isEntry = state.step == GoogleAuthViewModel.SetupStep.Enter Text( - text = stringResource(titleRes), + text = stringResource( + if (isEntry) R.string.pin_setup_title else R.string.pin_confirm_title + ), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center ) Spacer(Modifier.height(8.dp)) Text( text = stringResource( - if (backups.isEmpty()) R.string.google_auth_choose_body_empty - else R.string.google_auth_choose_body_with_backups + if (isEntry) R.string.pin_setup_body else R.string.pin_confirm_body ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) + if (isEntry) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.pin_setup_warning), + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } - if (backups.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 280.dp) + val errorText: String? = when { + isEntry && state.mismatch -> stringResource(R.string.pin_mismatch) + else -> null + } + + Spacer(Modifier.height(20.dp)) + PinField( + value = pin, + onChange = { pin = it.filter { ch -> ch.isDigit() }.take(8) }, + errorText = errorText, + onSubmit = { + if (isEntry) onSubmitEntry(pin) else onSubmitConfirm(pin) + } + ) + + Spacer(Modifier.height(20.dp)) + Button( + onClick = { if (isEntry) onSubmitEntry(pin) else onSubmitConfirm(pin) }, + enabled = pin.length in 4..8, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.btn_continue)) + } + + if (!isEntry) { + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onBackToEntry, + modifier = Modifier.fillMaxWidth() ) { - items(backups, key = { it.npub }) { backup -> - BackupRow(backup = backup, onClick = { onRestore(backup) }) - Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.btn_back)) + } + } +} + +@Composable +private fun RestorePinBlock( + attemptFailed: Boolean, + onSubmit: (String) -> Unit +) { + var pin by remember { mutableStateOf("") } + + Text( + text = stringResource(R.string.pin_restore_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pin_restore_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(20.dp)) + PinField( + value = pin, + onChange = { pin = it.filter { ch -> ch.isDigit() }.take(8) }, + errorText = if (attemptFailed) stringResource(R.string.pin_restore_incorrect) else null, + onSubmit = { onSubmit(pin) } + ) + + Spacer(Modifier.height(20.dp)) + Button( + onClick = { onSubmit(pin) }, + enabled = pin.length in 4..8, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.btn_unlock)) + } +} + +@Composable +private fun PinField( + value: String, + onChange: (String) -> Unit, + errorText: String?, + onSubmit: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = value, + onValueChange = onChange, + singleLine = true, + isError = errorText != null, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + keyboardActions = KeyboardActions(onDone = { + keyboard?.hide() + onSubmit() + }), + placeholder = { Text(stringResource(R.string.pin_placeholder)) }, + supportingText = { + if (errorText != null) { + Text(text = errorText, color = MaterialTheme.colorScheme.error) + } else { + Text(text = stringResource(R.string.pin_too_short)) } + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) +} + +@Composable +private fun ChooseBlock( + backups: List, + onRestore: (GoogleAuthViewModel.BackupSummary) -> Unit, + onCreate: () -> Unit +) { + Text( + text = stringResource(R.string.google_auth_choose_title_with_backups), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.google_auth_choose_body_with_backups), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + ) { + items(backups, key = { it.npub }) { backup -> + BackupRow(backup = backup, onClick = { onRestore(backup) }) + Spacer(Modifier.height(8.dp)) } } @@ -273,12 +446,7 @@ private fun ChooseBlock( onClick = onCreate, modifier = Modifier.fillMaxWidth() ) { - Text( - stringResource( - if (backups.isEmpty()) R.string.google_auth_create_first - else R.string.google_auth_create_another - ) - ) + Text(stringResource(R.string.google_auth_create_another)) } } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/GoogleAuthViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/GoogleAuthViewModel.kt index a1b5c77..3c8bb4d 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/GoogleAuthViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/GoogleAuthViewModel.kt @@ -17,9 +17,12 @@ import com.wisp.app.repo.FiatPreferences import com.wisp.app.repo.KeyRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -30,22 +33,27 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.util.Collections import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine private const val TAG = "GoogleAuth" /** * Orchestrates the "Continue with Google" flow: - * 1. Sign in via GoogleSignInManager → derive backup key from sub claim. - * 2. List every backup in the user's Drive appDataFolder. Each filename - * embeds the npub (`wisp_nsec_.bin`) so we can show the chooser - * without downloading every file. - * 3. UI shows a list of restorable accounts (if any) plus a "Create new - * account" option that's always available — users can keep adding new - * Nostr identities to the same Google account's backup space. - * - * The plaintext nsec only leaves Drive when the user actually picks Restore; - * generation only happens when they pick Create. + * 1. Sign in via GoogleSignInManager → keep the JWT `sub` claim around. + * 2. List backups in the user's appData folder. Filenames are opaque + * (`wisp_bk_.bin`); the npub is recovered by decrypting. + * 3. Branch on what we find: + * - Files exist → prompt for the user's PIN, try to decrypt each file, + * and show the recovered accounts in the chooser. Files that fail to + * decrypt are treated as belonging to a different (or wrong) PIN. + * - No files → walk the user through setting a new PIN (enter, then + * confirm) and create their first account. + * 4. From the chooser the user can restore one of the listed accounts or + * add another account under the same Google login (PIN already known). */ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { private val keyRepo = KeyRepository(app) @@ -60,10 +68,19 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { val picture: String? = null ) + enum class SetupStep { Enter, Confirm } + sealed class State { object Idle : State() object SigningIn : State() object CheckingDrive : State() + + /** Backups were found; the user must enter their existing PIN. */ + data class EnterPinForRestore(val attemptFailed: Boolean = false) : State() + + /** No backups found; walk the user through choosing a new PIN. */ + data class SetupPin(val step: SetupStep, val mismatch: Boolean = false) : State() + data class Choose(val backups: List) : State() object Working : State() data class Done(val isNewAccount: Boolean) : State() @@ -73,8 +90,12 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { private val _state = MutableStateFlow(State.Idle) val state: StateFlow = _state - private var pendingBackupKey: ByteArray? = null + private var pendingSub: String? = null private var pendingAccessToken: String? = null + private var pendingBackupKey: ByteArray? = null + private var pendingFiles: List = emptyList() + private var pendingSetupFirstPin: String? = null + private var signInManager: GoogleSignInManager? = null private var profileFetchJob: Job? = null fun beginSignIn(activity: ComponentActivity, webClientId: String) { @@ -84,49 +105,23 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { return } val manager = GoogleSignInManager(activity.applicationContext, webClientId) + signInManager = manager _state.value = State.SigningIn - Log.d(TAG, "state -> SigningIn") viewModelScope.launch { try { - Log.d(TAG, "calling manager.signIn(activity)…") val result = manager.signIn(activity) - Log.d(TAG, "signIn returned: sub-len=${result.sub.length}, hasToken=${result.accessToken.isNotEmpty()}") - val backupKey = BackupCrypto.deriveBackupKey(result.sub) - pendingBackupKey = backupKey + pendingSub = result.sub pendingAccessToken = result.accessToken _state.value = State.CheckingDrive - Log.d(TAG, "state -> CheckingDrive") - - val files = listBackupsWithRefresh(manager, activity) - val activeToken = pendingAccessToken ?: result.accessToken + val files = listBackupsWithRefresh(activity) + pendingFiles = files Log.d(TAG, "listBackups returned ${files.size} file(s)") - val summaries = files.mapNotNull { file -> - val npub = file.npubFromName ?: try { - // Legacy file with no npub in the filename — decrypt to learn it. - val payload = driveService.downloadBackup(activeToken, file.fileId) - val nsec = BackupCrypto.decryptNsec(payload, backupKey) - Nip19.npubEncode(Keys.xOnlyPubkey(nsec)) - } catch (e: Exception) { - Log.w(TAG, "couldn't resolve npub for ${file.name}; skipping", e) - null - } - npub?.let { - val pubkeyHex = try { - Nip19.npubDecode(it).toHex() - } catch (e: Exception) { - Log.w(TAG, "couldn't decode npub $it", e) - return@let null - } - BackupSummary(fileId = file.fileId, npub = it, pubkeyHex = pubkeyHex) - } - }.distinctBy { it.npub } - - _state.value = State.Choose(summaries) - Log.d(TAG, "state -> Choose with ${summaries.size} restorable account(s)") - if (summaries.isNotEmpty()) { - fetchProfilesInBackground(summaries.map { it.pubkeyHex }) + _state.value = if (files.isEmpty()) { + State.SetupPin(step = SetupStep.Enter) + } else { + State.EnterPinForRestore() } } catch (e: GoogleSignInException) { Log.w(TAG, "GoogleSignInException", e) @@ -138,28 +133,84 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { } } - /** - * Calls `listBackups` with the pending access token; if Drive returns 401, - * clears the stale token from Play Services' cache and re-runs the - * authorization flow (which surfaces a consent prompt when the user has - * revoked Wisp's authorization), then retries once. - */ - private suspend fun listBackupsWithRefresh( - manager: GoogleSignInManager, - activity: ComponentActivity - ): List { - val token = pendingAccessToken ?: error("no pending access token") - return try { - driveService.listBackups(token) - } catch (e: DriveAuthorizationExpiredException) { - Log.w(TAG, "Drive returned 401; clearing stale token and re-authorizing", e) - val fresh = manager.refreshDriveAccessToken(activity, e.staleToken) - pendingAccessToken = fresh - Log.d(TAG, "refresh complete; retrying listBackups") - driveService.listBackups(fresh) + fun submitRestorePin(pin: String, activity: ComponentActivity) { + if (!BackupCrypto.isValidPin(pin)) return + val sub = pendingSub ?: return + val files = pendingFiles + if (files.isEmpty()) return + _state.value = State.Working + viewModelScope.launch { + try { + val key = withContext(Dispatchers.Default) { BackupCrypto.deriveBackupKey(sub, pin) } + + val summaries = files.mapNotNull { file -> + try { + val payload = downloadWithRefresh(activity, file.fileId) + val nsec = withContext(Dispatchers.Default) { + BackupCrypto.decryptNsec(payload, key) + } + val pubkey = Keys.xOnlyPubkey(nsec) + val npub = Nip19.npubEncode(pubkey) + BackupSummary( + fileId = file.fileId, + npub = npub, + pubkeyHex = pubkey.toHex() + ) + } catch (e: Exception) { + Log.d(TAG, "decrypt failed for ${file.name} (likely wrong PIN or unrelated file)", e) + null + } + }.distinctBy { it.npub } + + if (summaries.isEmpty()) { + _state.value = State.EnterPinForRestore(attemptFailed = true) + return@launch + } + + pendingBackupKey = key + _state.value = State.Choose(summaries) + fetchProfilesInBackground(summaries.map { it.pubkeyHex }) + } catch (e: Exception) { + Log.w(TAG, "submitRestorePin failed", e) + _state.value = State.Error(e.message ?: "Failed to check PIN.") + } } } + fun submitSetupPinEntry(pin: String) { + if (!BackupCrypto.isValidPin(pin)) return + pendingSetupFirstPin = pin + _state.value = State.SetupPin(step = SetupStep.Confirm) + } + + fun submitSetupPinConfirm(pin: String, activity: ComponentActivity) { + if (!BackupCrypto.isValidPin(pin)) return + val first = pendingSetupFirstPin + if (first == null || first != pin) { + pendingSetupFirstPin = null + _state.value = State.SetupPin(step = SetupStep.Enter, mismatch = true) + return + } + val sub = pendingSub ?: return + pendingSetupFirstPin = null + _state.value = State.Working + viewModelScope.launch { + try { + val key = withContext(Dispatchers.Default) { BackupCrypto.deriveBackupKey(sub, pin) } + pendingBackupKey = key + createAndStoreNewAccount(activity) + } catch (e: Exception) { + Log.w(TAG, "submitSetupPinConfirm failed", e) + _state.value = State.Error(e.message ?: "Failed to set up PIN.") + } + } + } + + fun backToSetupEntry() { + pendingSetupFirstPin = null + _state.value = State.SetupPin(step = SetupStep.Enter) + } + fun restoreAccount(fileId: String) { Log.d(TAG, "restoreAccount tapped, fileId=$fileId") val key = pendingBackupKey ?: return @@ -168,12 +219,11 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { try { val payload = driveService.downloadBackup(accessToken, fileId) - val nsec = BackupCrypto.decryptNsec(payload, key) + val nsec = withContext(Dispatchers.Default) { BackupCrypto.decryptNsec(payload, key) } val keypair = Keys.fromPrivkey(nsec) keyRepo.saveKeypair(keypair) keyRepo.reloadPrefs(keypair.pubkey.toHex()) _state.value = State.Done(isNewAccount = false) - Log.d(TAG, "state -> Done(isNewAccount=false)") } catch (e: Exception) { Log.w(TAG, "restoreAccount failed", e) _state.value = State.Error(e.message ?: "Failed to restore account.") @@ -181,57 +231,117 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { } } - fun createNewAccount() { - Log.d(TAG, "createNewAccount tapped") - val key = pendingBackupKey ?: return - val accessToken = pendingAccessToken ?: return + /** Called from the Choose screen when the user wants to add another account + * to a Google login that already has backups. PIN is already known. */ + fun createAnotherAccount(activity: ComponentActivity) { + if (pendingBackupKey == null) return _state.value = State.Working viewModelScope.launch { try { - val keypair = Keys.generate() - val npub = Nip19.npubEncode(keypair.pubkey) - val payload = BackupCrypto.encryptNsec(keypair.privkey, key) - driveService.uploadBackup(accessToken, npub, payload) - keyRepo.saveKeypair(keypair) - keyRepo.reloadPrefs(keypair.pubkey.toHex()) - val fiatPrefs = FiatPreferences.get(getApplication()) - fiatPrefs.setFiatMode(true) - fiatPrefs.setCurrency("USD") - _state.value = State.Done(isNewAccount = true) - Log.d(TAG, "state -> Done(isNewAccount=true)") + createAndStoreNewAccount(activity) } catch (e: Exception) { - Log.w(TAG, "createNewAccount failed", e) + Log.w(TAG, "createAnotherAccount failed", e) _state.value = State.Error(e.message ?: "Failed to create account.") } } } + private suspend fun createAndStoreNewAccount(activity: ComponentActivity) { + val key = pendingBackupKey ?: error("backup key not derived") + val keypair = Keys.generate() + val payload = withContext(Dispatchers.Default) { + BackupCrypto.encryptNsec(keypair.privkey, key) + } + uploadWithRefresh(activity, payload) + keyRepo.saveKeypair(keypair) + keyRepo.reloadPrefs(keypair.pubkey.toHex()) + val fiatPrefs = FiatPreferences.get(getApplication()) + fiatPrefs.setFiatMode(true) + fiatPrefs.setCurrency("USD") + _state.value = State.Done(isNewAccount = true) + } + fun reset() { profileFetchJob?.cancel() profileFetchJob = null + pendingSub = null pendingBackupKey = null pendingAccessToken = null + pendingFiles = emptyList() + pendingSetupFirstPin = null + signInManager = null _state.value = State.Idle } + private suspend fun listBackupsWithRefresh( + activity: ComponentActivity + ): List { + val token = pendingAccessToken ?: error("no pending access token") + return try { + driveService.listBackups(token) + } catch (e: DriveAuthorizationExpiredException) { + Log.w(TAG, "Drive returned 401 on list; refreshing token", e) + val fresh = refreshToken(activity, e.staleToken) + driveService.listBackups(fresh) + } + } + + private suspend fun downloadWithRefresh(activity: ComponentActivity, fileId: String): String { + val token = pendingAccessToken ?: error("no pending access token") + return try { + driveService.downloadBackup(token, fileId) + } catch (e: DriveAuthorizationExpiredException) { + Log.w(TAG, "Drive returned 401 on download; refreshing token", e) + val fresh = refreshToken(activity, e.staleToken) + driveService.downloadBackup(fresh, fileId) + } + } + + private suspend fun uploadWithRefresh(activity: ComponentActivity, payload: String) { + val token = pendingAccessToken ?: error("no pending access token") + try { + driveService.uploadBackup(token, payload) + } catch (e: DriveAuthorizationExpiredException) { + Log.w(TAG, "Drive returned 401 on upload; refreshing token", e) + val fresh = refreshToken(activity, e.staleToken) + driveService.uploadBackup(fresh, payload) + } + } + + private suspend fun refreshToken(activity: ComponentActivity, staleToken: String): String { + val manager = signInManager ?: error("no sign-in manager — was beginSignIn called?") + val fresh = manager.refreshDriveAccessToken(activity, staleToken) + pendingAccessToken = fresh + return fresh + } + /** - * Opens ephemeral WebSocket connections to a couple of widely-used relays, - * requests kind-0 profile metadata for each backup's pubkey, and merges the - * parsed display name + picture into the Choose state as results arrive. - * Cancelled when the user moves past the chooser. + * Pulls decoy profiles from a popular relay first, then issues one combined + * REQ for (real + decoys) against the profile relays. From a relay + * operator's perspective the real backup pubkeys are mixed in with random + * recent profiles, blunting the "this Google account corresponds to these + * npubs" linkage. UI updates filter to only the real pubkeys. */ - private fun fetchProfilesInBackground(pubkeyHexList: List) { + private fun fetchProfilesInBackground(realPubkeys: List) { profileFetchJob?.cancel() profileFetchJob = viewModelScope.launch(Dispatchers.IO) { + val real = realPubkeys.distinct() + if (real.isEmpty()) return@launch + val client = OkHttpClient.Builder() .connectTimeout(8, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) .build() - val pubkeys = pubkeyHexList.distinct() - if (pubkeys.isEmpty()) return@launch - val pubkeyJsonArray = pubkeys.joinToString(",") { "\"$it\"" } - val reqMessage = """["REQ","wisp-google-profiles",{"kinds":[0],"authors":[$pubkeyJsonArray]}]""" + val decoys = withTimeoutOrNull(DECOY_FETCH_TIMEOUT_MS) { + fetchDecoyPubkeys(client, DECOY_COUNT) + }.orEmpty().filter { it !in real } + Log.d(TAG, "decoys fetched: ${decoys.size}") + + val combined = (real + decoys).shuffled() + val authorsJson = combined.joinToString(",") { "\"$it\"" } + val reqMessage = """["REQ","wisp-google-profiles",{"kinds":[0],"authors":[$authorsJson]}]""" + val realSet = real.toSet() val sockets = PROFILE_RELAYS.map { url -> try { @@ -243,7 +353,7 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { } override fun onMessage(webSocket: WebSocket, text: String) { - handleProfileMessage(text) + handleProfileMessage(text, realSet) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { @@ -258,7 +368,7 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { } try { - kotlinx.coroutines.delay(PROFILE_FETCH_TIMEOUT_MS) + delay(PROFILE_FETCH_TIMEOUT_MS) } finally { for (socket in sockets.filterNotNull()) { try { @@ -272,13 +382,65 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { } } - private fun handleProfileMessage(text: String) { + private suspend fun fetchDecoyPubkeys(client: OkHttpClient, count: Int): List = + suspendCoroutine { cont -> + val resumed = AtomicBoolean(false) + val collected = Collections.synchronizedSet(mutableSetOf()) + val subId = "wisp-google-decoys" + val req = """["REQ","$subId",{"kinds":[0],"limit":$count}]""" + + fun resumeOnce(result: List, ws: WebSocket?) { + if (!resumed.compareAndSet(false, true)) return + try { + ws?.send("""["CLOSE","$subId"]""") + ws?.close(1000, null) + } catch (_: Exception) {} + cont.resume(result) + } + + client.newWebSocket( + Request.Builder().url(DECOY_RELAY).build(), + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(req) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val arr = try { + profileJson.parseToJsonElement(text) as? JsonArray + } catch (_: Exception) { null } ?: return + if (arr.size < 2) return + val tag = try { arr[0].jsonPrimitive.content } catch (_: Exception) { return } + when (tag) { + "EVENT" -> { + if (arr.size < 3) return + val event = arr[2] as? JsonObject ?: return + val pubkey = event["pubkey"]?.jsonPrimitive?.content ?: return + collected.add(pubkey) + if (collected.size >= count) { + resumeOnce(collected.toList(), webSocket) + } + } + "EOSE" -> resumeOnce(collected.toList(), webSocket) + } + } + + override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + Log.w(TAG, "decoy relay failed", t) + resumeOnce(emptyList(), null) + } + } + ) + } + + private fun handleProfileMessage(text: String, realPubkeys: Set) { val arr = try { profileJson.parseToJsonElement(text) as? JsonArray } catch (_: Exception) { return } ?: return if (arr.size < 3) return if (arr[0].jsonPrimitive.content != "EVENT") return val event = arr[2] as? JsonObject ?: return if (event["kind"]?.jsonPrimitive?.content != "0") return val pubkey = event["pubkey"]?.jsonPrimitive?.content ?: return + if (pubkey !in realPubkeys) return val content = event["content"]?.jsonPrimitive?.content ?: return val profile = try { profileJson.parseToJsonElement(content).jsonObject } catch (_: Exception) { return } @@ -288,7 +450,6 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { if (name == null && picture == null) return - // Merge into current Choose state if still active. val current = _state.value if (current !is State.Choose) return val updated = current.backups.map { backup -> @@ -305,8 +466,12 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { override fun onCleared() { super.onCleared() profileFetchJob?.cancel() + pendingSub = null pendingBackupKey = null pendingAccessToken = null + pendingFiles = emptyList() + pendingSetupFirstPin = null + signInManager = null } companion object { @@ -315,6 +480,9 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) { "wss://relay.damus.io", "wss://relay.primal.net" ) + private const val DECOY_RELAY = "wss://relay.primal.net" + private const val DECOY_COUNT = 10 + private const val DECOY_FETCH_TIMEOUT_MS = 4_000L private const val PROFILE_FETCH_TIMEOUT_MS = 8_000L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd0de17..baeab16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,10 +65,24 @@ Your backed-up accounts Tap an account to restore it, or create a new one. New accounts are encrypted and added to your Google Drive backup. Create your account - Wisp will generate a new Nostr key and save an encrypted backup to a hidden folder in your Google Drive. The encryption key is derived from your Google account, so anyone with access to it can recover your identity. + Wisp will generate a new Nostr key and save an encrypted backup to a hidden folder in your Google Drive. Restore Create account & back up Create another account + + + Set a recovery PIN + Pick a 4–8 digit PIN. You\'ll need it to restore your account on a new device. + If you forget this PIN, your Nostr key is lost forever — there is no reset. + Confirm your PIN + Enter your PIN again to make sure you remember it. + PINs didn\'t match. Try again. + Enter your recovery PIN + This is the PIN you set when you first signed in to Wisp with this Google account. + Incorrect PIN. Try again. + PIN + Use 4–8 digits + Unlock %d people online now Online Now %d online in your network