diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/DataStoreAccess.kt b/app/src/main/java/com/greenart7c3/nostrsigner/DataStoreAccess.kt index 8dc04942..d6044d6c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/DataStoreAccess.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/DataStoreAccess.kt @@ -59,6 +59,28 @@ object DataStoreAccess { return SecureCryptoHelper.decrypt(encrypted) } + /** + * Raw read: returns the still-encrypted value without decrypting. Used by + * [SecureCryptoHelper.rotateKey] to avoid mutex reentrancy (the public + * [getEncryptedKey] calls SecureCryptoHelper.decrypt which acquires the + * mutex that rotateKey already holds). + */ + suspend fun getEncryptedRaw(context: Context, npub: String, key: Preferences.Key): String? { + val prefs = getDataStore(context, npub).data.first() + if (prefs.asMap().keys.isEmpty()) return null + return prefs[key] + } + + /** + * Raw write: stores an already-encrypted value without encrypting. Used by + * [SecureCryptoHelper.rotateKey] to avoid mutex reentrancy. + */ + suspend fun saveEncryptedRaw(context: Context, npub: String, key: Preferences.Key, encryptedValue: String) { + getDataStore(context, npub).edit { prefs -> + prefs[key] = encryptedValue + } + } + suspend fun clearCacheForNpub(context: Context, npub: String) { getDataStore(context, npub).edit { prefs -> prefs.clear() @@ -80,4 +102,17 @@ object DataStoreAccess { val encrypted = prefs[PIN] ?: return null return SecureCryptoHelper.decrypt(encrypted) } + + /** Raw read for the PIN — see [getEncryptedRaw] for rationale. */ + suspend fun getPinRaw(context: Context): String? { + val prefs = getAppDataStore(context).data.first() + return prefs[PIN] + } + + /** Raw write for the PIN — see [saveEncryptedRaw] for rationale. */ + suspend fun savePinRaw(context: Context, encryptedValue: String) { + getAppDataStore(context).edit { prefs -> + prefs[PIN] = encryptedValue + } + } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index 97d73df2..d71264c8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -78,6 +78,7 @@ private enum class SettingsKeys(val key: String) { RATE_LIMIT_WINDOW_SECONDS("rate_limit_window_seconds"), PROFILE_FETCH_INTERVAL("profile_fetch_interval"), TRUST_SCORE_ENABLED("trust_score_enabled"), + REQUIRE_UNLOCKED_DEVICE("require_unlocked_device"), } @Immutable @@ -158,6 +159,7 @@ object LocalPreferences { putInt(SettingsKeys.RATE_LIMIT_MAX_PER_WINDOW.key, settings.rateLimitMaxPerWindow) putInt(SettingsKeys.RATE_LIMIT_WINDOW_SECONDS.key, settings.rateLimitWindowSeconds) putBoolean(SettingsKeys.TRUST_SCORE_ENABLED.key, settings.trustScoreEnabled) + putBoolean(SettingsKeys.REQUIRE_UNLOCKED_DEVICE.key, settings.requireUnlockedDevice) } } } @@ -240,6 +242,14 @@ object LocalPreferences { currentAccount = null savedAccounts = null accountCache.clear() + // Defense-in-depth (GHSA-8844-q5vh-9j8f, I3): decrypting and caching + // every account's private key process-wide is a memory-dump risk. + // Loading only the active account here would reduce exposure, but + // the UI account-switcher in approval screens reads + // allCachedAccounts() synchronously, so we keep the eager load and + // mitigate the other surface — loadFromEncryptedStorageSync no + // longer warms the whole cache on a cold miss, and logout zeroizes + // the private-key bytes — in this change. allSavedAccounts(context).forEach { loadFromEncryptedStorage(context, it.npub) } @@ -322,6 +332,7 @@ object LocalPreferences { ProfileFetchInterval.FIFTEEN_MINUTES }, trustScoreEnabled = getBoolean(SettingsKeys.TRUST_SCORE_ENABLED.key, true), + requireUnlockedDevice = getBoolean(SettingsKeys.REQUIRE_UNLOCKED_DEVICE.key, false), ) } } @@ -418,6 +429,12 @@ object LocalPreferences { */ @SuppressLint("ApplySharedPref") fun updatePrefsForLogout(npub: String, context: Context): Boolean { + // Defense-in-depth (GHSA-8844-q5vh-9j8f, I3): best-effort zeroize the + // private-key byte array of the logging-out account before dropping + // the reference from the cache, so the key material does not linger + // in heap until GC. KeyPair.privKey is a `val` referencing a mutable + // ByteArray, so we overwrite its contents in place. + accountCache.get(npub)?.signer?.keyPair?.privKey?.fill(0) accountCache.remove(npub) // Close Room handles BEFORE deleting on-disk preference/data files so // we release file locks and worker-pool threads instead of leaking @@ -503,19 +520,18 @@ object LocalPreferences { } fun loadFromEncryptedStorageSync(context: Context, npub: String? = null): Account? { - if (savedAccounts == null || accountCache.size() == 0 || savedAccounts?.size != accountCache.size()) { - AmberLog.d(Amber.TAG, "accountCache is null loading accounts") - runBlocking { - allSavedAccounts(context).forEach { - loadFromEncryptedStorage(context, it.npub) - } - } - } - if (npub == null) { - val currentAccount = currentAccount(context) ?: return null - return accountCache.get(currentAccount) - } - return accountCache.get(npub) + // Defense-in-depth (GHSA-8844-q5vh-9j8f, I3): previously this warmed + // the entire cache (runBlocking over all saved accounts) on any cold + // cache / size mismatch, decrypting every account's private key + // process-wide on the first IPC/relay lookup. Lazy-load only the + // requested account. The UI account switcher relies on + // allCachedAccounts() being populated, which reloadApp() handles at + // startup; this path is for direct lookups (SignerProvider / NIP-46) + // that always know which npub they want. + val targetNpub = npub ?: currentAccount(context) ?: return null + accountCache.get(targetNpub)?.let { return it } + if (!containsAccount(context, targetNpub)) return null + return runBlocking { loadFromEncryptedStorage(context, targetNpub) } } fun setAccountName( @@ -614,6 +630,23 @@ object LocalPreferences { } } + /** + * Toggles the opt-in "require unlocked device" key policy (GHSA-8844-q5vh-9j8f, L1). + * The Keystore flag is set at key-generation time, so changing the setting + * requires rotating the AMBER_AES_KEY: decrypt all stored secrets with the + * old key, delete it, generate a new one with/without + * setUnlockedDeviceRequired, and re-encrypt everything. + */ + suspend fun updateRequireUnlockedDevice(context: Context, enabled: Boolean) { + SecureCryptoHelper.rotateKey(context, requireUnlockedDevice = enabled) + sharedPrefs(context).edit { + apply { + putBoolean(SettingsKeys.REQUIRE_UNLOCKED_DEVICE.key, enabled) + } + } + Amber.instance.settings = loadSettingsFromEncryptedStorage() + } + fun updateUpdateCheckFrequency(context: Context, frequency: UpdateCheckFrequency) { sharedPrefs(context).edit { apply { @@ -659,7 +692,7 @@ object LocalPreferences { fun getWebDavFilename(context: Context): String = sharedPrefs(context).getString(SettingsKeys.WEBDAV_FILENAME.key, "amber_backup.txt") ?: "amber_backup.txt" suspend fun getWebDavPassword(context: Context): String { - val encrypted = sharedPrefs(context).getString(SettingsKeys.WEBDAV_PASSWORD_ENCRYPTED.key, "") ?: "" + val encrypted = getWebDavPasswordEncrypted(context) return if (encrypted.isBlank()) { "" } else { @@ -671,6 +704,25 @@ object LocalPreferences { } } + /** + * Returns the still-encrypted WebDAV password (raw SharedPreferences + * value). Used by [SecureCryptoHelper.rotateKey] to avoid mutex + * reentrancy. + */ + fun getWebDavPasswordEncrypted(context: Context): String = sharedPrefs(context).getString(SettingsKeys.WEBDAV_PASSWORD_ENCRYPTED.key, "") ?: "" + + /** + * Stores an already-encrypted WebDAV password. Used by + * [SecureCryptoHelper.rotateKey]. + */ + fun saveWebDavPasswordEncrypted(context: Context, encryptedPassword: String) { + sharedPrefs(context).edit { + apply { + putString(SettingsKeys.WEBDAV_PASSWORD_ENCRYPTED.key, encryptedPassword) + } + } + } + suspend fun saveWebDavSettings( context: Context, url: String, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SecureCryptoHelper.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SecureCryptoHelper.kt index 8613fc24..38d1b78b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SecureCryptoHelper.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SecureCryptoHelper.kt @@ -24,11 +24,11 @@ object SecureCryptoHelper { private val mutex = Mutex() suspend fun encrypt(plainText: String): String = mutex.withLock { - encryptBlocking(plainText) + encryptWithKey(getOrCreateSecretKey(), plainText) } suspend fun decrypt(encryptedText: String): String = mutex.withLock { - decryptBlocking(encryptedText) + decryptWithKey(getOrCreateSecretKey(), encryptedText) } /** @@ -41,7 +41,10 @@ object SecureCryptoHelper { * bridge. */ fun encryptBlocking(plainText: String): String { - val key = getOrCreateSecretKey() + return encryptWithKey(getOrCreateSecretKey(), plainText) + } + + private fun encryptWithKey(key: SecretKey, plainText: String): String { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, key) val iv = cipher.iv @@ -60,7 +63,10 @@ object SecureCryptoHelper { * rationale. */ fun decryptBlocking(encryptedText: String): String { - val key = getOrCreateSecretKey() + return decryptWithKey(getOrCreateSecretKey(), encryptedText) + } + + private fun decryptWithKey(key: SecretKey, encryptedText: String): String { val data = Base64.decode(encryptedText, Base64.NO_WRAP) val buffer = ByteBuffer.wrap(data) @@ -84,6 +90,12 @@ object SecureCryptoHelper { } } + return generateSecretKey(Amber.instance.settings.requireUnlockedDevice) + } + + private fun getKeyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + private fun generateSecretKey(requireUnlockedDevice: Boolean): SecretKey { val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) val paramsBuilder = KeyGenParameterSpec.Builder( KEY_ALIAS, @@ -93,7 +105,15 @@ object SecureCryptoHelper { .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Defense-in-depth (GHSA-8844-q5vh-9j8f, L1): opt-in toggle — when + // enabled, require the device to be unlocked at the time the key + // material is used, so a process that runs while the screen is + // locked cannot decrypt stored account keys. Does not prompt for + // biometrics per operation (which would break background NIP-46 + // signing); it only refuses key use while the device is locked. + // Available from API 28 (KeyGenParameterSpec.Builder). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && requireUnlockedDevice) { + paramsBuilder.setUnlockedDeviceRequired(true) try { if (Amber.instance.hasStrongBox()) { paramsBuilder.setIsStrongBoxBacked(true) @@ -111,6 +131,99 @@ object SecureCryptoHelper { return keyGenerator.generateKey() } } + + /** + * Rotates the [KEY_ALIAS] Keystore key, re-encrypting every stored secret + * (per-account DataStore keys, app DataStore PIN, and WebDAV password). + * + * The Keystore `setUnlockedDeviceRequired` flag is set at key-generation + * time, so toggling the opt-in policy requires destroying and recreating + * the key. The rotation is safe-by-design: all secrets are decrypted + * with the old key BEFORE the old key is deleted, so a mid-rotation crash + * leaves the old key intact (the delete is the only destructive step and + * it runs after all decryptions succeed). + * + * Uses internal non-locking cipher methods ([encryptWithKey]/ + * [decryptWithKey]) and raw DataStore helpers to avoid mutex reentrancy + * (Kotlin's [Mutex] is not reentrant). + */ + suspend fun rotateKey(context: Context, requireUnlockedDevice: Boolean) = mutex.withLock { + val keyStore = getKeyStore() + if (!keyStore.containsAlias(KEY_ALIAS)) { + generateSecretKey(requireUnlockedDevice) + return@withLock + } + + // 1. Get the old key and decrypt all stored secrets (before deleting). + val oldEntry = keyStore.getEntry(KEY_ALIAS, null) as KeyStore.SecretKeyEntry + val oldKey = oldEntry.secretKey + + val accounts = LocalPreferences.allSavedAccounts(context) + val decryptedAccountKeys = mutableMapOf>() + + for (acc in accounts) { + val rawPrivKey = DataStoreAccess.getEncryptedRaw(context, acc.npub, DataStoreAccess.NOSTR_PRIVKEY) + val rawSeedWords = DataStoreAccess.getEncryptedRaw(context, acc.npub, DataStoreAccess.SEED_WORDS) + decryptedAccountKeys[acc.npub] = Pair( + rawPrivKey?.let { + try { + decryptWithKey(oldKey, it) + } catch (_: Exception) { + null + } + }, + rawSeedWords?.let { + try { + decryptWithKey(oldKey, it) + } catch (_: Exception) { + null + } + }, + ) + } + + val rawPin = DataStoreAccess.getPinRaw(context) + val decryptedPin = rawPin?.let { + try { + decryptWithKey(oldKey, it) + } catch (_: Exception) { + null + } + } + + val encryptedWebDavPassword = LocalPreferences.getWebDavPasswordEncrypted(context) + val decryptedWebDavPassword = if (encryptedWebDavPassword.isNotBlank()) { + try { + decryptWithKey(oldKey, encryptedWebDavPassword) + } catch (_: Exception) { + null + } + } else { + null + } + + // 2. Delete the old key and generate a new one with the new policy. + keyStore.deleteEntry(KEY_ALIAS) + val newKey = generateSecretKey(requireUnlockedDevice) + + // 3. Re-encrypt and store all secrets with the new key. + for ((npub, keys) in decryptedAccountKeys) { + keys.first?.let { + DataStoreAccess.saveEncryptedRaw(context, npub, DataStoreAccess.NOSTR_PRIVKEY, encryptWithKey(newKey, it)) + } + keys.second?.let { + DataStoreAccess.saveEncryptedRaw(context, npub, DataStoreAccess.SEED_WORDS, encryptWithKey(newKey, it)) + } + } + decryptedPin?.let { + DataStoreAccess.savePinRaw(context, encryptWithKey(newKey, it)) + } + decryptedWebDavPassword?.let { + LocalPreferences.saveWebDavPasswordEncrypted(context, encryptWithKey(newKey, it)) + } + + AmberLog.d("SecureCryptoHelper", "Key rotation complete (requireUnlockedDevice=$requireUnlockedDevice)") + } } fun Context.hasStrongBox(): Boolean { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt index 4d868f21..029acfac 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt @@ -4,6 +4,7 @@ import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.net.Uri +import com.greenart7c3.nostrsigner.database.ApplicationDao import com.greenart7c3.nostrsigner.database.HistoryEntity import com.greenart7c3.nostrsigner.database.LogEntity import com.greenart7c3.nostrsigner.models.Account @@ -285,6 +286,35 @@ object SignerProviderQuery { null to "" } + // Pre-authorization (GHSA-8844-q5vh-9j8f, I1): do not invoke + // the decrypt key before we know the caller has *any* + // relevant permission, otherwise an unauthorized caller + // triggers a key-use / timing / error oracle even though + // the result is withheld. If no relevant grant exists and + // the per-app signPolicy is not "accept all" (== 2), + // short-circuit: the existing no-permission behavior is to + // return null (relay path → prompt; IPC path → caller + // surfaces its own approval flow), so returning null here + // is externally identical, but the private key is never + // touched for an unknown caller. The classified-specific + // grant case (e.g. a grant for DECRYPT_EVENT only) is + // preserved: ANY row of a relevant type counts as "has a + // relevant grant", so we still proceed to decrypt + + // classify, and the final per-type check below honors + // accept/reject precisely. + if (!isEncrypt) { + val preSignPolicy = permDao.getSignPolicy(requesterId) + val preAuthorized = preSignPolicy == 2 || + hasAnyDecryptPermission(permDao, requesterId, type, v3Kind, stringType) + if (!preAuthorized) { + AmberLog.d( + Amber.TAG, + "Skipping decrypt for $requesterId: no relevant permission and signPolicy != 2 (I1 pre-auth)", + ) + return null + } + } + // For ENCRYPT: classify plaintext input; for DECRYPT: perform operation first then classify result val result = if (isEncrypt) { @@ -638,4 +668,48 @@ object SignerProviderQuery { return null } } + + /** + * I1 pre-authorization gate (GHSA-8844-q5vh-9j8f): returns true when the + * requester has *any* permission row that could decide a decrypt of this + * type — generic ([type.toString()]), any of the V2 classified content + * types, the NIP-level grant, or (for V3) the kind-scoped / all-kinds rows. + * Used to short-circuit a decrypt request from a totally unknown caller + * *before* the private key is invoked, closing a key-use / timing / error + * oracle. Does not inspect accept/reject state: a row that auto-rejects + * still counts as "relevant" so the existing classify-and-decide path + * below runs and returns [rejectedCursor] as appropriate. + */ + private fun hasAnyDecryptPermission( + permDao: ApplicationDao, + requesterId: String, + type: SignerType, + v3Kind: Int?, + stringType: String, + ): Boolean { + if (permDao.getPermission(requesterId, type.toString()) != null) return true + + if (v3Kind != null) { + if (permDao.getPermission(requesterId, type.toString(), v3Kind) != null) return true + if (permDao.getPermissionAllKinds(requesterId, type.toString()) != null) return true + } else { + // V2 classified content-type grants. The set of possible types + // comes from permissionTypeFromContent / EncryptedDataKind. + if (permDao.getPermission(requesterId, "DECRYPT_CLEAR_TEXT") != null) return true + if (permDao.getPermission(requesterId, "DECRYPT_EVENT") != null) return true + if (permDao.getPermission(requesterId, "DECRYPT_TAG_ARRAY") != null) return true + if (permDao.getPermission(requesterId, "DECRYPT_ZAP_EVENT") != null) return true + + // NIP-level fallback grant (matches the lookup at line ~341-358). + val nip = when (stringType) { + "NIP04_DECRYPT" -> 4 + "NIP44_DECRYPT" -> 44 + else -> null + } + nip?.let { + if (permDao.getPermission(requesterId, "NIP", it) != null) return true + } + } + return false + } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt index ae856f5b..d0ed5978 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt @@ -40,6 +40,7 @@ data class AmberSettings( val rateLimitWindowSeconds: Int = 30, val profileFetchInterval: ProfileFetchInterval = ProfileFetchInterval.FIFTEEN_MINUTES, val trustScoreEnabled: Boolean = true, + val requireUnlockedDevice: Boolean = false, ) { val useProxy: Boolean get() = torMode != TorMode.DISABLED } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt index 646f14d4..2ba302c9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt @@ -133,7 +133,11 @@ class NostrClientLoggerListener( } override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { - AmberLog.d(Amber.TAG, "onSent: ${relay.url.url} success: $success cmd: $cmdStr") + // Defense-in-depth (GHSA-8844-q5vh-9j8f, L2): never log raw relay + // frames verbatim — they can carry NIP-46 envelopes, DM/gift-wrap + // ciphertexts and event content. Log only the command type and the + // wire size, which is enough for debugging without leaking payloads. + AmberLog.d(Amber.TAG, "onSent: ${relay.url.url} success: $success cmd: ${cmd.javaClass.simpleName} bytes: ${cmdStr.toByteArray().size}") if (cmd is ReqCmd) { saveLog(relay.url.url, "onSent", "Subscribed to relay: $success") @@ -156,7 +160,10 @@ class NostrClientLoggerListener( } override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { - AmberLog.d(Amber.TAG, "onIncomingMessage: ${relay.url.url} msg: $msgStr") + // Defense-in-depth (GHSA-8844-q5vh-9j8f, L2): log only the message + // type and wire size, not the raw frame (which may carry NIP-46 + // envelopes, DM/gift-wrap ciphertexts and event content). + AmberLog.d(Amber.TAG, "onIncomingMessage: ${relay.url.url} msg: ${msg.javaClass.simpleName} bytes: ${msgStr.toByteArray().size}") if (msg is OkMessage) { saveLog(relay.url.url, "onIncomingMessage", "Relay accepted message: ${msg.success}") diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/crashreports/ReportAssembler.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/crashreports/ReportAssembler.kt index 74e5ff5f..a37a6852 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/crashreports/ReportAssembler.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/crashreports/ReportAssembler.kt @@ -71,22 +71,46 @@ class ReportAssembler { appendLine(" |") appendLine() + // Defense-in-depth (GHSA-8844-q5vh-9j8f, L3): exception messages and + // stack traces can embed attacker-controlled request data (parser/ + // crypto exceptions quoting bad input). Redact Nostr key material + // (bech32 npub/nsec/etc, 64-char hex keys) and long base64 blobs + // before persistence so the crash report cannot leak secrets. appendLine("```") - appendLine(e.toString()) + appendLine(redactSensitive(e.toString())) e.stackTrace.forEach { append(" ") - appendLine(it.toString()) + appendLine(redactSensitive(it.toString())) } val cause = e.cause if (cause != null) { appendLine("\n\nCause:") append(" ") - appendLine(cause.toString()) + appendLine(redactSensitive(cause.toString())) cause.stackTrace.forEach { append(" ") - appendLine(it.toString()) + appendLine(redactSensitive(it.toString())) } } appendLine("```") } + + private fun redactSensitive(input: String): String = input + .replace(BECH32_REDACT, "") + .replace(HEXKEY_REDACT, "") + .replace(BASE64_REDACT, "") + + private companion object { + // bech32 prefixes used by Nostr (npub, nsec, note, nprofile, nevent, + // naddr, nrelay, ncryptsec). Match the hrp + data part; bech32 uses + // the alphabet 023456789acdefghjklmnpqrstuvwxyz (no b, i, o, 1). + private val BECH32_REDACT = + Regex("\\b(npub|nsec|note|nprofile|nevent|naddr|nrelay|ncryptsec)1[023456789acdefghjklmnpqrstuvwxyz]{6,}\\b") + + // 64-char lowercase hex keys (pubkeys/event ids). + private val HEXKEY_REDACT = Regex("\\b[0-9a-f]{64}\\b") + + // Long base64 runs (>=32 chars): NIP-44/44v3 ciphertexts, payloads. + private val BASE64_REDACT = Regex("\\b[A-Za-z0-9+/]{32,}={0,2}\\b") + } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SecurityScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SecurityScreen.kt index b135a125..bd5b6ed1 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SecurityScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SecurityScreen.kt @@ -51,6 +51,7 @@ fun SecurityScreen( var enableBiometrics by remember { mutableStateOf(Amber.instance.settings.useAuth) } val setupPin by remember { mutableStateOf(Amber.instance.settings.usePin) } var privacyMode by remember { mutableStateOf(Amber.instance.settings.privacyMode) } + var requireUnlockedDevice by remember { mutableStateOf(Amber.instance.settings.requireUnlockedDevice) } var biometricsIndex by remember { mutableIntStateOf(Amber.instance.settings.biometricsTimeType.screenCode) } @@ -121,6 +122,44 @@ fun SecurityScreen( ) } + // GHSA-8844-q5vh-9j8f, L1: opt-in toggle + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clickable { + val newValue = !requireUnlockedDevice + requireUnlockedDevice = newValue + // Use the application-scoped IOScope, not the + // composition scope: key rotation must complete + // even if the user leaves the screen/app, or + // stored secrets could be left inaccessible. + Amber.instance.applicationIOScope.launch(Dispatchers.IO) { + LocalPreferences.updateRequireUnlockedDevice(context, newValue) + } + }, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = stringResource(R.string.require_unlocked_device)) + Text( + text = stringResource(R.string.require_unlocked_device_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + Switch( + checked = requireUnlockedDevice, + onCheckedChange = { enabled -> + requireUnlockedDevice = enabled + Amber.instance.applicationIOScope.launch(Dispatchers.IO) { + LocalPreferences.updateRequireUnlockedDevice(context, enabled) + } + }, + ) + } + Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt index 565b676a..3e3cf0f4 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt @@ -578,7 +578,11 @@ fun parseRememberType(screenCode: Int): RememberType = when (screenCode) { RememberType.ONE_HOUR.screenCode -> RememberType.ONE_HOUR RememberType.ONE_DAY.screenCode -> RememberType.ONE_DAY RememberType.ONE_WEEK.screenCode -> RememberType.ONE_WEEK - else -> RememberType.ALWAYS + RememberType.ALWAYS.screenCode -> RememberType.ALWAYS + // Defense-in-depth (GHSA-8844-q5vh-9j8f, I2): unknown screenCode values + // previously failed open to ALWAYS. Fail closed to NEVER so a corrupted + // or unrecognized stored policy cannot grant indefinite auto-accept. + else -> RememberType.NEVER } enum class DeleteAfterType(val screenCode: Int, val resourceId: Int) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt index 01dd88f0..ef0793e8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt @@ -545,6 +545,20 @@ fun QrCodeScreen( modifier: Modifier, content: String, ) { + // Defense-in-depth (GHSA-8844-q5vh-9j8f, L4): this screen renders the + // raw nsec/ncryptsec as a QR code (via Route.QrCode). Prevent + // screenshots/screen recording for the lifetime of the screen. + val context = LocalContext.current + DisposableEffect(Unit) { + val window = (context as? Activity)?.window + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } Column( modifier = modifier.fillMaxSize(), ) { @@ -579,10 +593,18 @@ fun QrCodeDialog( window?.attributes = window.attributes?.apply { screenBrightness = 1f } + // Defense-in-depth (GHSA-8844-q5vh-9j8f, L4): this dialog renders the + // raw nsec as a QR code. Prevent screenshots/screen recording for the + // lifetime of the dialog. Mirrors SeedWordsPage/RandomPinInput. + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) onDispose { window?.attributes = window.attributes?.apply { screenBrightness = originalBrightness ?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE } + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt index caa263be..9cafa6fd 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/EditRelaysDialog.kt @@ -275,6 +275,26 @@ fun DefaultRelaysScreen( }, ) + // Defense-in-depth (GHSA-8844-q5vh-9j8f, I4): warn when the + // user explicitly types a ws:// URL for a non-onion / + // non-private-network relay, since NIP-46 traffic over + // cleartext exposes metadata + ciphertext to network + // observers. wss:// is the default; ws:// is auto-prefixed + // only for .onion / private IPs in onAddRelay. + val text = textFieldRelay.value.text + val showInsecureWarning = text.startsWith("ws://") && + !text.endsWith(".onion") && + !text.endsWith(".onion/") && + !Amber.instance.isPrivateIp(text) + if (showInsecureWarning) { + Text( + text = stringResource(R.string.insecure_relay_warning), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 12.sp, + ) + } + LazyColumn( Modifier .weight(1f), diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e45dbdcf..55eed1cf 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -465,7 +465,7 @@ Anwendungen Eingehende Anfrage Zurück zu %1$s - + Einen QR-Code scannen Einen nsecbunker hinzufügen Mit dieser Option kann ein nsecbunker manuell hinzugefügt und mit jeder App verwendet werden, die Nostr Connect (NIP-46) unterstützt Wenn deine App einen Nostr-Connect-QR-Code anbietet, kannst du ihn hier scannen. Dieser Anmeldemodus wird üblicherweise von Web-Apps angeboten. @@ -862,4 +862,10 @@ App-Aktivität anzeigen Fehler melden oder Funktion vorschlagen Nach neuen Versionen suchen + Entsperrtes Gerät für den Schlüsselzugriff anfordern + Wenn aktiviert, kann der Keystore-Schlüssel zum Entschlüsseln Ihrer gespeicherten Kontoschlüssel nicht verwendet werden, während das Gerät gesperrt ist. Dies verhindert, dass Code, der im Amber-Prozess ausgeführt wird, Ihre Schlüssel entschlüsselt, während der Bildschirm gesperrt ist. Hinweis: Dies deaktiviert die Hintergrund-NIP-46-Signierung, während das Gerät gesperrt ist. Das Umschalten erfordert eine erneute Verschlüsselung aller gespeicherten Schlüssel. + Unsicheres (Klartext, ws://) Relay über das öffentliche Internet legt NIP-46-Metadaten und Chiffretext für Netzwerkbeobachter offen. Bevorzugen Sie wss://, oder verwenden Sie ws:// nur für .onion / lokale Netzwerk-Relays. + Verbinde mit Tor: %1$d%% + Tor-Verbindung fehlgeschlagen + Neustart diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3df91891..d0ee6651 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -33,7 +33,7 @@ Activar cuenta Editar nombre "Copiar " - + Datos copiados al portapapeles Conectar app Evento copiado al portapapeles Seleccionar/Deseleccionar todo @@ -865,4 +865,10 @@ Ver la actividad de la app Informar de un error o solicitar una función Buscar nuevas versiones + Requerir dispositivo desbloqueado para acceder a la clave + Cuando está habilitado, la clave de Keystore utilizada para descifrar las claves de su cuenta almacenadas no se puede usar mientras el dispositivo está bloqueado. Esto evita que cualquier código que se ejecute en el proceso de Amber descifre sus claves mientras la pantalla está bloqueada. Nota: esto deshabilita la firma NIP-46 en segundo plano mientras el dispositivo está bloqueado. Cambiar esto requiere volver a cifrar todas las claves almacenadas. + Un relay inseguro (texto plano, ws://) a través de la Internet pública expone los metadatos y el texto cifrado de NIP-46 a los observadores de la red. Prefiera wss://, o use ws:// solo para relays .onion / de red local. + Conectando a Tor: %1$d%% + Conexión a Tor fallida + Reiniciar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2f87e7a4..fe823b9b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -862,4 +862,10 @@ Voir l\'activité de l\'application Signaler un bug ou demander une fonctionnalité Rechercher de nouvelles versions + Exiger un appareil déverrouillé pour l\'accès aux clés + Une fois activée, la clé Keystore utilisée pour déchiffrer les clés de votre compte stockées ne peut pas être utilisée lorsque l\'appareil est verrouillé. Cela empêche tout code s\'exécutant dans le processus d\'Amber de déchiffrer vos clés lorsque l\'écran est verrouillé. Remarque : cela désactive la signature NIP-46 en arrière-plan lorsque l\'appareil est verrouillé. L\'activation de cette option nécessite le rechiffrement de toutes les clés stockées. + Un relais non sécurisé (texte clair, ws://) sur l\'Internet public expose les métadonnées et le texte chiffré NIP-46 aux observateurs du réseau. Préférez wss://, ou utilisez ws:// uniquement pour les relais .onion / réseau local. + Connexion à Tor : %1$d%% + Échec de la connexion à Tor + Redémarrer diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 6a1d9a85..85daba02 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -865,4 +865,10 @@ Lihat aktivitas aplikasi Laporkan bug atau minta fitur Periksa versi baru + Wajibkan perangkat tidak terkunci untuk akses kunci + Saat diaktifkan, kunci Keystore yang digunakan untuk mendekripsi kunci akun yang Anda simpan tidak dapat digunakan saat perangkat terkunci. Ini mencegah kode apa pun yang berjalan di proses Amber mendekripsi kunci Anda saat layar terkunci. Catatan: ini menonaktifkan penandatanganan NIP-46 di latar belakang saat perangkat terkunci. Mengalihkan ini memerlukan enkripsi ulang semua kunci yang disimpan. + Relay yang tidak aman (teks biasa, ws://) melalui internet publik mengekspos metadata dan teks sandi NIP-46 ke pengamat jaringan. Pilih wss://, atau gunakan ws:// hanya untuk relay .onion / jaringan lokal. + Menghubungkan ke Tor: %1$d%% + Koneksi Tor gagal + Mulai ulang diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 7bad7f02..7ee564ee 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -865,4 +865,10 @@ Visualizza l\'attività dell\'app Segnala un bug o richiedi una funzionalità Controlla nuove versioni + Richiedi dispositivo sbloccato per l\'accesso alle chiavi + Se abilitato, la chiave Keystore utilizzata per decrittografare le chiavi del tuo account memorizzate non può essere utilizzata mentre il dispositivo è bloccato. Ciò impedisce a qualsiasi codice in esecuzione nel processo di Amber di decrittografare le tue chiavi mentre lo schermo è bloccato. Nota: questo disabilita la firma NIP-46 in background mentre il dispositivo è bloccato. L\'attivazione di questa opzione richiede la ri-crittografia di tutte le chiavi memorizzate. + Un relay non sicuro (testo in chiaro, ws://) su internet pubblico espone i metadati NIP-46 e il testo cifrato agli osservatori di rete. Preferisci wss://, o usa ws:// solo per relay .onion / di rete locale. + Connessione a Tor: %1$d%% + Connessione a Tor fallita + Riavvia diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index aa8d484d..78af5619 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -841,4 +841,10 @@ アプリのアクティビティを表示 バグを報告または機能をリクエスト 新しいバージョンを確認 + キーアクセスにデバイスのロック解除を要求する + 有効にすると、保存されたアカウントキーを復号化するために使用されるKeystoreキーは、デバイスがロックされている間は使用できなくなります。これにより、画面がロックされている間にAmberのプロセスで実行されているコードがキーを復号化するのを防ぎます。注:これにより、デバイスがロックされている間、バックグラウンドでのNIP-46署名が無効になります。これを切り替えるには、すべての保存されたキーの再暗号化が必要です。 + 公開インターネットを介した安全でない(クリアテキスト、ws://)リレーは、NIP-46のメタデータと暗号文をネットワークオブザーバーにさらします。wss://を優先するか、.onion / ローカルネットワークリレーの場合のみws://を使用してください。 + Torに接続中: %1$d%% + Torへの接続に失敗しました + 再起動 diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index aa07c385..3e0c623c 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -865,4 +865,10 @@ 앱 활동 보기 버그 신고 또는 기능 요청 새 버전 확인 + 키 액세스를 위해 잠금 해제된 장치 필요 + 활성화하면 저장된 계정 키를 복호화하는 데 사용되는 Keystore 키를 장치가 잠겨 있는 동안 사용할 수 없습니다. 이는 화면이 잠겨 있는 동안 Amber 프로세스에서 실행되는 코드가 키를 복호화하는 것을 방지합니다. 참고: 장치가 잠겨 있는 동안 백그라운드 NIP-46 서명이 비활성화됩니다. 이를 전환하려면 저장된 모든 키를 다시 암호화해야 합니다. + 공개 인터넷을 통한 안전하지 않은(일반 텍스트, ws://) 릴레이는 NIP-46 메타데이터와 암호문을 네트워크 관찰자에게 노출합니다. wss://를 선호하거나, .onion / 로컬 네트워크 릴레이에 대해서만 ws://를 사용하십시오. + Tor 연결 중: %1$d%% + Tor 연결 실패 + 재시작 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4d22b5ab..f18b7dfe 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -731,10 +731,10 @@ Recuperar do backup Importando conta %1$d Não foi possível conectar ao relay. Gostaria de adicioná-lo mesmo assim? - + Sinalização WebRTC Zap privado Bons autores da wiki - + Bons relays de wiki Notificação de relay Status do relay Tipo @@ -860,4 +860,10 @@ Ver a atividade do app Relatar um erro ou solicitar um recurso Verificar novas versões + Exigir dispositivo desbloqueado para acesso à chave + Quando ativado, a chave do Keystore usada para descriptografar as chaves da sua conta armazenada não pode ser usada enquanto o dispositivo estiver bloqueado. Isso evita que qualquer código em execução no processo do Amber descriptografe suas chaves enquanto a tela estiver bloqueada. Observação: isso desativa a assinatura NIP-46 em segundo plano enquanto o dispositivo estiver bloqueado. Alternar isso requer a criptografia de todas as chaves armazenadas. + Um relay inseguro (texto simples, ws://) pela internet pública expõe metadados e texto cifrado do NIP-46 a observadores de rede. Prefira wss://, ou use ws:// apenas para relays .onion / de rede local. + Conectando ao Tor: %1$d%% + Falha na conexão ao Tor + Reiniciar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c6dc41af..41927aeb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -865,4 +865,10 @@ Просмотр активности приложения Сообщить об ошибке или запросить функцию Проверить наличие новых версий + Требовать разблокировки устройства для доступа к ключам + Если этот параметр включен, ключ Keystore, используемый для расшифровки сохраненных ключей вашей учетной записи, нельзя использовать, пока устройство заблокировано. Это предотвращает расшифровку ваших ключей любым кодом, запущенным в процессе Amber, пока экран заблокирован. Примечание: это отключает фоновую подпись NIP-46, пока устройство заблокировано. Переключение этого параметра требует повторного шифрования всех сохраненных ключей. + Незащищенное (открытый текст, ws://) реле через общедоступный Интернет раскрывает метаданные NIP-46 и зашифрованный текст сетевым наблюдателям. Предпочитайте wss:// или используйте ws:// только для реле .onion / локальной сети. + Подключение к Tor: %1$d%% + Ошибка подключения к Tor + Перезапуск diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 66f96aeb..da34c7ce 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -841,4 +841,10 @@ ดูกิจกรรมของแอป รายงานข้อบกพร่องหรือขอคุณสมบัติ ตรวจหาเวอร์ชันใหม่ + ต้องปลดล็อกอุปกรณ์เพื่อเข้าถึงคีย์ + เมื่อเปิดใช้งาน คีย์ Keystore ที่ใช้ในการถอดรหัสคีย์บัญชีที่เก็บไว้ของคุณจะไม่สามารถใช้งานได้ในขณะที่อุปกรณ์ถูกล็อก ซึ่งจะช่วยป้องกันไม่ให้โค้ดใดๆ ที่ทำงานในกระบวนการของ Amber ถอดรหัสคีย์ของคุณในขณะที่หน้าจอถูกล็อก หมายเหตุ: การดำเนินการนี้จะปิดใช้งานการลงนาม NIP-46 ในเบื้องหลังในขณะที่อุปกรณ์ถูกล็อก การสลับการตั้งค่านี้ต้องมีการเข้ารหัสคีย์ที่เก็บไว้ทั้งหมดใหม่ + รีเลย์ที่ไม่ปลอดภัย (ข้อความธรรมดา, ws://) ผ่านอินเทอร์เน็ตสาธารณะจะเปิดเผยข้อมูลเมตาและข้อความไซเฟอร์เท็กซ์ของ NIP-46 ต่อผู้สังเกตการณ์เครือข่าย แนะนำให้ใช้ wss:// หรือใช้ ws:// เฉพาะสำหรับรีเลย์ .onion / เครือข่ายท้องถิ่นเท่านั้น + กำลังเชื่อมต่อกับ Tor: %1$d%% + การเชื่อมต่อ Tor ล้มเหลว + เริ่มใหม่ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 478e3baa..6ad8e03d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -861,4 +861,10 @@ Uygulama etkinliğini görüntüleyin Hata bildirin veya özellik isteyin Yeni sürümleri kontrol edin + Anahtar erişimi için kilit açılmış cihaz gerektir + Etkinleştirildiğinde, kayıtlı hesap anahtarlarınızı çözmek için kullanılan Keystore anahtarı cihaz kilitliyken kullanılamaz. Bu, ekran kilitliyken Amber\'ın sürecinde çalışan herhangi bir kodun anahtarlarınızı çözmesini engeller. Not: Bu, cihaz kilitliyken arka planda NIP-46 imzalama işlemini devre dışı bırakır. Bunu değiştirmek, kayıtlı tüm anahtarların yeniden şifrelenmesini gerektirir. + Genel internet üzerindeki güvensiz (açık metin, ws://) röle, NIP-46 meta verilerini ve şifreli metni ağ gözlemcilerine ifşa eder. wss:// tercih edin veya ws://\'yi yalnızca .onion / yerel ağ röleleri için kullanın. + Tor\'a bağlanılıyor: %1$d%% + Tor bağlantısı başarısız oldu + Yeniden başlat diff --git a/app/src/main/res/values-vi-rVN/strings.xml b/app/src/main/res/values-vi-rVN/strings.xml index db268442..cd1b6fd5 100644 --- a/app/src/main/res/values-vi-rVN/strings.xml +++ b/app/src/main/res/values-vi-rVN/strings.xml @@ -841,4 +841,10 @@ Xem hoạt động của ứng dụng Báo lỗi hoặc yêu cầu tính năng Kiểm tra phiên bản mới + Yêu cầu mở khóa thiết bị để truy cập khóa + Khi được bật, khóa Keystore dùng để giải mã các khóa tài khoản đã lưu của bạn không thể sử dụng được khi thiết bị đang khóa. Điều này ngăn bất kỳ mã nào chạy trong quy trình của Amber giải mã khóa của bạn khi màn hình đang khóa. Lưu ý: điều này sẽ vô hiệu hóa việc ký NIP-46 trong nền khi thiết bị đang khóa. Việc bật/tắt tính năng này yêu cầu mã hóa lại tất cả các khóa đã lưu. + Relay không an toàn (văn bản thuần túy, ws://) qua internet công cộng sẽ để lộ siêu dữ liệu NIP-46 và văn bản mã hóa cho những người quan sát mạng. Ưu tiên wss:// hoặc chỉ sử dụng ws:// cho các relay .onion / mạng cục bộ. + Đang kết nối với Tor: %1$d%% + Kết nối Tor thất bại + Khởi động lại diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0fd1fe16..2fea9f37 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -277,7 +277,7 @@ 创建或更新市场产品出售形式为拍卖 长篇内容 长篇内容草稿 - + 表情符号集 发行产物合集 特定应用数据 直播事件 @@ -846,4 +846,10 @@ 查看应用活动 报告错误或请求功能 检查新版本 + 访问密钥需要解锁设备 + 启用后,在设备锁定期间,将无法使用用于解密存储的帐户密钥的密钥库密钥。这可以防止在屏幕锁定期间 Amber 进程中运行的任何代码解密您的密钥。注意:这会在设备锁定期间禁用后台 NIP-46 签名。切换此设置需要重新加密所有存储的密钥。 + 通过公共互联网进行的不安全(明文,ws://)中继会将 NIP-46 元数据和密文暴露给网络观察者。建议优先使用 wss://,或仅对 .onion / 本地网络中继使用 ws://。 + 正在连接到 Tor: %1$d%% + Tor 连接失败 + 重启 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60120709..bb67e3be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -555,6 +555,9 @@ Name No relays added wss://… + Require unlocked device for key access + When enabled, the Keystore key used to decrypt your stored account keys cannot be used while the device is locked. This prevents any code running in Amber\'s process from decrypting your keys while the screen is locked. Note: this disables background NIP-46 signing while the device is locked. Toggling this requires re-encrypting all stored keys. + Insecure (cleartext, ws://) relay over the public internet exposes NIP-46 metadata and ciphertext to network observers. Prefer wss://, or use ws:// only for .onion / local-network relays. "Name can't be empty " Your nsecbunker is ready! Use this url in your app: diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 7f5715bd..ecaba4a4 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,5 +1,10 @@ - + ")) + } + + @Test + fun `redacts npub bech32 from exception messages`() { + // npub1... derived from the hex key above + val npub = "npub10qp7qrzdrqmax40ayhxm2vp6d8yurfr3lwxyh4df0sf5r8cjnrms2cv7aj" + val e = IllegalArgumentException("could not parse $npub") + + val report = ReportAssembler().buildReport(e) + + assertFalse("npub must be redacted", report.contains(npub)) + assertTrue("redaction marker present", report.contains("")) + } + + @Test + fun `redacts long base64 blobs from exception messages`() { + val base64 = "kL9/abcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZ+/" + val e = RuntimeException("decryption failed for payload=$base64") + + val report = ReportAssembler().buildReport(e) + + assertFalse("base64 must be redacted", report.contains(base64)) + assertTrue("redaction marker present", report.contains("")) + } + + @Test + fun `preserves non-sensitive message content`() { + val e = IllegalStateException("null pointer in feature X") + + val report = ReportAssembler().buildReport(e) + + assertTrue("non-sensitive content preserved", report.contains("null pointer in feature X")) + } + + @Test + fun `redacts cause chain too`() { + val cause = IllegalArgumentException("bad hex $npubHex") + val e = RuntimeException("wrapper", cause) + + val report = ReportAssembler().buildReport(e) + + assertFalse("cause hex must be redacted", report.contains(npubHex)) + } +}