mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 077c82b63d6af8607846da8969202ea927b46c74 into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
3b0239ee61
@ -0,0 +1,116 @@
|
||||
package com.bitchat.android.identity
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The rule that decides whether a verified seal still applies to the name a
|
||||
* peer is currently announcing.
|
||||
*
|
||||
* A verification binds a FINGERPRINT, which is right — but it is *rendered*
|
||||
* beside a self-claimed nickname, and nothing binds those two together. So a
|
||||
* key that gets verified once under any name can rename itself onto a nickname
|
||||
* the user trusts and keep drawing the seal beside the new one. See
|
||||
* [SecureIdentityStateManager.setVerifiedFingerprint].
|
||||
*
|
||||
* Deliberately free of Android imports so it is reachable from a plain JVM unit
|
||||
* test: this is the whole security decision, and it should not be testable only
|
||||
* through a view.
|
||||
*/
|
||||
object NicknameBinding {
|
||||
|
||||
/**
|
||||
* The form two nicknames are compared in to decide whether they are the
|
||||
* SAME NAME.
|
||||
*
|
||||
* NFC, then a locale-independent case fold, then NFC again — case folding
|
||||
* can itself emit decomposed sequences (Turkish İ lowercases to i + U+0307),
|
||||
* so normalising only once leaves two spellings of one name unequal.
|
||||
*
|
||||
* `Locale.ROOT` is not optional. `lowercase()` with the default locale makes
|
||||
* a Turkish phone fold `I` to `ı` while every other device folds it to `i`,
|
||||
* so two users would disagree about whether a peer had renamed.
|
||||
*
|
||||
* Deliberately NFC and **not** NFKC: a fullwidth `Medic` merely *looks*
|
||||
* like `Medic`, so it is a different name and must break the binding.
|
||||
* Folding look-alikes is a different question — whether two peers on screen
|
||||
* need telling apart — and answering it here would let a vouch for one name
|
||||
* quietly cover another.
|
||||
*/
|
||||
fun bindingKey(nickname: String): String =
|
||||
nfc(nfc(nickname).lowercase(Locale.ROOT))
|
||||
|
||||
private fun nfc(s: String): String = Normalizer.normalize(s, Normalizer.Form.NFC)
|
||||
|
||||
/**
|
||||
* Strips ONLY a trailing `#abcd` collision suffix, leaving everything else
|
||||
* alone.
|
||||
*
|
||||
* ASCII hex only. `Char.isDigit()` and friends accept fullwidth digits, so a
|
||||
* nickname literally ending in `#ABCD` would otherwise be truncated and
|
||||
* could then match a baseline it is not — a seal on a name that was never
|
||||
* verified, which is the whole subject of this change.
|
||||
*/
|
||||
fun withoutCollisionSuffix(name: String): String {
|
||||
if (name.length < 5) return name
|
||||
val tail = name.substring(name.length - 5)
|
||||
if (tail[0] != '#') return name
|
||||
for (i in 1 until 5) {
|
||||
val c = tail[i]
|
||||
val isAsciiHex = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||
if (!isAsciiHex) return name
|
||||
}
|
||||
return name.substring(0, name.length - 5)
|
||||
}
|
||||
|
||||
/** Two nicknames that are the same name, by [bindingKey]. */
|
||||
private fun sameName(a: String, b: String): Boolean = bindingKey(a) == bindingKey(b)
|
||||
|
||||
/**
|
||||
* Does a seal earned under [pinned] still apply to a peer ANNOUNCING
|
||||
* [announced]?
|
||||
*
|
||||
* No suffix stripping. What a peer announces is its own string, and a `#`
|
||||
* plus four hex is a perfectly legal thing to announce — this app's own
|
||||
* `splitSuffix` exists because announced names carry them. Stripping here
|
||||
* would let a key pinned as `medic` rename to `medic#cafe` and keep its
|
||||
* seal, and would drop the seal of a key honestly verified as `medic#cafe`.
|
||||
*
|
||||
* Fails **open** when nothing was pinned, or when there is no announced
|
||||
* name yet. Peers verified by builds from before this existed have no
|
||||
* baseline, and dropping their seals on upgrade would teach people to
|
||||
* ignore the signal — which costs more than the narrow case it would catch.
|
||||
*/
|
||||
fun sealAppliesToAnnounced(pinned: String?, announced: String?): Boolean {
|
||||
if (pinned.isNullOrEmpty() || announced.isNullOrEmpty()) return true
|
||||
return sameName(pinned, announced)
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a seal earned under [pinned] still apply beside [rendered] — a name
|
||||
* as it appears on a row?
|
||||
*
|
||||
* On this platform that is the same question as [sealAppliesToAnnounced],
|
||||
* and the answer is the same comparison, because **nothing decorates a mesh
|
||||
* nickname here.** `splitSuffix` only separates a suffix the peer itself
|
||||
* announced, and `showHashSuffix` decides whether to display it; the one
|
||||
* place a `#abcd` is synthesised is `GeohashPeopleList`, for Nostr people,
|
||||
* and `isPeerVerified` refuses those outright. iOS is the one that appends
|
||||
* `#` plus four hex of the peerID in `PeerDisplayNameResolver`, and it has
|
||||
* to remove exactly that suffix and no other.
|
||||
*
|
||||
* It is kept as a separate function anyway, named for the question it
|
||||
* answers, so that the day a row here does start carrying a decoration
|
||||
* there is one place to change — and so nobody reads the single comparison
|
||||
* as the two questions having merged.
|
||||
*
|
||||
* The first version of this stripped any trailing `#` plus four hex. That
|
||||
* is a fail-OPEN on this platform: a key pinned as `medic` renaming to
|
||||
* `medic#cafe` had the suffix removed and kept its seal, and `#cafe` reads
|
||||
* as the disambiguator the app generates elsewhere, which is a better
|
||||
* disguise than an unrelated name rather than a worse one.
|
||||
*/
|
||||
fun sealAppliesToRendered(pinned: String?, rendered: String?): Boolean =
|
||||
sealAppliesToAnnounced(pinned, rendered)
|
||||
|
||||
}
|
||||
@ -31,6 +31,11 @@ class SecureIdentityStateManager {
|
||||
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
|
||||
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
|
||||
private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
|
||||
// The nickname each fingerprint was announcing when it was verified.
|
||||
// Distinct from KEY_CACHED_FINGERPRINT_NICKNAMES, which is a LAST SEEN
|
||||
// cache overwritten on every peer-list refresh — comparing against that
|
||||
// would always match and catch nothing.
|
||||
private const val KEY_VERIFIED_NICKNAMES = "verified_nicknames_v1"
|
||||
private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
|
||||
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
|
||||
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
|
||||
@ -223,7 +228,14 @@ class SecureIdentityStateManager {
|
||||
return getVerifiedFingerprints().contains(fingerprint)
|
||||
}
|
||||
|
||||
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
|
||||
/**
|
||||
* @param nickname the name this peer was announcing at the moment it was
|
||||
* verified. Recorded so the seal can be bound to it — see
|
||||
* [getVerifiedNickname]. Optional so callers that genuinely have no name
|
||||
* to hand (a verification completed before the first announce arrived)
|
||||
* can omit it and fail open rather than pin an empty string.
|
||||
*/
|
||||
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean, nickname: String? = null) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
@ -234,6 +246,68 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
|
||||
}
|
||||
if (verified) {
|
||||
// Re-verifying overwrites: the user just checked this key again, in
|
||||
// person, under whatever name it presents now.
|
||||
if (!nickname.isNullOrBlank()) pinVerifiedNickname(fingerprint, nickname)
|
||||
} else {
|
||||
clearVerifiedNickname(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verified nicknames
|
||||
//
|
||||
// A verification binds a FINGERPRINT, which is right. But the seal is
|
||||
// *rendered* beside a self-claimed nickname, and until now nothing bound
|
||||
// those two together: a key verified once under any name could rename
|
||||
// itself onto a nickname the user trusts and keep the seal beside the new
|
||||
// one. The rename is free and silent — `PeerManager` computes
|
||||
// `nicknameChanged` only to decide whether to refresh the list.
|
||||
//
|
||||
// The receiver is the only party that can hold this binding, because it is
|
||||
// the one that decided to trust this key while it was presenting a
|
||||
// particular name.
|
||||
|
||||
/** The nickname [fingerprint] was announcing when it was verified, or null
|
||||
* if nothing was bound. */
|
||||
fun getVerifiedNickname(fingerprint: String): String? {
|
||||
if (!isValidFingerprint(fingerprint)) return null
|
||||
val key = fingerprint.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_VERIFIED_NICKNAMES, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
|
||||
return runCatching {
|
||||
String(Base64.decode(entry.substringAfter('='), Base64.NO_WRAP), Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when a baseline exists AND this peer now announces something
|
||||
* else. Fails OPEN on a missing baseline: peers verified by builds from
|
||||
* before this existed have none, and dropping their seals on upgrade would
|
||||
* teach people to ignore the signal.
|
||||
*/
|
||||
fun verifiedNicknameMismatch(fingerprint: String, announcedNickname: String?): Boolean =
|
||||
!NicknameBinding.sealAppliesToAnnounced(getVerifiedNickname(fingerprint), announcedNickname)
|
||||
|
||||
private fun pinVerifiedNickname(fingerprint: String, nickname: String) {
|
||||
val key = fingerprint.lowercase()
|
||||
val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$key=") }
|
||||
current.add("$key=$encoded")
|
||||
prefs.edit { putStringSet(KEY_VERIFIED_NICKNAMES, current) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearVerifiedNickname(fingerprint: String) {
|
||||
val key = fingerprint.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_NICKNAMES, emptySet())?.toMutableSet() ?: return
|
||||
if (current.removeAll { it.startsWith("$key=") }) {
|
||||
prefs.edit { putStringSet(KEY_VERIFIED_NICKNAMES, current) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedPeerFingerprint(peerID: String): String? {
|
||||
|
||||
@ -1258,17 +1258,44 @@ class ChatViewModel(
|
||||
|
||||
// MARK: - QR Verification
|
||||
|
||||
/**
|
||||
* A verification binds a fingerprint, but the seal is drawn beside a
|
||||
* self-claimed nickname — so it is withheld once this key announces a
|
||||
* different name than the one it was verified under. The key is still the
|
||||
* key it was; only the claim about which name it belongs to is withdrawn.
|
||||
*/
|
||||
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) ?: return false
|
||||
if (!verifiedFingerprints.contains(fingerprint)) return false
|
||||
return !verificationHandler.verifiedNicknameMismatch(peerID)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
|
||||
/**
|
||||
* @param renderedName the name this row actually shows. Offline favourite
|
||||
* rows display a name held in the favourites record rather than a live
|
||||
* announce, so they must be checked against their own name — asking about
|
||||
* a "current" name a peer is not announcing would answer nothing.
|
||||
*
|
||||
* Deliberately has no default. A missing name fails OPEN, by design, so a
|
||||
* defaulted parameter would let any future caller reinstate the rename
|
||||
* attack by simply not passing one — silently, and without touching this
|
||||
* file. Make it a decision at the call site.
|
||||
*/
|
||||
fun isNoisePublicKeyVerified(
|
||||
noisePublicKey: ByteArray,
|
||||
verifiedFingerprints: Set<String>,
|
||||
renderedName: String?,
|
||||
): Boolean {
|
||||
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return verifiedFingerprints.contains(fingerprint)
|
||||
if (!verifiedFingerprints.contains(fingerprint)) return false
|
||||
return verificationHandler.sealAppliesToName(fingerprint, renderedName)
|
||||
}
|
||||
|
||||
/** Whether a seal drawn beside [renderedName] still applies to [fingerprint]. */
|
||||
fun sealAppliesToName(fingerprint: String, renderedName: String?): Boolean =
|
||||
verificationHandler.sealAppliesToName(fingerprint, renderedName)
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
verificationHandler.unverifyFingerprint(peerID)
|
||||
}
|
||||
|
||||
@ -709,7 +709,14 @@ fun PeopleSection(
|
||||
}
|
||||
}
|
||||
|
||||
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
|
||||
// `peerNicknames` is a key because the verification check now reads the
|
||||
// peer's announced name. Without it a rename repaints the NAME from the
|
||||
// state flow while this cached `true` — and the seal — survives until
|
||||
// some unrelated key happens to change. That is the live impersonation
|
||||
// still showing a seal, which is the whole case this is meant to stop.
|
||||
val peerVerifiedStates = remember(
|
||||
verifiedFingerprints, peerFingerprints, connectedPeers, peerNicknames
|
||||
) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
@ -864,7 +871,10 @@ fun PeopleSection(
|
||||
val (bName, _) = splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
|
||||
// `dn` is the name this row draws, so it is the name the seal has
|
||||
// to be checked against — see isNoisePublicKeyVerified.
|
||||
val isVerified = viewModel.isNoisePublicKeyVerified(
|
||||
fav.peerNoisePublicKey, verifiedFingerprints, dn)
|
||||
|
||||
val unreadCount = (
|
||||
privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0
|
||||
@ -999,7 +1009,10 @@ private fun ConversationSwipeItem(
|
||||
val theyFavoritedUs =
|
||||
(fingerprint != null && fingerprint in peerFavoritedUs) ||
|
||||
favoriteRelationship?.theyFavoritedUs == true
|
||||
val isVerified = fingerprint != null && fingerprint in verifiedFingerprints
|
||||
// Bound to the name this row draws. A key verified under one nickname that
|
||||
// now presents another keeps its key, but not the claim about whose it is.
|
||||
val isVerified = fingerprint != null && fingerprint in verifiedFingerprints &&
|
||||
viewModel.sealAppliesToName(fingerprint, conversation.displayName)
|
||||
val dismissState = rememberSwipeToDismissBoxState()
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (isFirst) 14.dp else 0.dp,
|
||||
@ -1784,7 +1797,8 @@ fun PrivateChatSheet(
|
||||
}
|
||||
}
|
||||
|
||||
val isVerified = remember(peerID, verifiedFingerprints) {
|
||||
// Keyed on the announced nickname as well — see PeopleSection above.
|
||||
val isVerified = remember(peerID, verifiedFingerprints, peerNicknames[peerID]) {
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
|
||||
|
||||
@ -101,6 +101,16 @@ fun SecurityVerificationSheet(
|
||||
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
|
||||
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
|
||||
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
// The key really is verified — this sheet is the one place that
|
||||
// distinction can be explained rather than collapsed into a
|
||||
// glyph, so the word stays. What is withdrawn is the SEAL, and
|
||||
// only when this key now presents a different name than the one
|
||||
// it was verified under. Saying "not verified" here would be
|
||||
// false, and suppressing nothing would let a reader tap through
|
||||
// from a row whose seal just vanished and be reassured by a
|
||||
// green checkmark.
|
||||
val nameBound = fingerprint == null ||
|
||||
viewModel.sealAppliesToName(fingerprint, displayName)
|
||||
val activeMeshPeerID = ContactDirectory.resolve(selectedPeerID).meshPeerID
|
||||
val sessionState = resolveConversationSessionState(
|
||||
conversationID = selectedPeerID,
|
||||
@ -109,6 +119,7 @@ fun SecurityVerificationSheet(
|
||||
)
|
||||
val statusInfo = buildStatusInfo(
|
||||
isVerified = isVerified,
|
||||
nameBound = nameBound,
|
||||
sessionState = sessionState,
|
||||
accent = accent
|
||||
)
|
||||
@ -135,7 +146,7 @@ fun SecurityVerificationSheet(
|
||||
)
|
||||
|
||||
SecurityVerificationActions(
|
||||
isVerified = isVerified,
|
||||
isVerified = nameBound && isVerified,
|
||||
fingerprint = fingerprint,
|
||||
displayName = displayName,
|
||||
accent = accent,
|
||||
@ -175,25 +186,42 @@ private fun SecurityVerificationHeader(
|
||||
@Composable
|
||||
private fun buildStatusInfo(
|
||||
isVerified: Boolean,
|
||||
nameBound: Boolean,
|
||||
sessionState: String?,
|
||||
accent: Color
|
||||
): SecurityStatusInfo {
|
||||
// Everything here demotes together: glyph, tint and text.
|
||||
//
|
||||
// The first revision kept the word "verified" on the grounds that the key
|
||||
// genuinely is verified, and that was wrong for a reason review put better
|
||||
// than I had: this status sits directly beside `displayName`, so it is not
|
||||
// a claim about a bare key, it is a claim about THIS NAME — and that claim
|
||||
// is false. Worse, it is the screen someone opens *because* a seal vanished
|
||||
// from a row, so the one surface they consult to resolve the doubt was the
|
||||
// one endorsing the rename.
|
||||
//
|
||||
// It needs no new string. An established session falls through to
|
||||
// "encrypted" with a padlock, which is true and asserts nothing about
|
||||
// identity, and the actions block below already names the current nickname
|
||||
// in its not-verified copy and offers to verify it — which is exactly the
|
||||
// recovery: re-verifying re-pins the baseline to the name on screen.
|
||||
val sealed = isVerified && nameBound
|
||||
val text = when {
|
||||
isVerified -> stringResource(R.string.fingerprint_status_verified)
|
||||
sealed -> stringResource(R.string.fingerprint_status_verified)
|
||||
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
|
||||
sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking)
|
||||
sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed)
|
||||
else -> stringResource(R.string.fingerprint_status_uninitialized)
|
||||
}
|
||||
val icon = when {
|
||||
isVerified -> Icons.Filled.Verified
|
||||
sealed -> Icons.Filled.Verified
|
||||
sessionState == "handshaking" -> Icons.Outlined.Sync
|
||||
sessionState == "failed" -> Icons.Outlined.OutlinedWarning
|
||||
sessionState == "established" -> Icons.Filled.Lock
|
||||
else -> Icons.Outlined.NoEncryption
|
||||
}
|
||||
val tint = when {
|
||||
isVerified -> Color(0xFF32D74B)
|
||||
sealed -> Color(0xFF32D74B)
|
||||
sessionState == "failed" -> Color(0xFFFF3B30)
|
||||
sessionState == "handshaking" -> Color(0xFFFF9500)
|
||||
sessionState == "established" -> Color(0xFF32D74B)
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
||||
import android.content.Context
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.identity.NicknameBinding
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
@ -52,12 +53,17 @@ class VerificationHandler(
|
||||
fun isPeerVerified(peerID: String): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
|
||||
if (fingerprint == null || !_verifiedFingerprints.value.contains(fingerprint)) return false
|
||||
// Gated like the ChatViewModel entry point. These two have no callers
|
||||
// today; leaving them ungated would hand the next caller the answer this
|
||||
// change exists to stop giving.
|
||||
return !verifiedNicknameMismatch(peerID)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, renderedName: String?): Boolean {
|
||||
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return _verifiedFingerprints.value.contains(fingerprint)
|
||||
if (!_verifiedFingerprints.value.contains(fingerprint)) return false
|
||||
return sealAppliesToName(fingerprint, renderedName)
|
||||
}
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
@ -148,7 +154,11 @@ class VerificationHandler(
|
||||
|
||||
pendingQRVerifications.remove(peerID)
|
||||
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
|
||||
identityManager.setVerifiedFingerprint(fp, true)
|
||||
// Bind the seal to the name this key is announcing right now.
|
||||
// `announcedNickname` and not `resolvePeerDisplayName`: the latter
|
||||
// falls back to a truncated peerID, which is not a name anyone
|
||||
// announced and must never become a baseline.
|
||||
identityManager.setVerifiedFingerprint(fp, true, announcedNickname(peerID))
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.add(fp)
|
||||
_verifiedFingerprints.value = current
|
||||
@ -296,7 +306,8 @@ class VerificationHandler(
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
if (fingerprint.isBlank()) return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, true)
|
||||
identityManager.setVerifiedFingerprint(fingerprint, true,
|
||||
announcedNicknameForFingerprint(fingerprint))
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.add(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
@ -322,6 +333,59 @@ class VerificationHandler(
|
||||
messageManager.addPrivateMessageNoUnread(peerID, msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* The nickname this peer is ANNOUNCING, or null when we have not seen one.
|
||||
*
|
||||
* Deliberately not `resolvePeerDisplayName`, which falls back to a
|
||||
* truncated peerID: that is an identifier, not a claimed name, and pinning
|
||||
* it as a verification baseline would mismatch against the peer's first
|
||||
* real announce and drop a seal that was legitimately earned.
|
||||
*/
|
||||
private fun announcedNickname(peerID: String): String? =
|
||||
try { meshService.getPeerInfo(peerID)?.nickname?.takeIf { it.isNotBlank() } }
|
||||
catch (_: Exception) { null }
|
||||
|
||||
/**
|
||||
* The announced nickname of whichever known peer holds [fingerprint].
|
||||
*
|
||||
* `verifyFingerprintValue` is reached from the fingerprint sheet, which
|
||||
* knows only a fingerprint, so the name has to be found by walking the peer
|
||||
* list. Returns null when no peer matches — a fingerprint verified with
|
||||
* nobody around to announce a name pins nothing and fails open, which is
|
||||
* the same rule as everywhere else here.
|
||||
*/
|
||||
private fun announcedNicknameForFingerprint(fingerprint: String): String? {
|
||||
val nicknames = try { meshService.getPeerNicknames() } catch (_: Exception) { return null }
|
||||
for ((peerID, nickname) in nicknames) {
|
||||
if (nickname.isBlank()) continue
|
||||
val fp = try { meshService.getPeerFingerprint(peerID) } catch (_: Exception) { null }
|
||||
if (fp != null && fp.equals(fingerprint, ignoreCase = true)) return nickname
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this peer now announces a different nickname than the one its
|
||||
* verification was earned under. Every seal is suppressed in that case: the
|
||||
* seal attests to a key, but it is read as a name.
|
||||
*/
|
||||
fun verifiedNicknameMismatch(peerID: String): Boolean {
|
||||
val fp = try { meshService.getPeerFingerprint(peerID) } catch (_: Exception) { null }
|
||||
?: return false
|
||||
return identityManager.verifiedNicknameMismatch(fp, announcedNickname(peerID))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a seal drawn beside [renderedName] still applies to [fingerprint].
|
||||
*
|
||||
* Offline favourite rows show a name frozen in the favourites record rather
|
||||
* than a live announce, so the live check above is the wrong question for
|
||||
* them: the row has to be checked against its own name.
|
||||
*/
|
||||
fun sealAppliesToName(fingerprint: String, renderedName: String?): Boolean =
|
||||
NicknameBinding.sealAppliesToRendered(
|
||||
identityManager.getVerifiedNickname(fingerprint), renderedName)
|
||||
|
||||
private fun resolvePeerDisplayName(peerID: String): String {
|
||||
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
|
||||
return nick ?: peerID.take(8)
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
package com.bitchat.android.identity
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rename attack and the ways a fix for it can go wrong.
|
||||
*
|
||||
* Every case here was reachable before the binding existed, or is a way an
|
||||
* over-eager binding would have broken something legitimate. The logic lives in
|
||||
* a pure-Kotlin object precisely so it can be tested here rather than only
|
||||
* through a Composable.
|
||||
*/
|
||||
class NicknameBindingTest {
|
||||
|
||||
// ---- the attack -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `renaming onto a trusted nickname breaks the seal`() {
|
||||
// Eve is verified while announcing "ravi", then announces "medic". Every
|
||||
// device that trusts the real medic would otherwise show a second
|
||||
// trusted-looking medic.
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("ravi", "medic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the seal stands while the name is unchanged`() {
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("ravi", "ravi"))
|
||||
}
|
||||
|
||||
// ---- what counts as the same name -------------------------------------
|
||||
|
||||
@Test
|
||||
fun `recasing your own nickname is not a rename`() {
|
||||
// A rename is meant to break the binding; a recase is not. Without the
|
||||
// case fold, changing "Ravi" to "ravi" silently dropped the seal.
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("Ravi", "ravi"))
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("ravi", "RAVI"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a combining accent is the same name as a precomposed one`() {
|
||||
val precomposed = "José" // José
|
||||
val decomposed = "José" // Jose + combining acute
|
||||
assertEquals(NicknameBinding.bindingKey(precomposed), NicknameBinding.bindingKey(decomposed))
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced(precomposed, decomposed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case folding is normalised again afterwards`() {
|
||||
// Turkish dotted capital I lowercases to i + U+0307, which is a
|
||||
// DECOMPOSED sequence. Normalising only before the fold leaves two
|
||||
// spellings of one name unequal.
|
||||
val dotted = "İstanbul"
|
||||
assertEquals(NicknameBinding.bindingKey(dotted), NicknameBinding.bindingKey(dotted.lowercase()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a look-alike does break the binding`() {
|
||||
// The other half of the case fold. A fullwidth M merely LOOKS like M,
|
||||
// so it is a different name and must break the binding — folding
|
||||
// look-alikes is a different question, and answering it here would let
|
||||
// a verification for one name quietly cover another.
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("Medic", "Medic"))
|
||||
// ...and so does a Cyrillic М.
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("Medic", "Меdic"))
|
||||
}
|
||||
|
||||
// ---- the collision suffix ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a hash suffix on the rendered name is ignored`() {
|
||||
// Two peers claiming one nickname render as "medic#a1b2" and
|
||||
// "medic#c3d4". Comparing the decorated string would drop the seal of
|
||||
// the peer being impersonated, at exactly the moment it matters most.
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("medic", "medic"))
|
||||
assertEquals("medic", NicknameBinding.withoutCollisionSuffix("medic#a1b2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a trailing ASCII hex suffix is stripped`() {
|
||||
// `Char.isDigit()` accepts fullwidth digits, so a nickname literally
|
||||
// ending in "#ABCD" would be truncated and could then match a
|
||||
// baseline it is not — a seal on a name that was never verified.
|
||||
assertEquals("medic#ABCD",
|
||||
NicknameBinding.withoutCollisionSuffix("medic#ABCD"))
|
||||
assertEquals("medic#zzzz", NicknameBinding.withoutCollisionSuffix("medic#zzzz"))
|
||||
assertEquals("medic#abc", NicknameBinding.withoutCollisionSuffix("medic#abc"))
|
||||
// Only ONE suffix comes off — the name keeps whatever else it had.
|
||||
assertEquals("x#abcd", NicknameBinding.withoutCollisionSuffix("x#abcd#abcd"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an at sign in a nickname survives`() {
|
||||
// Nothing in the app forbids "@" in a nickname, and the mention parser's
|
||||
// splitSuffix strips every "@" in the string — right for parsing a
|
||||
// mention, wrong for comparing a name, since "ravi@hq" would then
|
||||
// compare unequal to itself.
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("ravi@hq", "ravi@hq"))
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("ravi@hq", "ravi@hq"))
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("ravi@hq", "ravi"))
|
||||
}
|
||||
|
||||
// ---- failing open ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a missing baseline never suppresses`() {
|
||||
// Peers verified by builds from before this existed have no baseline.
|
||||
// Dropping their seals on upgrade would teach people to ignore the
|
||||
// signal, which costs more than the narrow case it would catch.
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced(null, "medic"))
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("", "medic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty current name never suppresses`() {
|
||||
// A row with no name to show yet is not evidence of a rename.
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("medic", null))
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("medic", ""))
|
||||
// A peer that announces "#a1b2" has renamed, and says so — on either
|
||||
// path, since a mesh row shows what was announced.
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("medic", "#a1b2"))
|
||||
assertFalse(NicknameBinding.sealAppliesToRendered("medic", "#a1b2"))
|
||||
}
|
||||
|
||||
// ---- announced vs rendered: the split that was missing -----------------
|
||||
|
||||
@Test
|
||||
fun `an announced suffix is not a UI decoration`() {
|
||||
// The first version of this patch stripped a trailing #abcd from the
|
||||
// LIVE announced name, which is wrong in both directions at once. A
|
||||
// peer announces whatever string it likes, and "#" plus four hex is a
|
||||
// legal thing to announce — this app's own splitSuffix exists because
|
||||
// announced names carry them.
|
||||
//
|
||||
// Stripping let the attack straight through...
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("medic", "medic#cafe"))
|
||||
// ...and dropped the seal of a key honestly verified under a name that
|
||||
// simply ends that way.
|
||||
assertTrue(NicknameBinding.sealAppliesToAnnounced("medic#cafe", "medic#cafe"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rendered mesh row carries no decoration to remove`() {
|
||||
// Nothing decorates a mesh nickname on this platform: splitSuffix only
|
||||
// separates a suffix the peer announced, and the one synthesised #abcd
|
||||
// is for Nostr people, which isPeerVerified refuses outright. So a row
|
||||
// name IS the announced name, and stripping would be a fail-open.
|
||||
assertFalse(NicknameBinding.sealAppliesToRendered("medic", "medic#cafe"))
|
||||
assertTrue(NicknameBinding.sealAppliesToRendered("medic#cafe", "medic#cafe"))
|
||||
assertFalse(NicknameBinding.sealAppliesToRendered("medic", "zebra#a1b2"))
|
||||
}
|
||||
|
||||
// ---- the key itself ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the binding key is idempotent`() {
|
||||
for (name in listOf("Medic", "ravi@hq", "José", "İstanbul", "Medic", "")) {
|
||||
val once = NicknameBinding.bindingKey(name)
|
||||
assertEquals("bindingKey is not stable for \"$name\"", once, NicknameBinding.bindingKey(once))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated names do not match`() {
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("medic", "zebra"))
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("medic", "medic2"))
|
||||
assertFalse(NicknameBinding.sealAppliesToAnnounced("medic", "medi"))
|
||||
}
|
||||
}
|
||||
@ -75,7 +75,12 @@ fun DmScreen(
|
||||
|
||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||
val identityRevision by WearPeerIdentityState.revision.collectAsState()
|
||||
val identity = remember(peerID, identityRevision) {
|
||||
// Keyed on the announced nickname too: the snapshot now reads it to
|
||||
// decide whether the seal still applies, and `revision` is bumped by
|
||||
// favourites and verification changes, never by a peer renaming. Without
|
||||
// this the name repaints while the cached "verified" — and its seal —
|
||||
// survives, which is the live impersonation the binding exists to stop.
|
||||
val identity = remember(peerID, identityRevision, nickname) {
|
||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||
}
|
||||
var sessionEstablished by remember {
|
||||
|
||||
@ -76,7 +76,8 @@ fun PeerDebugScreen() {
|
||||
items(peers) { peerID ->
|
||||
val nick = nicknames[peerID] ?: peerID.take(8)
|
||||
val encrypted = mesh?.hasEstablishedSession(peerID) == true
|
||||
val identity = androidx.compose.runtime.remember(peerID, identityRevision) {
|
||||
// Keyed on the nickname too — see DmScreen.
|
||||
val identity = androidx.compose.runtime.remember(peerID, identityRevision, nick) {
|
||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||
}
|
||||
Row(
|
||||
|
||||
@ -40,10 +40,13 @@ fun UserDetailScreen(
|
||||
) {
|
||||
val mesh = WearMeshService.peek()
|
||||
val revision by WearPeerIdentityState.revision.collectAsState()
|
||||
val identity = androidx.compose.runtime.remember(peerID, revision) {
|
||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||
// Keyed on the announced nickname too — see DmScreen. This screen draws the
|
||||
// seal as well, so without it a rename repaints the name here and leaves
|
||||
// the checkmark sitting beside the new one.
|
||||
val identity = androidx.compose.runtime.remember(peerID, revision, nickname) {
|
||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||
}
|
||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
|
||||
@ -36,7 +36,9 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
fun VerificationCodeScreen(peerID: String) {
|
||||
val mesh = WearMeshService.peek()
|
||||
val revision by WearPeerIdentityState.revision.collectAsState()
|
||||
val identity = androidx.compose.runtime.remember(peerID, revision) {
|
||||
// Keyed on the announced nickname too — see DmScreen.
|
||||
val announcedNickname = mesh?.getPeerNickname(peerID)
|
||||
val identity = androidx.compose.runtime.remember(peerID, revision, announcedNickname) {
|
||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||
}
|
||||
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
|
||||
|
||||
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.bitchat.android.favorites.FavoriteRelationship
|
||||
import com.bitchat.android.favorites.FavoritesChangeListener
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.identity.NicknameBinding
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.watch.mesh.WearMeshService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -67,10 +68,18 @@ object WearPeerIdentityState : FavoritesChangeListener {
|
||||
val relationship = relationship(peerID, mesh)
|
||||
val fingerprint = mesh?.getPeerFingerprint(peerID)
|
||||
?: relationship?.peerNoisePublicKey?.let(identityManager::generateFingerprint)
|
||||
// The watch draws the same seal as the phone and has to bind it the same
|
||||
// way: a key verified here can otherwise rename onto a nickname the
|
||||
// wearer trusts and keep the glyph. The announced name comes from the
|
||||
// watch's own mesh, so the check is the live one.
|
||||
val announced = mesh?.getPeerNickname(peerID)
|
||||
?: mesh?.getPeerInfo(peerID)?.nickname?.takeIf(String::isNotBlank)
|
||||
val isVerified = fingerprint != null &&
|
||||
identityManager.getVerifiedFingerprints().any {
|
||||
it.equals(fingerprint, ignoreCase = true)
|
||||
}
|
||||
} &&
|
||||
NicknameBinding.sealAppliesToAnnounced(
|
||||
identityManager.getVerifiedNickname(fingerprint), announced)
|
||||
val isFavorite = relationship?.isFavorite == true
|
||||
val theyFavoritedUs = relationship?.theyFavoritedUs == true
|
||||
return WearPeerIdentitySnapshot(
|
||||
@ -110,7 +119,11 @@ object WearPeerIdentityState : FavoritesChangeListener {
|
||||
): Boolean {
|
||||
check(initialized) { "WearPeerIdentityState must be initialized by the application" }
|
||||
val fingerprint = snapshot(peerID, mesh).fingerprint ?: return false
|
||||
identityManager.setVerifiedFingerprint(fingerprint.lowercase(), verified)
|
||||
// Pin the name this key is announcing, exactly as the phone does.
|
||||
// Without it every peer verified on a watch would fail open for ever.
|
||||
val announced = mesh?.getPeerNickname(peerID)
|
||||
?: mesh?.getPeerInfo(peerID)?.nickname?.takeIf(String::isNotBlank)
|
||||
identityManager.setVerifiedFingerprint(fingerprint.lowercase(), verified, announced)
|
||||
publishChange()
|
||||
return true
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user