diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 90c9d1ce8a..6cd193236c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -217,6 +217,12 @@ private object PrefKeys { const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids" const val PENDING_ATTESTATIONS = "pending_attestations" + // Per-account one-shot flag: false only for freshly-GENERATED accounts that + // haven't yet backed up their secret key. Absent (defaults to true) for every + // account logged in via an existing nsec/bunker/external signer — those already + // hold their key elsewhere and must not be nudged. + const val HAS_BACKED_UP_KEYS = "has_backed_up_keys" + const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" const val SHARED_SETTINGS = "shared_settings" const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets" @@ -690,6 +696,36 @@ object LocalPreferences { } } + // Reactive, per-account cache of the "has backed up keys" flag so the home-screen + // nudge updates the instant the user backs up or dismisses it, without a full + // account reload. Keyed by npub. Seeded lazily from encrypted storage. + private val hasBackedUpKeysFlows: MutableMap> = mutableMapOf() + private val hasBackedUpKeysMutex = Mutex() + + private suspend fun hasBackedUpKeysFlow(npub: String): MutableStateFlow = + hasBackedUpKeysMutex.withLock { + hasBackedUpKeysFlows.getOrPut(npub) { + val stored = + withContext(Dispatchers.IO) { + encryptedPreferences(npub).getBoolean(PrefKeys.HAS_BACKED_UP_KEYS, true) + } + MutableStateFlow(stored) + } + } + + /** Reactive flag: true (default) unless a freshly-generated account still needs to back up its key. */ + suspend fun hasBackedUpKeys(npub: String): MutableStateFlow = hasBackedUpKeysFlow(npub) + + suspend fun setHasBackedUpKeys( + value: Boolean, + npub: String, + ) { + withContext(Dispatchers.IO) { + encryptedPreferences(npub).edit { putBoolean(PrefKeys.HAS_BACKED_UP_KEYS, value) } + } + hasBackedUpKeysFlow(npub).value = value + } + val mutex = Mutex() suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 46d4b9bb70..5f89c002cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -279,6 +279,12 @@ class AccountSessionManager( localPreferences.setDefaultAccount(accountSettings) + // Freshly-generated key: mark it as not-yet-backed-up so the home screen + // nudges the user to save their secret key. Accounts logged in via an + // existing nsec/bunker/external signer never get this false flag (the + // pref defaults to true), so only brand-new accounts are nudged. + localPreferences.setHasBackedUpKeys(false, accountSettings.keyPair.pubKey.toNpub()) + startUI(accountSettings, routeBuilder = { Route.ImportFollowsSelectUser }) scope.launch(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 43f98ddc9e..4523431989 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterA import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderGeohashBubble import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderLiveActivityBubble +import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.BackupKeysNudge import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -290,14 +291,22 @@ private fun HomePages( ) } - HomeAlgoFeedStatusBanner( - accountViewModel = accountViewModel, - nav = nav, + Column( modifier = Modifier .align(Alignment.TopCenter) .padding(top = paddingValues.calculateTopPadding()), - ) + ) { + BackupKeysNudge( + accountViewModel = accountViewModel, + nav = nav, + ) + + HomeAlgoFeedStatusBanner( + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index 8689d496c2..553066ff0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup import android.app.Activity +import android.content.ClipData import android.content.Context import android.content.ContextWrapper +import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult @@ -50,6 +52,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +62,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -84,6 +88,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.components.util.getText import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -104,8 +109,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +/** Best-effort delay before the plaintext nsec is wiped from the clipboard. */ +private const val CLIPBOARD_CLEAR_DELAY_MS = 60_000L + @Composable fun AccountBackupScreen( accountViewModel: AccountViewModel, @@ -131,6 +140,18 @@ private fun AccountBackupScreenContent( accountViewModel: AccountViewModel, nav: INav, ) { + // Redact the secret key from screenshots and the app switcher while this + // screen is on-screen. Cleared on dispose so the flag never leaks to other + // screens. This is the only FLAG_SECURE usage in the app — scoped on purpose. + val context = LocalContext.current + DisposableEffect(context) { + val window = context.getFragmentActivity()?.window + window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + Scaffold( topBar = { TopBarWithBackButton( @@ -368,14 +389,23 @@ private fun copyNSec( clipboardManager: Clipboard, ) { account.settings.keyPair.privKey?.let { + val nsec = it.toNsec() scope.launch { - clipboardManager.setText(it.toNsec()) + clipboardManager.setText(nsec) Toast .makeText( context, stringRes(context, R.string.secret_key_copied_to_clipboard), Toast.LENGTH_SHORT, ).show() + + // Best-effort auto-clear: after a delay, wipe the clipboard only if it + // still holds this exact nsec (don't clobber anything copied since). + // On Android 13+ the OS also shows its own sensitive-content UI. + delay(CLIPBOARD_CLEAR_DELAY_MS) + if (clipboardManager.getText() == nsec) { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("", ""))) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt new file mode 100644 index 0000000000..83a9722b01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/BackupKeysNudge.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +/** + * Soft, dismissible "back up your keys" nudge shown on the home feed for freshly + * generated accounts that haven't saved their secret key yet. It never blocks + * navigation: the user either backs up (navigates to [Route.AccountBackup]) or + * confirms they already saved the key. Both actions flip the per-account + * [LocalPreferences.setHasBackedUpKeys] flag so the nudge stops appearing. + */ +@Composable +fun BackupKeysNudge( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val npub = + accountViewModel.account.signer.pubKey + .hexToByteArray() + .toNpub() + + // Seed the reactive flag off a background read. Rendering only proceeds once the + // flow resolves, so the observing composable never conditionally calls hooks. + val flow by produceState?>(initialValue = null, key1 = npub) { + value = LocalPreferences.hasBackedUpKeys(npub) + } + + flow?.let { stateFlow -> + WatchBackupKeysNudge(stateFlow, npub, nav, modifier) + } +} + +@Composable +private fun WatchBackupKeysNudge( + stateFlow: MutableStateFlow, + npub: String, + nav: INav, + modifier: Modifier, +) { + val scope = rememberCoroutineScope() + val hasBackedUp by stateFlow.collectAsStateWithLifecycle() + if (hasBackedUp) return + + BackupKeysNudgeCard( + modifier = modifier, + onBackupNow = { + // Best-effort: opening the backup screen counts as backing up so the + // nudge doesn't linger after the user follows through. + scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) } + nav.nav(Route.AccountBackup) + }, + onAlreadySaved = { + scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) } + }, + ) +} + +@Composable +private fun BackupKeysNudgeCard( + modifier: Modifier = Modifier, + onBackupNow: () -> Unit, + onAlreadySaved: () -> Unit, +) { + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.backup_keys_nudge_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onAlreadySaved) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.backup_keys_nudge_dismiss), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = StdVertSpacer) + + Text( + text = stringRes(R.string.backup_keys_nudge_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = StdVertSpacer) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onAlreadySaved) { + Text(stringRes(R.string.backup_keys_nudge_already_saved)) + } + Spacer(modifier = StdHorzSpacer) + Button(onClick = onBackupNow) { + Text(stringRes(R.string.backup_keys_nudge_backup_now)) + } + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 963b5d3aca..7737ecc1fd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -532,6 +532,12 @@ \n\nIf you lose your password, you will not be able to recover your key. + Back up your keys + Your secret key is the only way to access this account. If you lose it, it can never be recovered. Save it somewhere safe now. + Back up now + I saved them + Dismiss + Failed to encrypt your private key Secret key (nsec) copied to clipboard Copy my secret key diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index e5f8eff4dc..5f597e2aa8 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -18,10 +18,35 @@ IMPORTANT: Save your keys! - Your secret key (nsec) is the ONLY way to access your account. If you lose it, your account is gone forever. Save it somewhere safe! + Your secret key (nsec) is like a password that can NEVER be reset or recovered. It is the ONLY way to access your account, and anyone who has it controls your account forever. If you lose it, your account is gone for good. Never share it — store it in a password manager. Public Key (shareable): Secret Key (NEVER share this!): I've saved my keys, continue + I have saved my keys somewhere safe + Copy encrypted (recommended) + Password for encrypted backup + + + Backup Keys + Public key (npub) + Your public key is safe to share. Give it to people so they can find and follow you. + Show QR + Hide QR + Secret key (nsec) + Your secret key is like a password that can NEVER be reset or recovered. Anyone who has it controls your account forever. Never share it; store it in a password manager. + Secret key is hidden + Reveal secret key + Hide secret key + Copy secret key + Copies the plaintext nsec. Only paste it into a trusted password manager. + Copy encrypted (recommended) + Password for encrypted backup + Could not encrypt the key. Please try again. + This account uses an external signer — no secret key is stored here. + Copied! + Copy + Reveal secret key + Enter your privacy-lock password to reveal your secret key. Copy diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt index 260e0d9fc2..41ecc350a4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt @@ -27,4 +27,4 @@ package com.vitorpamplona.amethyst.commons.privacylock * together, but each scope keeps its own [PrivacyLockState] so that unlock, * idle-timer, and leave-route transitions apply independently per route. */ -enum class LockScope { Messages, Wallet } +enum class LockScope { Messages, Wallet, KeyBackup } diff --git a/desktopApp/plans/2026-08-11-key-backup-nsec-exposure-plan.md b/desktopApp/plans/2026-08-11-key-backup-nsec-exposure-plan.md new file mode 100644 index 0000000000..e5e071cb2f --- /dev/null +++ b/desktopApp/plans/2026-08-11-key-backup-nsec-exposure-plan.md @@ -0,0 +1,138 @@ +# Key Backup & nsec Exposure — Plan + +Date: 2026-08-11 +Scope: Desktop (`desktopApp`) primary, Android (`amethyst`) secondary, shared strings/logic in `commons`/`quartz`. + +## Problem + +User report (Nostr): new account creation gives no discoverable way to +find/save the keypair. "Couldn't find it in settings, no option to save when +generated." Keys are crucial → must reinforce, not obfuscate. + +Ask: make nsec discoverable + backupable; npub sharable (copy/QR); check both +Android and Desktop. + +## Key asymmetry (design principle — non-negotiable) + +Every surveyed client agrees: +- **npub** = public identity → plain by default, copy + QR, share freely. +- **nsec** = password that can NEVER be reset → masked by default, gated + reveal + gated copy, **never rendered as a QR**, prefer encrypted (NIP-49). + +So "show nsec with npub for sharing" splits: npub is for sharing; nsec is for +**private backup only**. The plan treats them differently. + +## Current state + +### Android (`amethyst`) — mostly done, one gap +- `ui/.../keyBackup/AccountBackupScreen.kt` — full backup screen: biometric-gated + copy nsec, NIP-49 encrypted (ncryptsec1) copy, plaintext + encrypted QR, + strong warnings (`account_backup_tips2_md`/`tips3_md`). Route `AccountBackup`. +- **Gap**: signup (`SignUpViewModel.signup` → `AccountSessionManager.createNewAccount`, + `val keyPair = KeyPair()`) generates silently. **No post-signup nudge** to back + up. Screen exists but discoverability relies on the user hunting the drawer. + +### Desktop (`desktopApp`) — large gaps +- `ui/auth/NewKeyWarningCard.kt` — shows npub+nsec once at creation as plain + `SelectableKeyText` (manual-select). No copy button, no QR, no encrypted + option, weak warning. +- `ui/DevSettingsSection.kt` — full copy UI but **debug-mode only**; normal + users can't re-reach nsec. +- `ui/profile/ProfileInfoCard.kt` — npub + hex only (no copy? verify), no QR. +- **No user-facing Backup Keys screen. No settings path to nsec. No NIP-49 + export. No QR for npub.** +- `AccountState.LoggedIn` already exposes `.npub`, `.nsec`, `.pubKeyHex` → + wiring a backup screen is trivial. +- `commons/jvmMain/.../keystorage/SecureKeyStorage.kt` — OS keychain + encrypted + fallback; can retrieve raw key. + +### Shared / quartz (reuse — don't rebuild) +- `nip19Bech32/ByteArrayExt.kt`: `toNsec()`, `toNpub()`. +- `nip49PrivKeyEnc/Nip49.kt`: `encrypt()/decrypt()` ncryptsec1. +- Android QR: `ui/.../qrcode` (`QrCodeDrawer`). Desktop QR: `QrCodeCanvas.kt` + (currently only NIP-46/NIP-47 URIs) — reuse for npub. + +## Recommended design + +Adopt the **hidden-password camp** (Amethyst Android's existing model) + +**persistent post-signup backup nudge** (Snort pattern). Rationale: matches +Android, keeps signup fast, avoids a hard gate, but nags until backed up. + +Firm rules across both platforms: +1. nsec masked by default; reveal is an explicit gated action. +2. Copy nsec is itself gated (copy is the real leak vector) + shows warning. +3. Offer **NIP-49 encrypted (ncryptsec1)** copy/export alongside plaintext. +4. npub always plain, copyable, QR. **nsec never QR.** +5. Hide the entire private-key section for **external-signer / bunker / read-only** + sessions (`nsec == null`) — show "This account uses an external signer" note. +6. Explicit **"cannot be recovered"** warning verbatim-strong (reuse Android's + `account_backup_tips2_md`). + +Platform reveal-gate: +- **Android**: biometric (already wired in `AccountBackupScreen`). +- **Desktop**: no biometric → gate reveal/copy behind a confirm dialog + ("Show secret key?" YES/CANCEL) + `showKeys` toggle, matching DevSettings but + user-facing and with the encrypted option. (Optional later: OS auth via + existing PrivacyLock/master-password machinery if present — verify.) + +## Implementation + +### Phase 1 — Desktop Backup Keys screen (biggest gap) +- New `desktopApp/.../ui/settings/BackupKeysScreen.kt` (or `.../ui/keyBackup/`): + - npub row: plain, Copy, "Show QR" (reuse `QrCodeCanvas`). + - nsec section: masked → confirm-dialog reveal → Copy (plaintext) + "Copy + encrypted" (password field → `Nip49().encrypt`). Strong warning banner. + - Hidden when `account.nsec == null` (external/read-only) → info note. +- Add entry point in Settings sidebar/account area (near `ProfileInfoCard` in + `Main.kt` ~2191). Label "Backup Keys" / "Account Keys". +- Reuse `AccountState.LoggedIn.{npub,nsec,pubKeyHex}`; clipboard via existing + `copyToClipboard` (AWT) — factor out of `DevSettingsSection`. + +### Phase 2 — Desktop NewKeyWarningCard upgrade +- Add Copy button per key (not just selection). +- Add "Copy encrypted (recommended)" + password field. +- Strengthen warning copy to match Android "no recovery" strength. +- Add "I've saved my keys" acknowledgement affordance before `onContinue` + (soft; not a hard checkbox gate — see open Q). +- Do NOT add nsec QR. + +### Phase 3 — Android post-signup backup nudge +- After `signup()`, route to / surface a dismissible "Back up your keys" prompt + with "Back up now" (→ `AccountBackupScreen`) / "I already saved them". +- Persist "backed up" flag per account; keep nudging (home banner or settings + badge) until acknowledged. No silent dismissal. + +### Phase 4 — Shared polish (both platforms) +- Copy-warning string on every nsec copy ("like a password, cannot be reset"). +- `FLAG_SECURE` (Android) on reveal to redact screenshots/recents. Desktop: + no direct equivalent — skip. +- (Stretch) timed clipboard auto-clear after nsec copy — genuinely novel, no + client does it. Verify feasibility (Android `ClipboardManager`, Desktop AWT). +- (Stretch) Coracle-style paste-guard on compose: warn if a note body starts + with `nsec1`. + +## Testing +- Desktop: generate account → reveal/copy plaintext + encrypted → decrypt + round-trips (`Nip49`) → QR shows npub not nsec → external-signer account hides + nsec section. Manual sheet. +- Android: signup → nudge appears → backup screen reachable → encrypted copy + round-trips. Existing `AccountBackupScreen` unit coverage if any. +- Reuse quartz `Nip49` tests; add commons test for any extracted helper. + +## Non-goals / deferred +- iOS (no mature target yet). +- BIP-39 / NIP-06 mnemonic backup (Snort) — separate feature. +- Full OS-biometric gate on Desktop (no primitive) — confirm dialog instead. + +## Unanswered questions +- Design camp: confirm hidden-password + nudge (recommended) vs. keep Desktop's + show-at-creation as the primary backup moment? +- Hard "I saved it" checkbox gate at signup, or soft dismissible nudge? (survey: + soft wins; hard gate largely unclaimed.) +- Desktop reveal gate: confirm-dialog only, or wire existing PrivacyLock/master + password if one exists? (verify what Desktop already has.) +- Extract a shared `commons` backup composable, or keep Android + Desktop + screens separate (Android biometric vs Desktop dialog diverge)? +- Timed clipboard auto-clear: in scope now or stretch? +- Paste-guard on compose (nsec self-doxx prevention): this feature or separate? +- Does Desktop `ProfileInfoCard` already copy npub / need a QR button there too? diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 704c5f1a2c..b9d2f2f6ae 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager import com.vitorpamplona.amethyst.desktop.ui.deck.param +import com.vitorpamplona.amethyst.desktop.ui.keyBackup.BackupKeysCard import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -2352,6 +2353,10 @@ fun ProfileScreen( isReadOnly = account.isReadOnly, ) + Spacer(Modifier.height(16.dp)) + + BackupKeysCard(account = account) + Spacer(Modifier.height(24.dp)) OutlinedButton( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt index caa205a864..f328e4873b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt @@ -52,8 +52,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.account.AccountState -import java.awt.Toolkit -import java.awt.datatransfer.StringSelection +import com.vitorpamplona.amethyst.desktop.util.copyToClipboard /** * Developer settings section - shows sensitive keys for debugging. @@ -246,16 +245,3 @@ private fun KeyRow( } } } - -/** - * Copy text to system clipboard using AWT Toolkit. - */ -private fun copyToClipboard(text: String) { - try { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - val selection = StringSelection(text) - clipboard.setContents(selection, selection) - } catch (e: Exception) { - e.printStackTrace() - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index c985c2e451..409ddeb3ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -20,34 +20,65 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.action_copy +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied +import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_failed import com.vitorpamplona.amethyst.commons.resources.new_key_continue_button +import com.vitorpamplona.amethyst.commons.resources.new_key_copy_encrypted_button +import com.vitorpamplona.amethyst.commons.resources.new_key_encrypt_password_label import com.vitorpamplona.amethyst.commons.resources.new_key_public_label +import com.vitorpamplona.amethyst.commons.resources.new_key_saved_checkbox import com.vitorpamplona.amethyst.commons.resources.new_key_secret_label import com.vitorpamplona.amethyst.commons.resources.new_key_warning_message import com.vitorpamplona.amethyst.commons.resources.new_key_warning_title +import com.vitorpamplona.amethyst.desktop.util.copyToClipboard +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource /** * Warning card displayed after generating a new Nostr key pair. * Reminds users to save their keys and shows both public and secret keys. * + * The npub is shareable (plain + copy). The nsec is an unrecoverable password: + * it is shown so the user can save it, offers plaintext AND NIP-49 encrypted + * copy, and is never rendered as a QR code. A soft acknowledgement checkbox + * nudges the user to confirm they saved their keys before continuing. + * * @param npub The public key in npub format * @param nsec The secret key in nsec format (nullable for read-only accounts) * @param onContinue Callback when user acknowledges they've saved their keys @@ -62,6 +93,8 @@ fun NewKeyWarningCard( modifier: Modifier = Modifier, cardWidth: Dp = 500.dp, ) { + var acknowledged by remember { mutableStateOf(false) } + Card( modifier = modifier.width(cardWidth), colors = @@ -94,6 +127,8 @@ fun NewKeyWarningCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) SelectableKeyText(npub) + Spacer(Modifier.height(8.dp)) + CopyKeyButton(value = npub) Spacer(Modifier.height(12.dp)) @@ -104,12 +139,36 @@ fun NewKeyWarningCard( color = MaterialTheme.colorScheme.error, ) SelectableKeyText(secretKey) + Spacer(Modifier.height(8.dp)) + CopyKeyButton(value = secretKey) + + Spacer(Modifier.height(16.dp)) + EncryptedCopyRow(nsec = secretKey) } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Checkbox( + checked = acknowledged, + onCheckedChange = { acknowledged = it }, + ) + Text( + stringResource(Res.string.new_key_saved_checkbox), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(16.dp)) Button( onClick = onContinue, + enabled = acknowledged, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(Res.string.new_key_continue_button)) @@ -118,6 +177,114 @@ fun NewKeyWarningCard( } } +@Composable +private fun CopyKeyButton(value: String) { + var copied by remember { mutableStateOf(false) } + + OutlinedButton( + onClick = { + copyToClipboard(value) + copied = true + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (copied) { + stringResource(Res.string.backup_keys_copied) + } else { + stringResource(Res.string.action_copy) + }, + ) + } + + if (copied) { + LaunchedEffect(Unit) { + delay(2000) + copied = false + } + } +} + +@Composable +private fun EncryptedCopyRow(nsec: String) { + var password by remember { mutableStateOf("") } + var showChars by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(false) } + var copied by remember { mutableStateOf(false) } + + OutlinedTextField( + value = password, + onValueChange = { + password = it + error = false + }, + label = { Text(stringResource(Res.string.new_key_encrypt_password_label)) }, + singleLine = true, + isError = error, + supportingText = + if (error) { + { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } + } else { + null + }, + visualTransformation = + if (showChars) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + Icon( + symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + val hex = decodePrivateKeyAsHexOrNull(nsec) + val encrypted = + hex?.let { runCatching { Nip49().encrypt(it, password) }.getOrNull() } + if (encrypted != null) { + copyToClipboard(encrypted) + copied = true + } else { + error = true + } + }, + enabled = password.isNotBlank(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (copied) { + stringResource(Res.string.backup_keys_copied) + } else { + stringResource(Res.string.new_key_copy_encrypted_button) + }, + ) + } + + if (copied) { + LaunchedEffect(Unit) { + delay(2000) + copied = false + } + } +} + @Preview @Composable fun NewKeyWarningCardPreview() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt new file mode 100644 index 0000000000..8d1c27f4bd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt @@ -0,0 +1,478 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.keyBackup + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +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.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.LockState +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_encrypted +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain_warning +import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_failed +import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_password_label +import com.vitorpamplona.amethyst.commons.resources.backup_keys_external_signer +import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide +import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide_qr +import com.vitorpamplona.amethyst.commons.resources.backup_keys_public_help +import com.vitorpamplona.amethyst.commons.resources.backup_keys_public_label +import com.vitorpamplona.amethyst.commons.resources.backup_keys_reveal +import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_hidden +import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_label +import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_warning +import com.vitorpamplona.amethyst.commons.resources.backup_keys_show_qr +import com.vitorpamplona.amethyst.commons.resources.backup_keys_title +import com.vitorpamplona.amethyst.commons.resources.backup_keys_unlock_subtitle +import com.vitorpamplona.amethyst.commons.resources.backup_keys_unlock_title +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.security.DesktopLockScreen +import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas +import com.vitorpamplona.amethyst.desktop.util.copyToClipboard +import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource + +/** + * Account key backup card shown in the Desktop settings/profile screen. + * + * The public key (npub) is treated as shareable: plain, copyable, QR is fine. + * The secret key (nsec) is treated as an unrecoverable password: masked by + * default, its reveal AND copy are gated behind the existing PrivacyLock + * ([LockScope.KeyBackup]), and it is NEVER rendered as a QR code. Encrypted + * (NIP-49 `ncryptsec1…`) copy is offered as the recommended path. + */ +@Composable +fun BackupKeysCard( + account: AccountState.LoggedIn, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + stringResource(Res.string.backup_keys_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + PublicKeySection(npub = account.npub) + + val nsec = account.nsec + if (nsec != null) { + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + SecretKeySection(nsec = nsec) + } else { + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource(Res.string.backup_keys_external_signer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun PublicKeySection(npub: String) { + var showQr by remember { mutableStateOf(false) } + + Text( + stringResource(Res.string.backup_keys_public_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(Res.string.backup_keys_public_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + + MonospaceKeyValue(value = npub) + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CopyButton(value = npub) + OutlinedButton(onClick = { showQr = !showQr }) { + Icon( + symbol = MaterialSymbols.QrCode2, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (showQr) { + stringResource(Res.string.backup_keys_hide_qr) + } else { + stringResource(Res.string.backup_keys_show_qr) + }, + ) + } + } + + if (showQr) { + Spacer(Modifier.height(12.dp)) + QrCodeCanvas(data = npub) + } +} + +@Composable +private fun SecretKeySection(nsec: String) { + val lockState = lockStateFor(LockScope.KeyBackup) + val current by lockState.state.collectAsState() + + var revealed by remember { mutableStateOf(false) } + var awaitingUnlock by remember { mutableStateOf(false) } + + // Re-hide whenever we leave this route/composable. + DisposableEffect(lockState) { + onDispose { + lockState.onLeaveRoute() + revealed = false + awaitingUnlock = false + } + } + + // When the user asked to reveal and the gate becomes usable, show the key. + LaunchedEffect(current, awaitingUnlock) { + if (awaitingUnlock && current !is LockState.Locked) { + revealed = true + awaitingUnlock = false + } + } + + Text( + stringResource(Res.string.backup_keys_secret_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(8.dp)) + + // Warning banner + Row( + modifier = + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + stringResource(Res.string.backup_keys_secret_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + + Spacer(Modifier.height(12.dp)) + + if (awaitingUnlock && current is LockState.Locked) { + // Force an unlock before revealing. DesktopLockScreen is a fillMaxSize + // Surface, so present it inside a modal Dialog with a bounded box rather + // than letting it take over the whole settings pane. + Dialog(onDismissRequest = { awaitingUnlock = false }) { + Surface( + modifier = Modifier.size(width = 420.dp, height = 380.dp), + shape = MaterialTheme.shapes.large, + tonalElevation = 6.dp, + ) { + DesktopLockScreen( + scope = LockScope.KeyBackup, + title = stringResource(Res.string.backup_keys_unlock_title), + subtitle = stringResource(Res.string.backup_keys_unlock_subtitle), + ) + } + } + } else if (!revealed) { + Text( + stringResource(Res.string.backup_keys_secret_hidden), + style = + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + if (current is LockState.Locked) { + awaitingUnlock = true + } else { + revealed = true + } + }, + ) { + Icon( + symbol = MaterialSymbols.Visibility, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text(stringResource(Res.string.backup_keys_reveal)) + } + } else { + RevealedSecret(nsec = nsec, onHide = { revealed = false }) + } +} + +@Composable +private fun RevealedSecret( + nsec: String, + onHide: () -> Unit, +) { + MonospaceKeyValue(value = nsec, isSensitive = true) + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CopyButton( + value = nsec, + label = stringResource(Res.string.backup_keys_copy_plain), + autoClearSensitive = true, + ) + OutlinedButton(onClick = onHide) { + Icon( + symbol = MaterialSymbols.VisibilityOff, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text(stringResource(Res.string.backup_keys_hide)) + } + } + + Spacer(Modifier.height(4.dp)) + Text( + stringResource(Res.string.backup_keys_copy_plain_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + + Spacer(Modifier.height(16.dp)) + + EncryptedCopy(nsec = nsec) +} + +@Composable +private fun EncryptedCopy(nsec: String) { + var password by remember { mutableStateOf("") } + var showChars by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(false) } + var copied by remember { mutableStateOf(false) } + + OutlinedTextField( + value = password, + onValueChange = { + password = it + error = false + }, + label = { Text(stringResource(Res.string.backup_keys_encrypt_password_label)) }, + singleLine = true, + isError = error, + supportingText = + if (error) { + { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } + } else { + null + }, + visualTransformation = + if (showChars) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + Icon( + symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + val hex = decodePrivateKeyAsHexOrNull(nsec) + val encrypted = + hex?.let { runCatching { Nip49().encrypt(it, password) }.getOrNull() } + if (encrypted != null) { + copyToClipboard(encrypted) + copied = true + } else { + error = true + } + }, + enabled = password.isNotBlank(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (copied) { + stringResource(Res.string.backup_keys_copied) + } else { + stringResource(Res.string.backup_keys_copy_encrypted) + }, + ) + } + + if (copied) { + LaunchedEffect(Unit) { + delay(2000) + copied = false + } + } +} + +@Composable +private fun MonospaceKeyValue( + value: String, + isSensitive: Boolean = false, +) { + Text( + value, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = + if (isSensitive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(4.dp), + ).padding(8.dp), + ) +} + +@Composable +private fun CopyButton( + value: String, + label: String = stringResource(Res.string.backup_keys_copy), + autoClearSensitive: Boolean = false, +) { + var copied by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Button( + onClick = { + if (autoClearSensitive) { + copyToClipboardThenClear(value, scope) + } else { + copyToClipboard(value) + } + copied = true + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text(if (copied) stringResource(Res.string.backup_keys_copied) else label) + } + + if (copied) { + LaunchedEffect(Unit) { + delay(2000) + copied = false + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/util/Clipboard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/util/Clipboard.kt new file mode 100644 index 0000000000..fcf40d82ed --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/util/Clipboard.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection + +/** + * Copy [text] to the system clipboard using the AWT Toolkit. + * + * Shared by every Desktop call site that needs plain clipboard access + * (developer settings, key-backup, new-key warning card, …). + */ +fun copyToClipboard(text: String) { + try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val selection = StringSelection(text) + clipboard.setContents(selection, selection) + } catch (e: Exception) { + e.printStackTrace() + } +} + +/** Reads the current clipboard as a String, or null if it isn't text / unavailable. */ +private fun clipboardString(): String? = + try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { + clipboard.getData(DataFlavor.stringFlavor) as? String + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + +/** + * Copy sensitive [text] to the clipboard, then best-effort wipe it after + * [delayMs]. The clipboard is only cleared if it still holds this exact value, + * so anything the user copied in the meantime is left untouched. + * + * Intended for the plaintext nsec copy in key backup — not for shareable values. + */ +fun copyToClipboardThenClear( + text: String, + scope: CoroutineScope, + delayMs: Long = 60_000L, +) { + copyToClipboard(text) + scope.launch { + delay(delayMs) + if (clipboardString() == text) { + copyToClipboard("") + } + } +}