refactor(auth): derive google-linked nsecs deterministically

Replaces the Google Drive backup flow (#528) with deterministic key
derivation. The user's Nostr identity IS their Google account — no
encrypted blobs to store, no backup events to publish, nothing for
Google or any third party to retain.

   privkey = SHA-256("wisp-account-v1:" || sub || ":" || accountIndex)

Properties:
- Same Google account always derives the same nsec on any device
- No backup to lose: signing in regenerates the keys
- No `drive.appdata` OAuth scope, no scary Drive consent dialog
- Anyone with access to the Google account can derive every nsec.
  Bounded by Google account security — same trade-off as #528, with
  a much simpler attack surface and no third-party storage layer

Discovery on sign-in:
- Derive candidate keypairs for indices 0..15 from the user's `sub`
- One REQ to relay.damus.io, relay.primal.net, nos.lol, nostr.wine,
  relay.wisp.talk, relay.ditto.pub asking for kind 0/3/10002 events
  from those pubkeys
- Pubkeys with any activity = "in use" accounts that go in the chooser;
  avatar + display name come from the same kind-0 events
- "Create another account" derives the next-unused index

Code shrinkage: DriveBackupService is gone, BackupCrypto's encryption
helpers are gone, the play-services-auth dependency is gone, and the
Drive-related ProGuard rules are gone. The whole flow is ~200 fewer
lines than #528 and easier to audit — the derivation is one line of
SHA-256.

Splash button switches to Google's dark-mode brand variant (#131314
container, full-color G, #8E918F stroke) per Sign in with Google spec.

No migration needed: nobody is on the #528 flow yet.
This commit is contained in:
Barry Deen
2026-05-14 11:40:37 -04:00
parent 403b25ac90
commit 3d3c6e12ef
10 changed files with 227 additions and 434 deletions
-1
View File
@@ -117,5 +117,4 @@ dependencies {
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.play.services.auth)
implementation(libs.googleid)
implementation(libs.play.services.auth)
}
-2
View File
@@ -68,5 +68,3 @@
-dontwarn androidx.credentials.**
-keep class com.google.android.libraries.identity.googleid.** { *; }
-dontwarn com.google.android.libraries.identity.googleid.**
-keep class com.google.android.gms.auth.** { *; }
-dontwarn com.google.android.gms.auth.**
@@ -1,148 +0,0 @@
package com.wisp.app.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.UUID
/**
* 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.
*/
class DriveBackupService(
private val httpClient: OkHttpClient = OkHttpClient()
) {
private val json = Json { ignoreUnknownKeys = true }
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
}
}
suspend fun listBackups(accessToken: String): List<BackupFile> = withContext(Dispatchers.IO) {
val nameQuery = "name = '$LEGACY_FILENAME' or name contains '$BACKUP_PREFIX'"
val url = "https://www.googleapis.com/drive/v3/files" +
"?spaces=appDataFolder" +
"&q=" + java.net.URLEncoder.encode(nameQuery, "UTF-8") +
"&fields=files(id,name,modifiedTime)" +
"&pageSize=100"
val req = Request.Builder()
.url(url)
.header("Authorization", "Bearer $accessToken")
.get()
.build()
httpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Drive list failed: ${response.code} ${response.message}")
}
val body = response.body?.string() ?: return@withContext emptyList()
val root = json.parseToJsonElement(body) as? JsonObject ?: return@withContext emptyList()
val files = root["files"]?.jsonArray ?: return@withContext emptyList()
files.mapNotNull { element ->
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
BackupFile(id, name)
}
}
}
suspend fun downloadBackup(accessToken: String, fileId: String): String =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("https://www.googleapis.com/drive/v3/files/$fileId?alt=media")
.header("Authorization", "Bearer $accessToken")
.get()
.build()
httpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Drive download failed: ${response.code} ${response.message}")
}
response.body?.string() ?: throw IOException("Empty download body")
}
}
/**
* 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.
*/
suspend fun uploadBackup(accessToken: String, npub: 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 metadata = """{"name":"$filename","parents":["$APP_DATA_FOLDER"]}"""
val boundary = "wisp-${UUID.randomUUID()}"
val crlf = "\r\n"
val body = buildString {
append("--").append(boundary).append(crlf)
append("Content-Type: application/json; charset=UTF-8").append(crlf).append(crlf)
append(metadata).append(crlf)
append("--").append(boundary).append(crlf)
append("Content-Type: application/octet-stream").append(crlf).append(crlf)
append(payload).append(crlf)
append("--").append(boundary).append("--").append(crlf)
}
val requestBody: RequestBody = body.toRequestBody(
"multipart/related; boundary=$boundary".toMediaType()
)
val req = Request.Builder()
.url("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart")
.header("Authorization", "Bearer $accessToken")
.post(requestBody)
.build()
httpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Drive upload failed: ${response.code} ${response.message}")
}
}
}
suspend fun deleteBackup(accessToken: String, fileId: String) = withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("https://www.googleapis.com/drive/v3/files/$fileId")
.header("Authorization", "Bearer $accessToken")
.delete()
.build()
httpClient.newCall(req).execute().close()
}
companion object {
private const val APP_DATA_FOLDER = "appDataFolder"
private const val BACKUP_PREFIX = "wisp_nsec_"
private const val BACKUP_SUFFIX = ".bin"
private const val LEGACY_FILENAME = "wisp_nsec.bin"
}
}
@@ -0,0 +1,34 @@
package com.wisp.app.auth
import com.wisp.app.nostr.Keys
import java.security.MessageDigest
/**
* Deterministically derives Nostr keypairs from a Google ID token's `sub`
* claim. Account #0 is the first identity, #1 is the second, and so on —
* each one is independent and recoverable from the same Google login alone.
*
* privkey = SHA-256("wisp-account-v1:" || sub || ":" || accountIndex)
*
* Properties:
* - Stable: the same Google account always produces the same Nostr keypair.
* - No backup: nothing to store anywhere; signing in regenerates the keys.
* - Auditable: the formula above is the entire derivation. Anyone can
* reproduce it on any device.
*
* Security: bounded by the security of the user's Google account. Anyone
* who can sign in to the account can recompute every nsec derived from it.
* This is the same trade-off any "Google sign-in = identity" scheme makes,
* accepted in exchange for not requiring the user to remember a passphrase.
*/
object GoogleAccountDerivation {
private const val DERIVATION_PREFIX = "wisp-account-v1"
fun deriveAccountKeypair(sub: String, accountIndex: Int): Keys.Keypair {
require(sub.isNotEmpty()) { "Google sub claim must not be empty" }
require(accountIndex >= 0) { "accountIndex must be non-negative" }
val input = "$DERIVATION_PREFIX:$sub:$accountIndex".toByteArray(Charsets.UTF_8)
val privkey = MessageDigest.getInstance("SHA-256").digest(input)
return Keys.fromPrivkey(privkey)
}
}
@@ -1,33 +1,20 @@
package com.wisp.app.auth
import android.app.PendingIntent
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialException
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import kotlinx.coroutines.tasks.await
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.
* 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.
* Returns the user's stable Google identifier (the `sub`-shaped `id` field
* from the Google ID token credential). That's the only thing we need — no
* OAuth scopes, no Drive access, no consent dialog beyond Credential
* Manager's account picker.
*/
class GoogleSignInManager(
context: Context,
@@ -35,23 +22,7 @@ class GoogleSignInManager(
) {
private val credentialManager = CredentialManager.create(context)
data class GoogleAuthResult(
val sub: String,
val accessToken: String,
val email: 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("@") }
)
}
private suspend fun getGoogleSubFromCredentialManager(activity: ComponentActivity): String {
suspend fun signIn(activity: ComponentActivity): String {
val option = GetGoogleIdOption.Builder()
.setServerClientId(webClientId)
.setFilterByAuthorizedAccounts(false)
@@ -82,57 +53,6 @@ class GoogleSignInManager(
}
return parsed.id
}
private suspend fun getDriveAccessToken(activity: ComponentActivity): String {
val authClient = Identity.getAuthorizationClient(activity)
val request = AuthorizationRequest.Builder()
.setRequestedScopes(listOf(Scope(DRIVE_APPDATA_SCOPE)))
.build()
val authResult = authClient.authorize(request).await()
if (authResult.hasResolution()) {
val pendingIntent = authResult.pendingIntent
?: throw GoogleSignInException("Authorization required but no pending intent provided")
return resolveAuthorization(activity, pendingIntent)
}
return authResult.accessToken
?: throw GoogleSignInException("No access token returned")
}
private suspend fun resolveAuthorization(
activity: ComponentActivity,
pendingIntent: PendingIntent
): String = suspendCoroutine { cont ->
val key = "wisp_google_auth_${System.currentTimeMillis()}"
var launcher: androidx.activity.result.ActivityResultLauncher<IntentSenderRequest>? = null
launcher = activity.activityResultRegistry.register(
key,
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
launcher?.unregister()
try {
val authResult = Identity.getAuthorizationClient(activity)
.getAuthorizationResultFromIntent(result.data)
val token = authResult.accessToken
?: throw GoogleSignInException("Authorization granted but no access token returned")
cont.resume(token)
} catch (e: Exception) {
cont.resumeWithException(
if (e is GoogleSignInException) e
else GoogleSignInException("Authorization resolution failed: ${e.message}", e)
)
}
}
launcher.launch(
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
)
}
companion object {
private const val DRIVE_APPDATA_SCOPE = "https://www.googleapis.com/auth/drive.appdata"
}
}
class GoogleSignInException(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -127,12 +127,12 @@ fun GoogleAuthScreen(
when (val s = state) {
GoogleAuthViewModel.State.Idle,
GoogleAuthViewModel.State.SigningIn,
GoogleAuthViewModel.State.CheckingDrive,
GoogleAuthViewModel.State.CheckingRelays,
GoogleAuthViewModel.State.Working -> {
LoadingBlock(
label = when (s) {
GoogleAuthViewModel.State.SigningIn -> stringResource(R.string.google_auth_signing_in)
GoogleAuthViewModel.State.CheckingDrive -> stringResource(R.string.google_auth_checking_drive)
GoogleAuthViewModel.State.CheckingRelays -> stringResource(R.string.google_auth_checking_relays)
GoogleAuthViewModel.State.Working -> stringResource(R.string.google_auth_working)
else -> stringResource(R.string.google_auth_starting)
}
@@ -140,8 +140,8 @@ fun GoogleAuthScreen(
}
is GoogleAuthViewModel.State.Choose -> ChooseBlock(
backups = s.backups,
onRestore = { viewModel.restoreAccount(it.fileId) },
accounts = s.accounts,
onSelect = { viewModel.selectAccount(it.accountIndex) },
onCreate = { viewModel.createNewAccount() }
)
@@ -226,14 +226,14 @@ private fun LoadingBlock(label: String) {
@Composable
private fun ChooseBlock(
backups: List<GoogleAuthViewModel.BackupSummary>,
onRestore: (GoogleAuthViewModel.BackupSummary) -> Unit,
accounts: List<GoogleAuthViewModel.AccountSummary>,
onSelect: (GoogleAuthViewModel.AccountSummary) -> Unit,
onCreate: () -> Unit
) {
val titleRes = if (backups.isEmpty())
val titleRes = if (accounts.isEmpty())
R.string.google_auth_choose_title_empty
else
R.string.google_auth_choose_title_with_backups
R.string.google_auth_choose_title_with_accounts
Text(
text = stringResource(titleRes),
@@ -243,23 +243,23 @@ private fun ChooseBlock(
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 (accounts.isEmpty()) R.string.google_auth_choose_body_empty
else R.string.google_auth_choose_body_with_accounts
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (backups.isNotEmpty()) {
if (accounts.isNotEmpty()) {
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) })
items(accounts, key = { it.accountIndex }) { account ->
AccountRow(account = account, onClick = { onSelect(account) })
Spacer(Modifier.height(8.dp))
}
}
@@ -275,7 +275,7 @@ private fun ChooseBlock(
) {
Text(
stringResource(
if (backups.isEmpty()) R.string.google_auth_create_first
if (accounts.isEmpty()) R.string.google_auth_create_first
else R.string.google_auth_create_another
)
)
@@ -283,8 +283,8 @@ private fun ChooseBlock(
}
@Composable
private fun BackupRow(
backup: GoogleAuthViewModel.BackupSummary,
private fun AccountRow(
account: GoogleAuthViewModel.AccountSummary,
onClick: () -> Unit
) {
val context = LocalContext.current
@@ -308,10 +308,10 @@ private fun BackupRow(
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
) {
if (!backup.picture.isNullOrBlank()) {
if (!account.picture.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(backup.picture)
.data(account.picture)
.crossfade(true)
.build(),
contentDescription = null,
@@ -323,8 +323,8 @@ private fun BackupRow(
Spacer(Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = backup.displayName?.takeIf { it.isNotBlank() }
?: formatShortNpub(backup.npub),
text = account.displayName?.takeIf { it.isNotBlank() }
?: formatShortPubkey(account.pubkeyHex),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
@@ -333,7 +333,7 @@ private fun BackupRow(
}
}
private fun formatShortNpub(npub: String): String {
if (npub.length <= 18) return npub
return npub.take(12) + "" + npub.takeLast(6)
private fun formatShortPubkey(hex: String): String {
if (hex.length <= 14) return hex
return hex.take(8) + "" + hex.takeLast(6)
}
@@ -193,10 +193,10 @@ fun SplashScreen(
.height(48.dp),
shape = RoundedCornerShape(24.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1F1F1F)
containerColor = Color(0xFF131314),
contentColor = Color(0xFFE3E3E3)
),
border = BorderStroke(1.dp, Color(0xFFDADCE0))
border = BorderStroke(1.dp, Color(0xFF8E918F))
) {
Icon(
painter = painterResource(R.drawable.ic_google_g),
@@ -5,20 +5,18 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.wisp.app.auth.BackupCrypto
import com.wisp.app.auth.DriveBackupService
import com.wisp.app.auth.GoogleAccountDerivation
import com.wisp.app.auth.GoogleSignInException
import com.wisp.app.auth.GoogleSignInManager
import com.wisp.app.nostr.Keys
import com.wisp.app.nostr.Nip19
import com.wisp.app.nostr.toHex
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.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -34,26 +32,26 @@ import java.util.concurrent.TimeUnit
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.
* "Continue with Google" flow with deterministic account derivation no
* backup events, no encryption, no Drive.
*
* The plaintext nsec only leaves Drive when the user actually picks Restore;
* generation only happens when they pick Create.
* 1. Sign in via Credential Manager get the user's Google `sub`.
* 2. Derive candidate keypairs for indices 0..MAX-1 from that `sub`.
* 3. Query a set of public relays for any kind 0 / 3 / 10002 events from
* those derived pubkeys. A pubkey with any activity = an "in use"
* account that should appear in the chooser.
* 4. The chooser shows discovered accounts (avatar + display name come
* from the kind-0 metadata, no separate fetch needed) plus "Create
* another account" — which derives the next unused index.
*
* Nothing is published, encrypted, or stored on relays. The user's own
* normal Nostr activity is what makes their derived accounts discoverable.
*/
class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
private val keyRepo = KeyRepository(app)
private val driveService = DriveBackupService()
/** One restorable account entry surfaced in the chooser. */
data class BackupSummary(
val fileId: String,
val npub: String,
data class AccountSummary(
val accountIndex: Int,
val pubkeyHex: String,
val displayName: String? = null,
val picture: String? = null
@@ -62,8 +60,11 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
sealed class State {
object Idle : State()
object SigningIn : State()
object CheckingDrive : State()
data class Choose(val backups: List<BackupSummary>) : State()
object CheckingRelays : State()
data class Choose(
val accounts: List<AccountSummary>,
val nextNewIndex: Int
) : State()
object Working : State()
data class Done(val isNewAccount: Boolean) : State()
data class Error(val message: String) : State()
@@ -72,9 +73,7 @@ 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 pendingAccessToken: String? = null
private var profileFetchJob: Job? = null
private var pendingSub: String? = null
fun beginSignIn(activity: ComponentActivity, webClientId: String) {
Log.d(TAG, "beginSignIn called, current state=${_state.value::class.simpleName}, webClientId.length=${webClientId.length}")
@@ -88,44 +87,17 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
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
pendingAccessToken = result.accessToken
val sub = manager.signIn(activity)
Log.d(TAG, "signIn returned sub-len=${sub.length}")
pendingSub = sub
_state.value = State.CheckingDrive
Log.d(TAG, "state -> CheckingDrive")
_state.value = State.CheckingRelays
Log.d(TAG, "state -> CheckingRelays")
val files = driveService.listBackups(result.accessToken)
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(result.accessToken, 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 })
}
val accounts = probeForActiveAccounts(sub)
val nextNew = accounts.maxOfOrNull { it.accountIndex + 1 } ?: 0
_state.value = State.Choose(accounts, nextNew)
Log.d(TAG, "state -> Choose with ${accounts.size} active account(s), nextNew=$nextNew")
} catch (e: GoogleSignInException) {
Log.w(TAG, "GoogleSignInException", e)
_state.value = State.Error(e.message ?: "Google sign-in failed.")
@@ -136,45 +108,40 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun restoreAccount(fileId: String) {
Log.d(TAG, "restoreAccount tapped, fileId=$fileId")
val key = pendingBackupKey ?: return
val accessToken = pendingAccessToken ?: return
fun selectAccount(accountIndex: Int) {
Log.d(TAG, "selectAccount tapped, index=$accountIndex")
val sub = pendingSub ?: return
_state.value = State.Working
viewModelScope.launch {
try {
val payload = driveService.downloadBackup(accessToken, fileId)
val nsec = BackupCrypto.decryptNsec(payload, key)
val keypair = Keys.fromPrivkey(nsec)
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, accountIndex)
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.")
Log.w(TAG, "selectAccount failed", e)
_state.value = State.Error(e.message ?: "Failed to log in.")
}
}
}
fun createNewAccount() {
Log.d(TAG, "createNewAccount tapped")
val key = pendingBackupKey ?: return
val accessToken = pendingAccessToken ?: return
val sub = pendingSub ?: return
val current = _state.value as? State.Choose ?: return
val newIndex = current.nextNewIndex
_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)
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, newIndex)
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)")
Log.d(TAG, "state -> Done(isNewAccount=true), accountIndex=$newIndex")
} catch (e: Exception) {
Log.w(TAG, "createNewAccount failed", e)
_state.value = State.Error(e.message ?: "Failed to create account.")
@@ -183,114 +150,140 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
}
fun reset() {
profileFetchJob?.cancel()
profileFetchJob = null
pendingBackupKey = null
pendingAccessToken = null
pendingSub = null
_state.value = State.Idle
}
/**
* 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.
* Derives candidate keypairs for indices 0..MAX_INDEX-1, then queries a
* set of widely-used relays for any kind 0 / 3 / 10002 events from those
* pubkeys. Returns one [AccountSummary] per pubkey that has activity,
* with profile data populated when a kind-0 was found in the same probe.
*/
private fun fetchProfilesInBackground(pubkeyHexList: List<String>) {
profileFetchJob?.cancel()
profileFetchJob = viewModelScope.launch(Dispatchers.IO) {
val client = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
val pubkeys = pubkeyHexList.distinct()
if (pubkeys.isEmpty()) return@launch
private suspend fun probeForActiveAccounts(sub: String): List<AccountSummary> = withContext(Dispatchers.IO) {
data class Candidate(
val accountIndex: Int,
val pubkeyHex: String,
var hasActivity: Boolean = false,
var displayName: String? = null,
var picture: String? = null
)
val pubkeyJsonArray = pubkeys.joinToString(",") { "\"$it\"" }
val reqMessage = """["REQ","wisp-google-profiles",{"kinds":[0],"authors":[$pubkeyJsonArray]}]"""
val candidates = (0 until MAX_INDEX).map { idx ->
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, idx)
Candidate(accountIndex = idx, pubkeyHex = keypair.pubkey.toHex())
}
val byPubkey = candidates.associateBy { it.pubkeyHex }
val sockets = PROFILE_RELAYS.map { url ->
try {
client.newWebSocket(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(reqMessage)
}
val authorList = candidates.joinToString(",") { "\"${it.pubkeyHex}\"" }
val subId = "wisp-google-probe"
val req = """["REQ","$subId",{"kinds":[0,3,10002],"authors":[$authorList]}]"""
override fun onMessage(webSocket: WebSocket, text: String) {
handleProfileMessage(text)
}
val client = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "profile relay $url failed", t)
val eoseCount = java.util.concurrent.atomic.AtomicInteger(0)
val sockets = PROBE_RELAYS.map { url ->
try {
client.newWebSocket(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(req)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val arr = try { json.parseToJsonElement(text) as? JsonArray } catch (_: Exception) { null }
?: return
if (arr.size < 2) return
when (arr[0].jsonPrimitive.content) {
"EVENT" -> {
if (arr.size < 3 || arr[1].jsonPrimitive.content != subId) return
val event = arr[2] as? JsonObject ?: return
val pubkey = event["pubkey"]?.jsonPrimitive?.content ?: return
val candidate = byPubkey[pubkey] ?: return
candidate.hasActivity = true
if (event["kind"]?.jsonPrimitive?.content == "0") {
val content = event["content"]?.jsonPrimitive?.content
if (content != null) {
try {
val profile = json.parseToJsonElement(content).jsonObject
val name = profile["display_name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
?: profile["name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
val picture = profile["picture"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
if (candidate.displayName == null) candidate.displayName = name
if (candidate.picture == null) candidate.picture = picture
} catch (_: Exception) {}
}
}
}
"EOSE" -> {
if (arr[1].jsonPrimitive.content == subId) {
eoseCount.incrementAndGet()
}
}
}
}
)
} catch (e: Exception) {
Log.w(TAG, "couldn't open profile relay $url", e)
null
}
}
try {
kotlinx.coroutines.delay(PROFILE_FETCH_TIMEOUT_MS)
} finally {
for (socket in sockets.filterNotNull()) {
try {
socket.send("""["CLOSE","wisp-google-profiles"]""")
socket.close(1000, null)
} catch (_: Exception) {}
}
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
}
}
}
private fun handleProfileMessage(text: 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
val content = event["content"]?.jsonPrimitive?.content ?: return
val profile = try { profileJson.parseToJsonElement(content).jsonObject } catch (_: Exception) { return }
val name = profile["display_name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
?: profile["name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
val picture = profile["picture"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
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 ->
if (backup.pubkeyHex == pubkey && (backup.displayName == null || backup.picture == null)) {
backup.copy(
displayName = backup.displayName ?: name,
picture = backup.picture ?: picture
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "probe relay $url failed", t)
eoseCount.incrementAndGet()
}
}
)
} else backup
} catch (e: Exception) {
Log.w(TAG, "couldn't open probe relay $url", e)
eoseCount.incrementAndGet()
null
}
}
_state.value = State.Choose(updated)
val deadline = System.currentTimeMillis() + PROBE_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (eoseCount.get() >= PROBE_RELAYS.size) break
delay(100)
}
for (socket in sockets.filterNotNull()) {
try {
socket.send("""["CLOSE","$subId"]""")
socket.close(1000, null)
} catch (_: Exception) {}
}
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
candidates
.filter { it.hasActivity }
.map {
AccountSummary(
accountIndex = it.accountIndex,
pubkeyHex = it.pubkeyHex,
displayName = it.displayName,
picture = it.picture
)
}
.sortedBy { it.accountIndex }
}
override fun onCleared() {
super.onCleared()
profileFetchJob?.cancel()
pendingBackupKey = null
pendingAccessToken = null
pendingSub = null
}
companion object {
private val profileJson = Json { ignoreUnknownKeys = true }
private val PROFILE_RELAYS = listOf(
private val json = Json { ignoreUnknownKeys = true }
private const val MAX_INDEX = 16
private const val PROBE_TIMEOUT_MS = 6_000L
private val PROBE_RELAYS = listOf(
"wss://relay.damus.io",
"wss://relay.primal.net"
"wss://relay.primal.net",
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.wisp.talk",
"wss://relay.ditto.pub"
)
private const val PROFILE_FETCH_TIMEOUT_MS = 8_000L
}
}
+6 -7
View File
@@ -47,19 +47,18 @@
<string name="splash_log_in">Log In</string>
<string name="splash_continue_with_google">Continue with Google</string>
<!-- Google Sign-In / Drive Backup -->
<!-- Google Sign-In / Nostr Relay Backup -->
<!-- Replace this empty string with your OAuth 2.0 Web Client ID from Google Cloud Console. -->
<string name="google_web_client_id" translatable="false">410412439051-mhcgeuvc2sjp75v8snucstfr5smrg03g.apps.googleusercontent.com</string>
<string name="google_auth_starting">Starting…</string>
<string name="google_auth_signing_in">Signing in with Google…</string>
<string name="google_auth_checking_drive">Checking your Google Drive backup</string>
<string name="google_auth_checking_relays">Looking for your accounts</string>
<string name="google_auth_working">Almost there…</string>
<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_with_accounts">Your accounts</string>
<string name="google_auth_choose_body_with_accounts">Tap an account to log in, or create a new identity.</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_restore">Restore</string>
<string name="google_auth_create_first">Create account &amp; back up</string>
<string name="google_auth_choose_body_empty">Wisp derives your Nostr identity from your Google account. Signing in with this Google account on any device produces the same identity. Anyone who can sign in to this Google account can derive — and use — your Nostr keys.</string>
<string name="google_auth_create_first">Create account</string>
<string name="google_auth_create_another">Create another account</string>
<string name="splash_people_online">%d people online now</string>
<string name="online_now">Online Now</string>
-2
View File
@@ -25,7 +25,6 @@ navigation-compose = "2.8.5"
lifecycle = "2.8.7"
credentials = "1.3.0"
googleid = "1.1.1"
play-services-auth = "21.3.0"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@@ -72,7 +71,6 @@ breez-sdk-spark = { group = "breez_sdk_spark", name = "bindings-android", versio
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" }
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "play-services-auth" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }