Skip to content

About

realxreal is MFA for people

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

realxreal-iOS

Two words. 10 seconds. Know it's really them.

iOS Swift License Version


The Problem

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.


Why Existing Solutions Don't Work

"Just call them back on a known number"

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.

"Use a family codeword"

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.

"AI deepfake detectors"

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.

"Hardware security keys (YubiKey)"

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?"


How realxreal Solves It

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 authenticationL 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.


The Cryptographic Foundation

TOTP Backed by HMAC-SHA256

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.swift

static func hotp(seed: Data, counter: UInt64) -> UInt32 {
    var c           = counter.bigEndian
    let counterData = Data(bytes: &c, count: 8)
    let key         = SymmetricKey(data: seed)

    // HMAC-SHA256 produces a 32-byte digest
    let mac   = HMAC<SHA256>.authenticationCode(for: counterData, using: key)
    let bytes = Array(mac)

    // Dynamic truncation — offset from last nibble of SHA-256 digest
    // Note: bytes[31] not bytes[19] — SHA-256 is 32 bytes, not SHA-1's 20
    let offset = Int(bytes[31] & 0x0f)
    let value  = (UInt32(bytes[offset]     & 0x7f) << 24)
               | (UInt32(bytes[offset + 1] & 0xff) << 16)
               | (UInt32(bytes[offset + 2] & 0xff) << 8)
               |  UInt32(bytes[offset + 3] & 0xff)
    return value
}

The Two-Word Split

The 31-bit HOTP value is split into two independent 12-bit indices — one for each party in the relationship:

static func wordPair(seed: Data, period: RXRPeriod, ...) -> (my: String, their: String) {
    let val      = hotp(seed: seed, counter: counter)
    let count    = UInt32(RXRWordlist.words.count)   // 4096

    let myIdx    = Int((val & 0x0FFF) % count)       // bits 0–11
    let theirIdx = Int(((val >> 12) & 0x0FFF) % count) // bits 12–23

    return (RXRWordlist.words[myIdx], RXRWordlist.words[theirIdx])
}

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.

Role-Aware Display

The initiator (whoever generated the invite) and the recipient (whoever scanned/redeemed it) see the same two words but with their labels swapped:

// RXRContactStore.swift

func wordPair(for contact: RXRContact) -> (my: String, their: String)? {
    guard let seed = getSeed(for: contact.id) else { return nil }
    let pair = RXRTOTPEngine.wordPair(seed: seed, period: contact.period)

    return contact.isInitiator
        ? (my: pair.my,    their: pair.their)  // Alice: bits 0-11 = mine
        : (my: pair.their, their: pair.my)     // Bob:   bits 12-23 = mine
}

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.

Per-Contact Rotation Periods

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 RXRPeriod: TimeInterval, CaseIterable, Codable {
    case thirtySeconds = 30
    case oneMinute     = 60
    case oneHour       = 3600
    case oneDay        = 86400
    case oneWeek       = 604800
    case oneMonth      = 2592000
}

static func counter(period: RXRPeriod, timeOffset: TimeInterval = 0) -> UInt64 {
    let adjusted = Date().timeIntervalSince1970 + timeOffset
    return UInt64(adjusted / period.rawValue)
}

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.


The Wordlist

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.


Security Architecture

Seed Storage

Seeds are generated using CryptoKit.SymmetricKey(size: .bits256) — 256 bits of cryptographically secure random entropy from the Secure Enclave. They are stored in the iOS Keychain with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly:

  • Requires device passcode to be set
  • Destroyed if passcode is removed
  • Never backed up to iCloud
  • Never migrated to another device
// RXRKeychainService.swift

static func save(seed: Data, for contactID: String) -> Bool {
    let query: [CFString: Any] = [
        kSecClass:          kSecClassGenericPassword,
        kSecAttrService:    "ai.realxreal.rxr.seeds",
        kSecAttrAccount:    contactID,
        kSecValueData:      seed,
        kSecAttrAccessible: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
    ]
    let status = SecItemAdd(query as CFDictionary, nil)
    return status == errSecSuccess
}

In-Memory Seed Cache

To avoid Keychain IPC (a round-trip to the securityd daemon) on every UI render, seeds are loaded once at launch into an in-memory dictionary. All word generation reads from this cache — no Keychain access during scrolling or display:

// RXRContactStore.swift

private var seedCache: [String: Data] = [:]

func wordPair(for contact: RXRContact) -> (my: String, their: String)? {
    guard let seed = seedCache[contact.id] else { return nil }  // O(1) dictionary lookup
    // ...
}

Zero-Knowledge Invite Relay

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.swift

static func encrypt(senderFirstName: String, ..., seed: Data, period: RXRPeriod) throws -> RXREncryptedInvite {
    let token   = UUID().uuidString.lowercased()  // generated locally, embedded in payload
    let payload = RXRInvitePayload(version: "1", token: token, ...)
    let key     = SymmetricKey(size: .bits256)    // ephemeral, per-invite
    let sealed  = try AES.GCM.seal(try JSONEncoder().encode(payload), using: key)

    // Key encoded as Base64URL — safe in URL fragments across all messaging apps
    let keyString = key.withUnsafeBytes { Data(Array($0)).base64EncodedString() }
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")

    return RXREncryptedInvite(
        token:         token,
        ciphertext:    sealed.combined!.base64EncodedString(),
        encryptionKey: keyString
    )
}

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.


Project Structure

realxreal-iOS/
├── App/
│   ├── realxreal_iOSApp.swift    Entry point, Firebase init, NTP sync on launch
│   └── RXRAppState.swift         Screen router, deep link queue, loading states
├── 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.swift        HOTP/TOTP — HMAC-SHA256, word pair extraction
│   ├── RXRWordlist.swift          4096-word EFF list
│   ├── RXRKeychainService.swift   Seed storage with WhenPasscodeSet access policy
│   ├── RXRUserProfile.swift       Local user identity (encoded in QR/invite links)
│   ├── RXRFirebaseService.swift   Cloud Function calls (createInvite, redeemInvite, serverTime)
│   ├── RXRInviteCrypto.swift      AES-256-GCM seal/open, Base64URL, token binding
│   ├── RXRInviteService.swift     Orchestrates encrypt → upload → URL build
│   ├── RXRDeepLinkHandler.swift   Universal Link + custom scheme parsing, clipboard recovery
│   └── RXRTimeService.swift       SNTP clock drift correction
├── Stores/
│   └── RXRContactStore.swift      Single source of truth, seed cache, role-aware word pairs
└── Theme/
    ├── DesignTokens.swift          Colors, spacing, typography (colorful + mono themes)
    ├── RXRComponents.swift         Reusable SwiftUI components
    └── RXRCircularTimer.swift      TimelineView-based countdown ring

Getting Started

Prerequisites

  • Xcode 15+
  • iOS 16+ device or simulator

Setup

git clone https://github.com/Trust-Worthy/realxreal-iOS.git
cd realxreal-iOS
open realxreal-iOS.xcodeproj

Build

⌘+B   # Build
⌘+R   # Run on simulator or device

Universal Links and camera QR scanning require a real device.


Backend

realxreal-iOS 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.

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

Related


Contributing

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.


License

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

About

realxreal is MFA for people

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages