Two words. 10 seconds. Know it's really them.
Deepfake technology has made it trivially easy to clone anyone's voice or face in real time. What used to require a Hollywood studio now takes a laptop and an open-source model.
The consequences are immediate and financial:
- $25 million wired by a Hong Kong finance worker who was convinced by a deepfake CFO on a Zoom call (2024)
- $35 million lost by a UAE bank after a deepfake voice call authorized a transfer
- The FBI publicly warned in 2024 that AI voice cloning is being used to impersonate family members in emergency scam calls
The attacks work because we evolved to trust voices and faces. We never had to verify them before — they were unforgeable. They aren't anymore.
A sophisticated attacker spoofs caller ID or intercepts the callback. This fails against nation-state actors, organized fraud rings, and anyone with $50 worth of VoIP tooling.
Static codewords are a good instinct but fail in practice. A real-time deepfake that intercepts the call can hear Alice say "Jupiter" and relay it to Bob before he notices the delay. A static word can also be extracted through social engineering — one successful impersonation and the attacker knows your word forever.
This is an arms race you will lose. Detectors are always trained on yesterday's deepfakes. The generation models improve faster than the detection models. Building your security on detection is building on sand.
YubiKey solves the right problem — cryptographic authentication — but for the wrong relationship. It authenticates you to a server. It has no concept of authenticating you to a person. There is no product in this category that answers the question: "Is the human on this call who they say they are?"
realxreal gives every contact relationship a pair of rotating codewords backed by a shared cryptographic secret. The words change every minute (configurable). Both parties generate them independently from the same seed, and no server is involved at verification time.
Alice's screen: Bob's screen:
┌─────────────────┐ ┌─────────────────┐
│ YOUR WORD │ │ YOUR WORD │
│ falcon │ │ river │
│ │ │ │
│ BOB'S WORD │ │ ALICE'S WORD │
│ river │ │ falcon │
│ │ │ │
│ ↻ 42s remaining │ │ ↻ 42s remaining │
└─────────────────┘ └─────────────────┘
Alice says "falcon." Bob confirms he sees "falcon" as Alice's word. Bob says "river." Alice confirms she sees "river" as Bob's word. Both verified. Both in the same time window. A relay attacker who intercepts "falcon" still has to produce "river", which they cannot do without the shared seed.
This is mutual authentication — both parties authenticate to each other simultaneously. No relay attack works. No static word can be stolen. No AI model can predict the next word without the seed.
realxreal implements HOTP (RFC 4226) with HMAC-SHA256 rather than the legacy SHA-1 used by standard authenticator apps. Since realxreal is not bound by TOTP interoperability requirements — both endpoints are realxreal devices — we use the stronger primitive.
// RXRTOTPEngine.kt
fun hotp(seed: ByteArray, counter: Long): Long {
val counterBytes = ByteBuffer.allocate(8).putLong(counter).array()
val mac = macThreadLocal.get()!!
mac.init(SecretKeySpec(seed, "HmacSHA256"))
val digest = mac.doFinal(counterBytes) // 32 bytes for SHA-256
val offset = digest[31].toInt() and 0x0f // last nibble of 32-byte digest
val value = ((digest[offset].toLong() and 0x7f) shl 24) or
((digest[offset + 1].toLong() and 0xff) shl 16) or
((digest[offset + 2].toLong() and 0xff) shl 8) or
(digest[offset + 3].toLong() and 0xff)
return value
}The 31-bit HOTP value is split into two independent 12-bit indices — one for each party in the relationship:
fun wordPair(
seed: ByteArray,
period: RXRPeriod,
timeOffset: Double = 0.0,
counter: Long? = null
): Pair<String, String> {
val c = counter ?: counter(period, timeOffset)
val value = hotp(seed, c)
val count = RXRWordlist.words.size.toLong() // 4096
val myIdx = (value and 0x0FFF) % count // bits 0-11
val theirIdx = ((value shr 12) and 0x0FFF) % count // bits 12-23
return RXRWordlist.words[myIdx.toInt()] to RXRWordlist.words[theirIdx.toInt()]
}The 12-bit mask guarantees values 0–4095, making % 4096 a no-op safety net with zero modulo bias. 24 bits are consumed from the 31-bit HOTP value, leaving 7 bits unused — clean extraction with no overlap.
The initiator (whoever generated the invite) and the recipient (whoever scanned/redeemed it) see the same two words but with their labels swapped:
// RXRContactStore.kt
fun wordPair(contact: RXRContact): Pair<String, String>? {
val seed = getSeed(contact.id) ?: return null
val pair = RXRTOTPEngine.wordPair(seed, contact.period)
return if (contact.isInitiator) pair
else Pair(pair.second, pair.first)
}This means Alice says "falcon" and Bob says "river" — each sees their own word labeled "YOUR WORD." A relay attacker who intercepts Alice's word still cannot produce Bob's without the shared seed.
Each contact relationship has its own configurable rotation period. Close family might use 30 seconds for maximum security. An occasional business contact might use 1 week:
enum class RXRPeriod(val seconds: Long) {
THIRTY_SECONDS(30),
ONE_MINUTE(60),
ONE_HOUR(3600),
ONE_DAY(86400),
ONE_WEEK(604800),
ONE_MONTH(2592000),
}
fun counter(period: RXRPeriod, timeOffset: Double = 0.0): Long {
val adjusted = System.currentTimeMillis() / 1000.0 + timeOffset
return (adjusted / period.seconds).toLong()
}The timeOffset is calculated on launch by comparing local device time to Firebase server time — correcting for clock drift without capping the correction, since the server clock (backed by Google TrueTime) is authoritative regardless of how wrong the device clock is.
realxreal uses the first 4096 words from the EFF Large Wordlist. This is a public domain list designed specifically for human-readable random word generation.
The EFF list was chosen because:
- Every word is short, unambiguous, and speakable over a phone call
- No homophones (no "bare" / "bear" confusion)
- No offensive or sensitive terms
- No easily confused pairs
- Designed by security researchers for exactly this use case
4096 words was chosen because it is a power of 2 (2¹²), which combined with the 12-bit mask extraction produces zero modulo bias — every word appears with exactly equal probability.
words: 4096 → pairs: 4096 × 4096 = 16,777,216 possible combinations
At 100,000 active simultaneous users, the probability of any two unconnected users sharing the same pair in the same time window is negligible. A future upgrade to three words (bits 0-11, 12-23, 24-35 of a second HOTP call) extends this to 68 billion combinations.
Seeds are generated using SecureRandom — 256 bits of cryptographically secure random entropy. They are stored using Tink AEAD (AES-256-GCM), wrapped by an Android Keystore master key that never leaves hardware:
- Encrypted-at-rest via a non-exportable Keystore master key
- Available to the app process once the device has been unlocked at least once since boot (Direct Boot model — the same standard used by Signal on Android)
- Never backed up outside the device
- Never migrated to another device
// RXRKeychainService.kt
suspend fun save(context: Context, seed: ByteArray, contactID: String): Boolean =
withContext(Dispatchers.IO) {
try {
val ciphertext = getAead(context).encrypt(seed, contactID.toByteArray(Charsets.UTF_8))
val encoded = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
prefs(context).edit()
.putString(contactID, encoded)
.commit()
} catch (e: Exception) {
Log.e(TAG, "Failed to save seed for contact $contactID", e)
false
}
}Platform note: unlike iOS's
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, which evicts key material the instant the device locks, Tink's default Android Keystore master key has no per-use authentication gate — it remains available to this process as long as the device has been unlocked once since boot, regardless of current lock state. This hardware-backed-at-rest model (no per-use auth prompt) is the accepted standard for apps in this category on Android.
To avoid repeated Keystore decryption on every UI render, seeds are loaded once at launch into an in-memory map. All word generation reads from this cache — no Keystore access during scrolling or display:
// RXRContactStore.kt
private val seedCache = mutableMapOf<String, ByteArray>()
fun getSeed(contactID: String): ByteArray? = seedCache[contactID]When Alice sends Bob an invite link, the seed travels encrypted. The decryption key travels exclusively in the URL fragment — which is stripped by every browser and server before the request is transmitted. Firebase receives only ciphertext it cannot decrypt:
https://join.realxreal.app/{token}?from=Alice#{AES-256-GCM-key} ↑ ↑ reaches Firebase never reaches Firebase (token lookup) (stays in Bob's browser/app)
// RXRInviteCrypto.kt
fun encrypt(
senderFirstName: String,
senderLastName: String,
seed: ByteArray,
period: RXRPeriod,
): EncryptedInvite {
val keyBytes = ByteArray(KEY_LENGTH).also { SecureRandom().nextBytes(it) }
val nonce = ByteArray(NONCE_LENGTH).also { SecureRandom().nextBytes(it) }
val token = UUID.randomUUID().toString().lowercase()
val payload = InvitePayload(
version = "1", role = "initiator",
seed = Base64.encodeToString(seed, Base64.NO_WRAP),
period = period.seconds, token = token,
senderFirstName = senderFirstName.trim(),
senderLastName = senderLastName.trim(),
)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyBytes, "AES"), GCMParameterSpec(TAG_LENGTH_BITS, nonce))
}
val encrypted = cipher.doFinal(Json.encodeToString(InvitePayload.serializer(), payload).toByteArray(Charsets.UTF_8))
return EncryptedInvite(
token = token,
ciphertext = Base64.encodeToString(nonce + encrypted, Base64.NO_WRAP),
encryptionKey = Base64.encodeToString(keyBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING),
)
}The token is embedded inside the encrypted payload and verified after decryption — closing the substitution attack vector where a MITM swaps the ciphertext under a known token.
realxreal-android/
├── App/
│ ├── RXRApplication.kt Application entry point, Firebase/Tink init, NTP sync on launch
│ ├── RXRAppViewModel.kt Screen router, deep link queue, loading states
│ └── RXRScreen.kt Sealed class screen routing
├── Scenes/
│ ├── Onboarding/ Welcome, import contacts, select self, name yourself
│ ├── Home/ Contact list, circular timer, edit profile
│ └── Contact/ Contact sheet, manual add wizard, QR scanner/result
├── Services/
│ ├── RXRTOTPEngine.kt HOTP/TOTP — HMAC-SHA256, word pair extraction
│ ├── RXRWordlist.kt 4096-word EFF list
│ ├── RXRKeychainService.kt Seed storage via Android Keystore + Tink AEAD
│ ├── RXRUserProfile.kt Local user identity (encoded in QR/invite links)
│ ├── RXRFirebaseService.kt Cloud Function calls (createInvite, redeemInvite, serverTime)
│ ├── RXRInviteCrypto.kt AES-256-GCM seal/open, Base64URL, token binding
│ ├── RXRInviteService.kt Orchestrates encrypt → upload → URL build
│ ├── RXRDeepLinkHandler.kt App Links + custom scheme parsing, clipboard recovery
│ └── RXRTimeService.kt SNTP clock drift correction
├── Stores/
│ └── RXRContactStore.kt Single source of truth, seed cache, role-aware word pairs
└── Theme/
├── RXRColor.kt Colors (colorful + mono themes)
├── RXRSpacing.kt Dp spacing constants
├── RXRTypography.kt TextStyle definitions
├── RXRComponents.kt Reusable Compose components
└── RXRCircularTimer.kt Canvas-based countdown ring
- Android Studio Meerkat or newer
- Android device or emulator running API 26+ (min SDK)
- compile/target SDK 37
git clone https://github.com/Trust-Worthy/realxreal-android.git
cd realxreal-android# From Android Studio: Run ▶ (Shift+F10)
# Or from the command line:
./gradlew assembleDebug
./gradlew installDebugApp Links and camera QR scanning require a real device.
realxreal-android connects to realxreal-relay — a zero-knowledge Firebase relay that stores encrypted invite payloads temporarily during the seed exchange. The server never has access to decryption keys or plaintext seeds. This backend is shared with realxreal-iOS — both platforms are fully interoperable, since the underlying cryptographic protocol and wire format are identical.
Three Cloud Functions:
| Function | Purpose |
|---|---|
createInvite |
Stores encrypted payload, returns token |
redeemInvite |
Returns payload (single-use, 24hr TTL) |
serverTime |
Returns server timestamp for NTP drift correction |
- realxreal-iOS — iOS client (SwiftUI, CryptoKit)
- realxreal-relay — Firebase backend
- realxreal.app — App landing page
- realxreal.ai — Brand and protocol
This is an open cryptographic protocol. Contributions welcome — especially:
- Wordlist improvements and localization
- Security audits of the TOTP implementation
- UI accessibility improvements
Please open an issue before submitting a large PR.
GNU Affero General Public License v3.0 — see LICENSE
AGPL-3.0 means:
- You can use, study, modify, and distribute this code freely
- If you run a modified version as a network service, you must release your modifications under the same license
- Proprietary forks that offer realxreal as a closed SaaS are not permitted
The EFF Large Wordlist is used under Creative Commons Attribution 3.0. © 2016 Electronic Frontier Foundation — eff.org





