Merge pull request #534 from barrydeen/fix/google-encrypted-drive-backup
fix(auth): restore encrypted Drive backup; drop deterministic sub derivation
This commit is contained in:
@@ -117,4 +117,5 @@ dependencies {
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.credentials.play.services.auth)
|
||||
implementation(libs.googleid)
|
||||
implementation(libs.play.services.auth)
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -68,3 +68,5 @@
|
||||
-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.**
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,20 +1,33 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
class GoogleSignInManager(
|
||||
context: Context,
|
||||
@@ -22,7 +35,23 @@ class GoogleSignInManager(
|
||||
) {
|
||||
private val credentialManager = CredentialManager.create(context)
|
||||
|
||||
suspend fun signIn(activity: ComponentActivity): String {
|
||||
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 {
|
||||
val option = GetGoogleIdOption.Builder()
|
||||
.setServerClientId(webClientId)
|
||||
.setFilterByAuthorizedAccounts(false)
|
||||
@@ -53,6 +82,57 @@ 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.CheckingRelays,
|
||||
GoogleAuthViewModel.State.CheckingDrive,
|
||||
GoogleAuthViewModel.State.Working -> {
|
||||
LoadingBlock(
|
||||
label = when (s) {
|
||||
GoogleAuthViewModel.State.SigningIn -> stringResource(R.string.google_auth_signing_in)
|
||||
GoogleAuthViewModel.State.CheckingRelays -> stringResource(R.string.google_auth_checking_relays)
|
||||
GoogleAuthViewModel.State.CheckingDrive -> stringResource(R.string.google_auth_checking_drive)
|
||||
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(
|
||||
accounts = s.accounts,
|
||||
onSelect = { viewModel.selectAccount(it.accountIndex) },
|
||||
backups = s.backups,
|
||||
onRestore = { viewModel.restoreAccount(it.fileId) },
|
||||
onCreate = { viewModel.createNewAccount() }
|
||||
)
|
||||
|
||||
@@ -226,14 +226,14 @@ private fun LoadingBlock(label: String) {
|
||||
|
||||
@Composable
|
||||
private fun ChooseBlock(
|
||||
accounts: List<GoogleAuthViewModel.AccountSummary>,
|
||||
onSelect: (GoogleAuthViewModel.AccountSummary) -> Unit,
|
||||
backups: List<GoogleAuthViewModel.BackupSummary>,
|
||||
onRestore: (GoogleAuthViewModel.BackupSummary) -> Unit,
|
||||
onCreate: () -> Unit
|
||||
) {
|
||||
val titleRes = if (accounts.isEmpty())
|
||||
val titleRes = if (backups.isEmpty())
|
||||
R.string.google_auth_choose_title_empty
|
||||
else
|
||||
R.string.google_auth_choose_title_with_accounts
|
||||
R.string.google_auth_choose_title_with_backups
|
||||
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
@@ -243,23 +243,23 @@ private fun ChooseBlock(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (accounts.isEmpty()) R.string.google_auth_choose_body_empty
|
||||
else R.string.google_auth_choose_body_with_accounts
|
||||
if (backups.isEmpty()) R.string.google_auth_choose_body_empty
|
||||
else R.string.google_auth_choose_body_with_backups
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (accounts.isNotEmpty()) {
|
||||
if (backups.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp)
|
||||
) {
|
||||
items(accounts, key = { it.accountIndex }) { account ->
|
||||
AccountRow(account = account, onClick = { onSelect(account) })
|
||||
items(backups, key = { it.npub }) { backup ->
|
||||
BackupRow(backup = backup, onClick = { onRestore(backup) })
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ private fun ChooseBlock(
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (accounts.isEmpty()) R.string.google_auth_create_first
|
||||
if (backups.isEmpty()) R.string.google_auth_create_first
|
||||
else R.string.google_auth_create_another
|
||||
)
|
||||
)
|
||||
@@ -283,8 +283,8 @@ private fun ChooseBlock(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountRow(
|
||||
account: GoogleAuthViewModel.AccountSummary,
|
||||
private fun BackupRow(
|
||||
backup: GoogleAuthViewModel.BackupSummary,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -308,10 +308,10 @@ private fun AccountRow(
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
if (!account.picture.isNullOrBlank()) {
|
||||
if (!backup.picture.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(account.picture)
|
||||
.data(backup.picture)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
@@ -323,8 +323,8 @@ private fun AccountRow(
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = account.displayName?.takeIf { it.isNotBlank() }
|
||||
?: formatShortPubkey(account.pubkeyHex),
|
||||
text = backup.displayName?.takeIf { it.isNotBlank() }
|
||||
?: formatShortNpub(backup.npub),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@@ -333,7 +333,7 @@ private fun AccountRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatShortPubkey(hex: String): String {
|
||||
if (hex.length <= 14) return hex
|
||||
return hex.take(8) + "…" + hex.takeLast(6)
|
||||
private fun formatShortNpub(npub: String): String {
|
||||
if (npub.length <= 18) return npub
|
||||
return npub.take(12) + "…" + npub.takeLast(6)
|
||||
}
|
||||
|
||||
@@ -5,18 +5,20 @@ import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wisp.app.auth.GoogleAccountDerivation
|
||||
import com.wisp.app.auth.BackupCrypto
|
||||
import com.wisp.app.auth.DriveBackupService
|
||||
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.delay
|
||||
import kotlinx.coroutines.Job
|
||||
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
|
||||
@@ -32,26 +34,26 @@ import java.util.concurrent.TimeUnit
|
||||
private const val TAG = "GoogleAuth"
|
||||
|
||||
/**
|
||||
* "Continue with Google" flow with deterministic account derivation — no
|
||||
* backup events, no encryption, no Drive.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* The plaintext nsec only leaves Drive when the user actually picks Restore;
|
||||
* generation only happens when they pick Create.
|
||||
*/
|
||||
class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val keyRepo = KeyRepository(app)
|
||||
private val driveService = DriveBackupService()
|
||||
|
||||
data class AccountSummary(
|
||||
val accountIndex: Int,
|
||||
/** One restorable account entry surfaced in the chooser. */
|
||||
data class BackupSummary(
|
||||
val fileId: String,
|
||||
val npub: String,
|
||||
val pubkeyHex: String,
|
||||
val displayName: String? = null,
|
||||
val picture: String? = null
|
||||
@@ -60,11 +62,8 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
sealed class State {
|
||||
object Idle : State()
|
||||
object SigningIn : State()
|
||||
object CheckingRelays : State()
|
||||
data class Choose(
|
||||
val accounts: List<AccountSummary>,
|
||||
val nextNewIndex: Int
|
||||
) : State()
|
||||
object CheckingDrive : State()
|
||||
data class Choose(val backups: List<BackupSummary>) : State()
|
||||
object Working : State()
|
||||
data class Done(val isNewAccount: Boolean) : State()
|
||||
data class Error(val message: String) : State()
|
||||
@@ -73,7 +72,9 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow<State>(State.Idle)
|
||||
val state: StateFlow<State> = _state
|
||||
|
||||
private var pendingSub: String? = null
|
||||
private var pendingBackupKey: ByteArray? = null
|
||||
private var pendingAccessToken: String? = null
|
||||
private var profileFetchJob: Job? = null
|
||||
|
||||
fun beginSignIn(activity: ComponentActivity, webClientId: String) {
|
||||
Log.d(TAG, "beginSignIn called, current state=${_state.value::class.simpleName}, webClientId.length=${webClientId.length}")
|
||||
@@ -87,21 +88,43 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "calling manager.signIn(activity)…")
|
||||
val sub = manager.signIn(activity)
|
||||
Log.d(TAG, "signIn returned sub-len=${sub.length}")
|
||||
pendingSub = sub
|
||||
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
|
||||
|
||||
_state.value = State.CheckingRelays
|
||||
Log.d(TAG, "state -> CheckingRelays")
|
||||
_state.value = State.CheckingDrive
|
||||
Log.d(TAG, "state -> CheckingDrive")
|
||||
|
||||
val accounts = probeForActiveAccounts(sub)
|
||||
if (accounts.isEmpty()) {
|
||||
Log.d(TAG, "no accounts found — auto-creating account at index 0")
|
||||
createAccountAt(sub, accountIndex = 0)
|
||||
} else {
|
||||
val nextNew = accounts.maxOf { it.accountIndex } + 1
|
||||
_state.value = State.Choose(accounts, nextNew)
|
||||
Log.d(TAG, "state -> Choose with ${accounts.size} active account(s), nextNew=$nextNew")
|
||||
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 })
|
||||
}
|
||||
} catch (e: GoogleSignInException) {
|
||||
Log.w(TAG, "GoogleSignInException", e)
|
||||
@@ -113,192 +136,161 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAccount(accountIndex: Int) {
|
||||
Log.d(TAG, "selectAccount tapped, index=$accountIndex")
|
||||
val sub = pendingSub ?: return
|
||||
fun restoreAccount(fileId: String) {
|
||||
Log.d(TAG, "restoreAccount tapped, fileId=$fileId")
|
||||
val key = pendingBackupKey ?: return
|
||||
val accessToken = pendingAccessToken ?: return
|
||||
_state.value = State.Working
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, accountIndex)
|
||||
val payload = driveService.downloadBackup(accessToken, fileId)
|
||||
val nsec = 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, "selectAccount failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to log in.")
|
||||
Log.w(TAG, "restoreAccount failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to restore account.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewAccount() {
|
||||
Log.d(TAG, "createNewAccount tapped")
|
||||
val sub = pendingSub ?: return
|
||||
val current = _state.value as? State.Choose ?: return
|
||||
val newIndex = current.nextNewIndex
|
||||
viewModelScope.launch {
|
||||
createAccountAt(sub, newIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: derives the keypair at [accountIndex], saves it locally,
|
||||
* and transitions to Done(isNewAccount = true). Called both from the user
|
||||
* tapping "Create another account" and from the auto-create-at-zero path
|
||||
* when no existing accounts are discovered on sign-in.
|
||||
*/
|
||||
private suspend fun createAccountAt(sub: String, accountIndex: Int) {
|
||||
val key = pendingBackupKey ?: return
|
||||
val accessToken = pendingAccessToken ?: return
|
||||
_state.value = State.Working
|
||||
try {
|
||||
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, accountIndex)
|
||||
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), accountIndex=$accountIndex")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "createAccountAt failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to create account.")
|
||||
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)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "createNewAccount failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to create account.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
pendingSub = null
|
||||
profileFetchJob?.cancel()
|
||||
profileFetchJob = null
|
||||
pendingBackupKey = null
|
||||
pendingAccessToken = null
|
||||
_state.value = State.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
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 pubkeyJsonArray = pubkeys.joinToString(",") { "\"$it\"" }
|
||||
val reqMessage = """["REQ","wisp-google-profiles",{"kinds":[0],"authors":[$pubkeyJsonArray]}]"""
|
||||
|
||||
val authorList = candidates.joinToString(",") { "\"${it.pubkeyHex}\"" }
|
||||
val subId = "wisp-google-probe"
|
||||
val req = """["REQ","$subId",{"kinds":[0,3,10002],"authors":[$authorList]}]"""
|
||||
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 client = OkHttpClient.Builder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleProfileMessage(text)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "profile relay $url failed", t)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "probe relay $url failed", t)
|
||||
eoseCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't open probe relay $url", e)
|
||||
eoseCount.incrementAndGet()
|
||||
null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't open profile relay $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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()
|
||||
}
|
||||
.sortedBy { it.accountIndex }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
} else backup
|
||||
}
|
||||
_state.value = State.Choose(updated)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pendingSub = null
|
||||
profileFetchJob?.cancel()
|
||||
pendingBackupKey = null
|
||||
pendingAccessToken = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
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(
|
||||
private val profileJson = Json { ignoreUnknownKeys = true }
|
||||
private val PROFILE_RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://nos.lol",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.wisp.talk",
|
||||
"wss://relay.ditto.pub"
|
||||
"wss://relay.primal.net"
|
||||
)
|
||||
private const val PROFILE_FETCH_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,18 +47,19 @@
|
||||
<string name="splash_log_in">Log In</string>
|
||||
<string name="splash_continue_with_google">Continue with Google</string>
|
||||
|
||||
<!-- Google Sign-In / Nostr Relay Backup -->
|
||||
<!-- Google Sign-In / Drive 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_relays">Looking for your accounts…</string>
|
||||
<string name="google_auth_checking_drive">Checking your Google Drive backup…</string>
|
||||
<string name="google_auth_working">Almost there…</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_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 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_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 & back up</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>
|
||||
|
||||
@@ -25,6 +25,7 @@ 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" }
|
||||
@@ -71,6 +72,7 @@ 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" }
|
||||
|
||||
Reference in New Issue
Block a user