feat(auth): require recovery PIN and strip identifying metadata from Drive backups

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_<uuid>.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.
This commit is contained in:
Barry Deen
2026-05-15 22:34:22 -04:00
parent acadead68b
commit 5029fbface
6 changed files with 555 additions and 173 deletions
@@ -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 48 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 48 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))
@@ -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_<npub>.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_<uuid>.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<BackupFile> = 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"
}
}
@@ -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 }
}
}
@@ -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<GoogleAuthViewModel.BackupSummary>,
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<GoogleAuthViewModel.BackupSummary>,
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))
}
}
@@ -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_<npub>.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_<uuid>.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<BackupSummary>) : 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>(State.Idle)
val state: StateFlow<State> = _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<DriveBackupService.BackupFile> = 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<DriveBackupService.BackupFile> {
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<DriveBackupService.BackupFile> {
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<String>) {
private fun fetchProfilesInBackground(realPubkeys: List<String>) {
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<String> =
suspendCoroutine { cont ->
val resumed = AtomicBoolean(false)
val collected = Collections.synchronizedSet(mutableSetOf<String>())
val subId = "wisp-google-decoys"
val req = """["REQ","$subId",{"kinds":[0],"limit":$count}]"""
fun resumeOnce(result: List<String>, 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<String>) {
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
}
}
+15 -1
View File
@@ -65,10 +65,24 @@
<string name="google_auth_choose_title_with_backups">Your backed-up accounts</string>
<string name="google_auth_choose_body_with_backups">Tap an account to restore it, or create a new one. New accounts are encrypted and added to your Google Drive backup.</string>
<string name="google_auth_choose_title_empty">Create your account</string>
<string name="google_auth_choose_body_empty">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.</string>
<string name="google_auth_choose_body_empty">Wisp will generate a new Nostr key and save an encrypted backup to a hidden folder in your Google Drive.</string>
<string name="google_auth_restore">Restore</string>
<string name="google_auth_create_first">Create account &amp; back up</string>
<string name="google_auth_create_another">Create another account</string>
<!-- Recovery PIN -->
<string name="pin_setup_title">Set a recovery PIN</string>
<string name="pin_setup_body">Pick a 48 digit PIN. You\'ll need it to restore your account on a new device.</string>
<string name="pin_setup_warning">If you forget this PIN, your Nostr key is lost forever — there is no reset.</string>
<string name="pin_confirm_title">Confirm your PIN</string>
<string name="pin_confirm_body">Enter your PIN again to make sure you remember it.</string>
<string name="pin_mismatch">PINs didn\'t match. Try again.</string>
<string name="pin_restore_title">Enter your recovery PIN</string>
<string name="pin_restore_body">This is the PIN you set when you first signed in to Wisp with this Google account.</string>
<string name="pin_restore_incorrect">Incorrect PIN. Try again.</string>
<string name="pin_placeholder">PIN</string>
<string name="pin_too_short">Use 48 digits</string>
<string name="btn_unlock">Unlock</string>
<string name="splash_people_online">%d people online now</string>
<string name="online_now">Online Now</string>
<string name="online_in_network">%d online in your network</string>