mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Bind a verified seal to the nickname it was earned under
A verified key can rename itself onto a nickname the user trusts and keep drawing the seal beside the new one. Verification binds a FINGERPRINT, which is right. But the seal is rendered beside a self-claimed nickname, and nothing binds those two together: 1. Eve announces "ravi" and gets verified in person by someone. 2. Eve announces "medic". The rename is free and silent — PeerManager computes `nicknameChanged` only to decide whether to refresh the list. 3. Every device that verified Eve now shows a second trusted-looking medic. This is the Android mirror of permissionlesstech/bitchat#1708. It is smaller, because this app has no vouching: there is no transitive trust to launder onward, only the seal itself to withdraw. The binding `SecureIdentityStateManager` records the nickname a key was announcing when it was verified, in its own store. Deliberately NOT the existing `cached_fingerprint_nicknames`, which is a LAST SEEN cache overwritten on every peer-list refresh — comparing against that would always match and catch nothing. Pinned on verification and on re-verification (the user just checked this key again, under whatever name it shows now), cleared on unverify. Never pinned from `resolvePeerDisplayName`, which falls back to a truncated peerID: that is an identifier, not a claimed name, and pinning it would drop a seal on the peer's first real announce. A missing baseline never suppresses. Peers verified by earlier builds have none, and dropping their seals on upgrade would teach people to ignore the signal. Where the seal comes from Four surfaces, all gated: the connected peer row and the peer sheet (via `ChatViewModel.isPeerVerified`), the conversation row, and offline favourite rows. The last two are checked against the name THEY render rather than a live announce — an offline favourite shows a name held in the favourites record, so asking about a "current" name it is not announcing would answer nothing. `VerificationHandler.isPeerVerified` / `isNoisePublicKeyVerified` have no callers today and are gated anyway: leaving them ungated would hand the next caller the answer this change exists to stop giving. One surface is deliberately half covered In the fingerprint sheet the seal GLYPH and its green tint are withheld, and the word "verified" is not. The key genuinely is verified, so saying otherwise would be false — and leaving the sheet untouched would let someone tap through from a row whose seal just vanished and be reassured by a green checkmark. The right fix is a sentence that says both, and a new string has to ship in all 34 locales; machine-translating a security warning is not something to do in passing. Happy to wire it if someone supplies the wording. Comparison rules NFC, then a locale-independent case fold, then NFC again — folding can itself emit decomposed sequences, and `Locale.ROOT` is not optional or a Turkish phone would disagree with every other device about whether a peer had renamed. Recasing your own nickname is not a rename; a fullwidth or Cyrillic look-alike IS one. A trailing `#abcd` is stripped before comparing, ASCII hex only, since `Char.isDigit()` accepts fullwidth digits and would truncate a nickname literally ending in "#ABCD" into something that could match a baseline it is not. Tests `NicknameBindingTest`, 13 cases: the rename attack, the rename-onto-a- look-alike cases, recasing, combining accents, Turkish dotted I, the hash suffix and the fullwidth-hex trap, "@" in a nickname, and both fail-open paths. The logic lives in a pure-Kotlin `NicknameBinding` object with no Android imports precisely so the security decision is testable without a view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c127eb83ab
commit
351f8861c7
@ -0,0 +1,81 @@
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a seal earned under [pinned] still apply to a peer announcing
|
||||
* [current]?
|
||||
*
|
||||
* Fails **open** when nothing was pinned. 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 sealApplies(pinned: String?, current: String?): Boolean {
|
||||
if (pinned.isNullOrEmpty()) return true
|
||||
val shown = withoutCollisionSuffix(current.orEmpty())
|
||||
if (shown.isEmpty()) return true
|
||||
return bindingKey(pinned) == bindingKey(shown)
|
||||
}
|
||||
}
|
||||
@ -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, currentNickname: String?): Boolean =
|
||||
!NicknameBinding.sealApplies(getVerifiedNickname(fingerprint), currentNickname)
|
||||
|
||||
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,39 @@ 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.
|
||||
*/
|
||||
fun isNoisePublicKeyVerified(
|
||||
noisePublicKey: ByteArray,
|
||||
verifiedFingerprints: Set<String>,
|
||||
renderedName: String? = null,
|
||||
): 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)
|
||||
}
|
||||
|
||||
@ -864,7 +864,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 +1002,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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -175,9 +186,18 @@ private fun SecurityVerificationHeader(
|
||||
@Composable
|
||||
private fun buildStatusInfo(
|
||||
isVerified: Boolean,
|
||||
nameBound: Boolean,
|
||||
sessionState: String?,
|
||||
accent: Color
|
||||
): SecurityStatusInfo {
|
||||
// The glyph is the seal; the text is the explanation. They part company for
|
||||
// exactly one state: verified key, different name. The icon and tint fall
|
||||
// back to the session's own state (a padlock on an encrypted session), so
|
||||
// the sheet stops asserting the name while the word "verified" still tells
|
||||
// the truth about the key. Adding a sentence that says both would need a
|
||||
// new string in all 34 locales, and machine-translating a security warning
|
||||
// is not something to do in passing.
|
||||
val sealed = isVerified && nameBound
|
||||
val text = when {
|
||||
isVerified -> stringResource(R.string.fingerprint_status_verified)
|
||||
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
|
||||
@ -186,14 +206,14 @@ private fun buildStatusInfo(
|
||||
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? = null): 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,58 @@ 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.sealApplies(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,142 @@
|
||||
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.sealApplies("ravi", "medic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the seal stands while the name is unchanged`() {
|
||||
assertTrue(NicknameBinding.sealApplies("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.sealApplies("Ravi", "ravi"))
|
||||
assertTrue(NicknameBinding.sealApplies("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.sealApplies(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.sealApplies("Medic", "Medic"))
|
||||
// ...and so does a Cyrillic М.
|
||||
assertFalse(NicknameBinding.sealApplies("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.sealApplies("medic", "medic#a1b2"))
|
||||
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.sealApplies("ravi@hq", "ravi@hq"))
|
||||
assertTrue(NicknameBinding.sealApplies("ravi@hq", "ravi@hq#a1b2"))
|
||||
assertFalse(NicknameBinding.sealApplies("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.sealApplies(null, "medic"))
|
||||
assertTrue(NicknameBinding.sealApplies("", "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.sealApplies("medic", null))
|
||||
assertTrue(NicknameBinding.sealApplies("medic", ""))
|
||||
// ...including one that is nothing BUT a suffix.
|
||||
assertTrue(NicknameBinding.sealApplies("medic", "#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.sealApplies("medic", "zebra"))
|
||||
assertFalse(NicknameBinding.sealApplies("medic", "medic2"))
|
||||
assertFalse(NicknameBinding.sealApplies("medic", "medi"))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user