Merge pull request #538 from barrydeen/feat/google-backup-hardening
feat(auth): require recovery PIN and strip identifying metadata from Drive backups
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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 4–8 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 4–8 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>
|
||||
|
||||
Reference in New Issue
Block a user