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:
Shubham Bhandari 2026-09-14 16:39:02 +08:00
parent c127eb83ab
commit 351f8861c7
7 changed files with 422 additions and 14 deletions

View File

@ -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)
}
}

View File

@ -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? {

View File

@ -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)
}

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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"))
}
}