Device distinct messaging: why I killed multi-device and how fingerprint hashing enforces it.
Tech

Device distinct messaging: why I killed multi-device and how fingerprint hashing enforces it.

Most messaging apps let you log in on your phone, laptop, iDevice, and browser, with all of your messages synced. It's framed as convenience. It's also an attack surface. When I was designing my messenger, I made a deliberately unpopular call: one device per account, enforced at the server. This post is about how I implement that, why the enforcement matters more than the policy, and what the recovery story looks like when a user's device dies. Why one device? The pitch for multi-device is: "I want my chats on every screen I own." The cost: Key distribution problem. Every new device needs the session keys. Either you re-derive them from a central secret, losing per-device forward secrecy, or you distribute keys between devices, which creates an extra sync protocol to audit. Compromise blast radius. A stolen laptop with your Signal desktop logged in is a full compromise of your chat history. In a single-device model, physical access to the one device is the attack, not access to any of N devices. Account-recovery social engineering. "Hi, this is Bob, I got a new iPad, can you add it to my account?" is the oldest trick in the book. If the account can only ever have one device, the answer is always: "No. Do recovery." For an end-to-end encrypted app where I can't see the content, multi-device means I'm maintaining a protocol whose failure modes I can't observe. Single-device means the server's job is simple: track which install is the canonical one, and refuse anyone else. The fingerprint hash Every install of the app generates a random 32-byte install ID on first launch and persists it in secure storage: Keychain on iOS, EncryptedSharedPreferences on Android. The server never sees this raw ID. What it sees is a hash: fun computeFingerprintHash(installId: ByteArray, platform: String): String { val digest = MessageDigest.getInstance("SHA-256") digest.update(installId) digest.update(0x00) digest.update(platform.toByteArray()) return digest.digest().toHexString() } On registration, the client sends this hash. The server writes it into the installs table: CREATE TABLE installs ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), active_fingerprint_hash TEXT NOT NULL, recovery_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (user_id) ); Note the UNIQUE (user_id) constraint. One row per user. No multi-device possible at the schema level. If you wanted to add it later, you'd have to change the table shape, which is the point. The 8-second poll Every authenticated request the client makes includes its fingerprint hash in the auth envelope. The server checks it against the stored active_fingerprint_hash. If they match, the request goes through. If they don't: route("/users/me") { get { val auth = call.principal<QuldraPrincipal>()!! val install = installRepo.findByUserId(auth.userId) ?: throw NotFoundException() if (install.activeFingerprintHash != auth.fingerprintHash) { call.respond(HttpStatusCode.OK, mapOf( "error" to "device_deactivated", "recoveredAt" to install.recoveredAt?.toString() )) return@get } call.respond(HttpStatusCode.OK, install.toDto()) } } The client has a background poll loop that hits /users/me every 8 seconds: private fun startDeviceCheckLoop(scope: CoroutineScope) { scope.launch { while (isActive) { delay(8_000) val result = apiClient.getMe() if (result.error == "device_deactivated") { handleDeactivation(result.recoveredAt) break } } } } Why 8 seconds? Because: Short enough that a deactivated device doesn't stay alive long. If someone steals your phone and does recovery on their new one, your old device knows within 8 seconds and locks itself. Long enough not to hammer the server. At 8-second intervals, a single active user makes 10,800 requests per day. That's acceptable for now, but it is still a cost worth watching. Feels instant to the user triggering the recovery. They tap "I've got a new phone," hit their recovery path, and 8 seconds later the old device visibly dies. That feedback loop matters for the mental model. Recovery = clean slate When a user recovers onto a new device: The new device generates a fresh install ID and computes a new fingerprint hash. It hits the recovery endpoint with the user's recovery code. The server updates active_fingerprint_hash to the new hash, increments recovery_count, and sets recovered_at = NOW(). The old device's next poll returns device_deactivated, and the client hard-resets. The clean-slate part matters: messages sent before recovered_at are not synchronized to the new device. This is a deliberate E2E choice. The new device doesn't have the old room secrets. They're gone. Even if the server served the ciphertext, the new device couldn't decrypt it. Showing "undecryptable message" rows would be a worse UX than just pretending they aren't there. The conversation starts fresh from the recovery timestamp, which fits the security model: whoever stole the old device can still see the old messages on it until it dies on poll, but everything from the recovery moment onward is only visible on the new device. What this costs you as a builder Single-device enforcement isn't free: You have to explain it. Users coming from WhatsApp or Telegram expect multi-device. The onboarding flow has to sell "you get one device and here's why" without sounding defensive. I learned to frame it as a feature, "your account is protected by physical possession," rather than a limitation. Desktop/web is harder. You can't have a persistent web session that survives the phone dying. I ended up building a separate ephemeral web chat that uses temporary keys and doesn't count as "the device." It's a different product surface with its own trust story. Testing is awkward. Every manual QA pass burns through install IDs because each recovery is a real recovery. I wrote a server-side dev endpoint that resets fingerprint state for a test user. Gated behind APP_ENV=dev, obviously. What could be different? I think there's always room to improve, and it's a constant battle to settle on a solution. That's where LLMs help the most, in my opinion. Still, it would be useful to hear what people in the field think could be better here. If you have thoughts, let me know personally or in the comments. This is post 2 of a short series on the tech behind Quldra, a post-quantum single-device messenger built in Kotlin Multiplatform. Previous post was on My road to ML-KEM-768 over X25519 for my messaging app.

Read full story →

Comments

Loading comments…

Related