# Show HN: I mapped every privacy claim in my messenger to its source code

> Source: <https://veilusdigital.co/audit/>
> Published: 2026-06-20 15:19:37+00:00

# Phantom Chat — Marketing Claim Audit

This document maps every technical claim made on **veilusdigital.co** and inside the iOS app to the specific Swift source file that implements it. Each row reflects what the code actually does — not what marketing copy aspires to. Where a claim is partial or unimplemented, that is noted explicitly. The goal is to make this product easy to fact-check before you write or record about it.

**On line numbers:** the line references below were accurate when re-verified on the date above. Line numbers drift as code evolves — the **file name + symbol/function name** is the durable reference. If a line has moved, search for the named symbol in that file. Happy to share the exact commit on request.

If anything here is unclear, contact `support@veilusdigital.co`

and I'll walk through the relevant code or schedule a screen-share.

## 1. Cryptography

### 1.1 Post-Quantum Key Exchange (PQXDH / Hybrid Curve25519 + ML-KEM-768)

**Claim** |
Every new conversation uses NIST FIPS 203 ML-KEM-768 (Kyber) **hybridised** with classical Curve25519 — if either survives the next 20 years, messages stay safe. |
**Status** |
✅ Verified, executes on every new conversation. Not dead code. |
**Code path** |
`Phantom Chat/X3DHProtocol.swift:64-80` (Kyber-768 keypair generation, integrated into bundle)
`Phantom Chat/X3DHProtocol.swift:161-191` (PQXDH hybrid is engaged for both initiator and responder)
`Phantom Chat/X3DHProtocol.swift:182` — `PQXDHHybrid.combine()` merges classical X3DH with Kyber shared secret via HKDF
`Phantom Chat/KeyStore.swift:329-352` — `ensureKyberKey()` generates and persists Kyber-768 keypairs (2400 bytes) |
**Note** |
Implementation is custom Swift, not libsignal. The algorithms (X3DH, Double Ratchet, ML-KEM-768) are standard and publicly documented; the Swift code re-implements them rather than linking the Rust libsignal library. |

### 1.2 Double Ratchet (Signal Protocol)

**Claim** |
Every message uses a fresh derived key, rotated automatically per message with a Diffie-Hellman ratchet step on conversation-direction change. |
**Status** |
✅ Verified — full implementation of all 5 algorithm components. |
**Code path** |
`Phantom Chat/DoubleRatchet.swift:20-33` (root key + send/receive chain keys + DH ratchet keys)
`Phantom Chat/DoubleRatchet.swift:45-46` (skipped-message-keys dictionary for out-of-order delivery)
`Phantom Chat/DoubleRatchet.swift:66-88` (`encryptMessage()` derives the next message key, advances send chain)
`Phantom Chat/DoubleRatchet.swift:112-114` (`performDHRatchet()` step)
`Phantom Chat/DoubleRatchet.swift:117-128` (skipped-keys path) |

### 1.3 AES-256-GCM

**Claim** |
Messages and media are encrypted with AES-256-GCM. |
**Status** |
✅ Verified — used everywhere. |
**Code path** |
`Phantom Chat/DoubleRatchet.swift:71` — `AES.GCM.seal()` for message encryption
`Phantom Chat/SecureMediaManager.swift:41` — `AES.GCM.seal(data, using: key)` for media
`Phantom Chat/ChatAPI.swift:978, 1338` — `AES.GCM.SealedBox` for decrypt
`Phantom Chat/VerificationStore.swift:388` — AES-GCM for stored fingerprint encryption |

### 1.4 Hardware-Bound Identity (Secure Enclave)

**Claim** |
Long-term identity key generated inside the iPhone's Secure Enclave; private key never leaves hardware. |
**Status** |
✅ Verified — SE preferred on first launch. |
**Code path** |
`Phantom Chat/KeyStore.swift:40, 60, 78, 109` — `SecureEnclave.P256.KeyAgreement.PrivateKey` creation and persistence
`Phantom Chat/KeyStore.swift:192` — biometric access control on SE key
`Phantom Chat/KeyStore.swift:554-580` — `createAndPersistSecureEnclaveKey()` and `loadSecureEnclaveKey()` via SE blob |
**Caveat** |
Falls back to software key only if the device does not have a Secure Enclave (older simulators, jailbroken devices missing SEP). Software fallback preserved for backward compatibility. |

### 1.5 App Attest (Device Attestation)

**Claim** |
Server verifies that the connecting device is a real iPhone running unmodified Phantom Chat code. |
**Status** |
✅ Verified — wired end-to-end (client + server). |
**Code path** |
`Phantom Chat/AuthService.swift:329, 410-475` — `AppAttestService` instantiates `DCAppAttestService.shared`
`Phantom Chat/AuthService.swift:469` — `service.generateKey()` called and cached
`Phantom Chat/AuthService.swift:452, 475-485` — `attestKey(keyID, clientDataHash:)` + POST to `verifyAppAttestKey` Cloud Function
`Phantom Chat/functions_index_consolidated.ts:1390-1410` — server-side attestation verification |

## 2. Privacy & Anonymity

### 2.1 Anonymous Sign-up

**Claim** |
No phone number, email, real name, or personal information collected at sign-up. Username only. |
**Status** |
✅ Verified. |
**Code path** |
`Phantom Chat/AuthView.swift:6` — `@State private var username: String = ""` (the only signup field)
`Phantom Chat/AuthView.swift:239-241` — signup collects only username, trims whitespace, calls `viewModel.signIn(username: name)` |

### 2.2 What the Server Stores

**Claim** |
The server (Firebase/Firestore) holds only: **username**, **public key bundle** (`keyBundle` ), **push/FCM token**, **subscription status**, and **encrypted message ciphertext** while it is in transit. It cannot read message content, contacts, or who you talk to. |
**Status** |
✅ Verified, with the two clarifications below. |
**Code path** |
`Phantom Chat/ChatAPI.swift:2820, 2835` — user doc: `username` + `keyBundle` written
`Phantom Chat/EncryptedProfileStore.swift:82-92` — profile write
`Phantom Chat/SignalSessionInitiationView.swift:281-282` — public `keyBundle` upload
`Phantom Chat/IAPService.swift:263` — subscription status
`Phantom Chat/firestore.rules` — `fcmTokens` / `fcm` / `tokens` subcollections (push token) |
**Clarification 1 — the username is plaintext** |
The `username` is stored as a plaintext field, because it is the routing address other people redeem an invite against. It is **not** encrypted. No phone number, email, or real name is stored anywhere (`ChatAPI.swift:2820` ). |
**Clarification 2 — diagnostic probes are transient** |
During connection setup the app writes a tiny probe doc to `users/{uid}/diagProbes/` to measure write latency, then **immediately deletes it in the same call** (`ChatAPI.swift:316-327` ). It contains only a server timestamp + the string `"diag"` — never user content. |

### 2.2b Message Retention — How Long the Server Keeps Ciphertext

**Claim** |
Encrypted message ciphertext is removed from the server when its disappearing-message timer expires, or when the account / conversation is deleted. |
**Status** |
✅ Verified — and stated honestly: messages are **not** wiped the instant they're delivered. |
**What actually happens** |
Ciphertext stays on the server until **(a)** the per-message `expireAt` timer fires, or **(b)** the user deletes their account or conversation. This is deliberate: Firestore re-delivers the message backlog to a device that was offline, which is exactly why the client keeps a persisted `seenRemoteIDs` dedup set (`ChatAPI.swift:508, 608` ). Throughout, the payload stays end-to-end encrypted and unreadable by the server. |
**Code path** |
`expireAt` set on send: `Phantom Chat/ChatAPI.swift:1696, 1895, 2168-2170, 2231-2233` expiry honoured on read: `Phantom Chat/ChatAPI.swift:1359-1365` account deletion: `Phantom Chat/ChatAPI.swift:4395` (`deleteAccount()` ) |
**What we do NOT claim** |
We deliberately avoid saying *"deleted from our servers immediately after delivery"* or *"messages live only on your device."* The honest framing is: the server holds only ciphertext it cannot read, removed on timer expiry or account/conversation deletion. |

### 2.3 Push Notifications Carry No Content

**Claim** |
Push notifications never include message content — only an opaque wake signal and metadata so the app can fetch and decrypt locally. |
**Status** |
✅ Verified. |
**Code path** |
`Phantom Chat/MessagePreviewExtention/NotificationService.swift:30-43` — security comment explicitly forbids sending message content; payload shows only "New Message" + sender pseudonym
`Phantom Chat/functions_notifyGroupInvitation.js:107` — push body is `"${inviterUsername} invited you to join ${groupName}"` (metadata only) |

### 2.4 No In-App User Search / No Public Directory

**Claim** |
There is no feature in the app to search for or browse other users by name. Contact discovery is invite-code / QR only. |
**Status** |
✅ Verified for the app. ⚠️ See the honest backend note below. |
**Code path** |
Client-side username-search code removed entirely (commits 2026-05-27).
`RedeemInviteView` / `GenerateInviteView` are the only discovery paths.
`Phantom Chat/firestore.rules:78-79` — legacy `usernameIndex/{hash}` read locked to `allow read: if false` , so the leftover index documents on existing accounts cannot be enumerated. |
**Honest backend note** |
The `users/{uid}` profile documents are readable by any authenticated app user (`firestore.rules:66-68` , `allow read: if isSignedIn()` ). In normal use the app fetches a single profile by a known UID (a `get` ). Because Firestore's `read` permission also grants `list` , a determined authenticated client could in principle enumerate the `users` collection — which would expose **usernames and public key bundles only**. It would never expose a phone number, email, real name (none are stored) or any message content (end-to-end encrypted). There is no in-app feature that performs this enumeration. |

### 2.5 Invitation Code Discovery (1-on-1)

**Claim** |
One-shot invite codes (XXXX-XXXX-XXXXXX) or QR codes are the only contact-discovery method. |
**Status** |
✅ Verified. |
**Code path** |
`Phantom Chat/ChatListView.swift:1341+` — `RedeemInviteView` + `GenerateInviteView`
`Phantom Chat/functions_index_consolidated.ts` — `generateInvitationCode` + `redeemInvitationCode` cloud functions |

### 2.6 Group Invitation Codes (1–500 redemptions per code)

**Claim** |
Each group invite code can be redeemed up to 500 times; expiry from 1 day to never; creator can issue multiple codes. |
**Status** |
✅ Verified. |
**Code path** |
`Phantom Chat/GroupInviteCodeManagerView.swift:458` — `Stepper(value: $maxJoins, in: 1...500)`
`Phantom Chat/GroupInviteCodeManagerView.swift:729-746` — expiry options including "Never" |
**Note** |
There is **no per-group total cap** in the code; the 500 is per-code. Multiple codes pointing at the same group are supported. The website wording reflects this honestly. |

**Claim** |
Message *content* is end-to-end encrypted and unreadable by us. We are honest that some *metadata* exists server-side, like every messenger. |
**Status** |
✅ Content encrypted. ⚠️ Metadata disclosed honestly below — we do **not** claim "zero metadata" or "we can't see who talks to whom." |
**What the server CAN see (plaintext)** |
• 1-on-1 pairing: `pairs/{id}` stores a plaintext `participants:[uidA,uidB]` (`ChatAPI.swift` ~1668-1674). • Group membership + title: `groups/{id}` stores plaintext `name` , `participants` , `createdBy` (`ChatAPI.swift` ~3373-3380). • Per-message envelope: plaintext `senderId` , `createdAt` , `expireAt` (the body/`ciphertext` is encrypted). • Account: username (plaintext), public key bundle, push token, subscription status. |
**What the server CANNOT see** |
Message text, media, and any conversation content — all AES-256-GCM under the Double-Ratchet/PQXDH session keys, which never leave the devices. |
**Honest framing** |
"We can't read your messages — there's no content for us to disclose. The minimal account metadata we hold (username, which accounts share a conversation, timestamps, push token, subscription) we minimise and resist requests for." This is the Signal trust model. Reducing metadata further (sealed sender) is a roadmap item. |

### 2.8 Emergency Destruct — exactly what is deleted, and what is recoverable

**Claim** |
Emergency Reset / account destruct removes your account, conversations, and media from our servers, and destroys the encryption keys on your device so any residual data is undecryptable. |
**Status** |
✅ Accurate with the honest scope/recoverability notes below. We do **not** claim we can forcibly overwrite data on Google's physical servers (no cloud service can). |

**On the device — **`SecureDataDestruction.killSwitch()`

:

- Calls the server
`deleteAccount`

cloud function.
- Deletes the user's Firebase Storage media (personal / pair / group).
**Keychain wipe** — `SecItemDelete`

across all key classes (`SecureDataDestruction.swift:158-176`

). This destroys the encryption keys = **crypto-erase**.
**7-pass DoD 5220.22-M overwrite** of every file in Documents/Caches/App Support/tmp, then delete (`SecureDataDestruction.swift:222-254`

).
- Clears UserDefaults, Firebase cache, overwrites heap memory.

**On the server — **`deleteAccount`

(`functions_index_consolidated.ts:212-740`

):

**1-on-1 **`pairs`

: deleted entirely — both halves, all messages, all media.
**Account:** user doc, all sub-collections, username index, push tokens, invites, invite codes deleted; then **Firebase Auth account deleted** (`auth.deleteUser`

).
**Groups:** the user is removed (sender key + receipts deleted). The group is fully wiped **only if no participants remain**; otherwise the group persists and **messages the user already sent into it remain (encrypted) for the other members** (`functions_index_consolidated.ts:438-494`

).

**Recoverability — the honest answer:**

**Device:** effectively permanent. The Keychain key-destruction is a cryptographic erase — any residual flash data is ciphertext with no key, mathematically unrecoverable. (On iOS flash/APFS a multi-pass overwrite isn't *guaranteed* to hit the original physical cells; the crypto-erase is the real guarantee, the overwrite is secondary.)
**Server:** the app issues a **logical delete** — data is removed from the live Firestore database and Storage and is gone from the app, queries, and console. We **cannot** securely overwrite data on Google's infrastructure; after deletion it follows Google Cloud's deletion process. If **Point-in-Time Recovery** or **scheduled backups** are enabled on the project, deleted data is restorable by the project owner for that window (PITR ≤ 7 days). **Mitigation:** the server only ever held encrypted ciphertext, so any residual copy is undecryptable once the device keys are crypto-erased.
**Not in scope of destruct:** the *recipient's* device still holds its own decrypted copy of any messages they received (no messenger can reach into another user's phone), and group messages the user contributed remain with groups that still have members.

**Honest one-liner:** "Destruct deletes your account, 1-on-1 conversations, and media from our servers and crypto-erases your keys on-device, so any encrypted copy that briefly persists in our provider's backups can never be read. We don't claim to physically overwrite Google's disks — no service can."

## 3. Emergency Account Reset (Three Independent Paths)

### 3.1 Shake-to-Wipe

**Claim** |
Shaking the phone four times in roughly one second surfaces the destruction confirmation. |
**Status** |
✅ Verified — uses CoreMotion accelerometer thresholds, not `motionEnded` . |
**Code path** |
`Phantom Chat/RootView.swift:16-102` — `PanicShakeDetector` (4+ reversal peaks within a 1.2s window)
`Phantom Chat/RootView.swift:91-92` — peak-count threshold
`Phantom Chat/RootView.swift:436-440` — callback sets `showPanicConfirm = true` |

**Claim** |
A Lock Screen widget that, on tap (Face ID required by iOS), opens the app to the destruction confirmation. |
**Status** |
✅ Verified — iOS enforces Face ID on Lock Screen widget delivery. |
**Code path** |
`PhantomPanicWidget/PhantomPanicWidget.swift:51` — `.widgetURL(URL(string: "phantomchat://panic"))`
`Phantom Chat/RootView.swift:374-397` — `.onOpenURL` catches the panic URL, triggers confirmation alert
`Phantom Chat/PhantomPanicWidget.swift:8-10` — comment notes Face ID is system-enforced for Lock Screen variant |

### 3.3 Duress Passcode

**Claim** |
A separate "duress" PIN entered at App Lock looks identical to the real PIN but silently destroys all data while presenting the decoy persona pack to whoever is holding the phone. |
**Status** |
✅ Verified. |
**Code path** |
`Phantom Chat/RootView.swift:526` — `AppPasscodeStore.verify(entered)` returns `.duress` verdict
`Phantom Chat/RootView.swift:535-553` — duress branch triggers silent wipe via `deleteAccount()` while showing decoy UI
`Phantom Chat/SettingsView.swift:536-548` — "Set Duress Passcode" UI |

### 3.4 Multi-Pass Secure Deletion (DoD 5220.22-M, 7 passes)

**Claim** |
Emergency Destruction overwrites local files 7 times before deletion. |
**Status** |
✅ Verified — the code literally performs DoD 5220.22-M (3 random + 3 complement + 1 zero passes). |
**Code path** |
`Phantom Chat/SecureDataDestruction.swift:227-250` — exact 7-pass implementation |
**Caveat (technical)** |
iOS uses APFS on flash storage with copy-on-write semantics. Multi-pass overwrites of file contents don't necessarily hit the original physical NAND blocks — that is a known property of flash storage, not specific to this app. The cryptographic-erase approach (destroying the data-protection key) is the primary defense; the multi-pass overwrite is a secondary belt-and-braces layer. We do both. The bare minimum guarantee — that the data is no longer cryptographically accessible — is satisfied either way. |

## 4. UI & State Privacy

### 4.1 App Switcher Blackout (Privacy Overlay)

**Claim** |
When the app moves to the background or App Switcher, a solid black overlay covers any sensitive content before iOS takes its snapshot. |
**Status** |
✅ Verified, covers both `.background` and `.inactive` scene phases. |
**Code path** |
`Phantom Chat/RootView.swift:354-355` — `.onChange(of: scenePhase)`
`Phantom Chat/RootView.swift:630-690` — three explicit handlers: • `.background` → sets `isScreenObscured = true` immediately • `.inactive` → debounced obscure via 400ms delay (suppresses transient push flickers) • `.active` → cancels any pending obscure
`Phantom Chat/RootView.swift:238-243` — black overlay renders when `appState.isScreenObscured && !appState.isLocked` |

### 4.2 Screenshot Detection

**Claim** |
If someone screenshots a 1-on-1 chat, the other party is notified with a system message. |
**Status** |
✅ Verified. |
**Code path** |
`Phantom Chat/ConversationScreen.swift:363` — listens for `UIApplication.userDidTakeScreenshotNotification`
`Phantom Chat/ConversationScreen.swift:801-826` — `handleScreenshot()` sends `"📸 Screenshot taken"` as an actual encrypted message to the peer |

### 4.3 Disappearing Messages

**Claim** |
Per-conversation timers that auto-delete messages from both devices after a chosen interval. |
**Status** |
⚠️ Range verified. The minimum is **10 seconds**, not 1 second. Maximum is ~30 days (1 month = 2.59 million seconds). The website was updated 2026-05-27 to reflect this. |
**Code path** |
`Phantom Chat/DisappearingMessagesView.swift:11-32` — timer options: 10s, 30s, 1m, 2m, 5m, 10m, 15m, 30m, 1h, 2h, 3h, 6h, 12h, 1d, 2d, 3d, 1w, 2w, 1mo |

## 5. Customisation

### 5.1 76 Alternate App Icons

**Claim** |
76+ alternate app icons available in Settings → App Icon. |
**Status** |
✅ Verified — exactly 76 unique icons in the bundle. |
**Code path** |
`Phantom Chat/Phantom Chat/AlternateIcons/*@2x.png` — 76 unique base names (each icon has @2x and @3x variants for 152 files total) |

## 6. Coming Soon — Honestly Marked, Not Yet Built

### 6.1 Tor Relay Routing (Anonymous Tier)

**Marketing label** |
"Coming Soon" |
**Status** |
❌ Not implemented in shipping build. |
**Code path** |
`Phantom Chat/ChatAPI.swift:29-31` — Tor integration explicitly disabled with comment "DISABLED: Tor integration (types not available)"
`Phantom Chat/CURRENT_ISSUES_SUMMARY.md:10-19` — open test failure `Code=-1004` |
**Note** |
Framework scaffolding exists in the build but is not functional. "Coming Soon" label on the website is accurate. |

### 6.2 Offline Bluetooth / P2P Mesh (Anonymous Tier)

**Marketing label** |
"Coming Soon" |
**Status** |
❌ Not implemented. Zero scaffolding. |
**Code path** |
No `MultipeerConnectivity` , `CoreBluetooth` , or mesh implementation in the Swift code. Pure roadmap item. |
**Note** |
"Coming Soon" label is honest — not even a stub exists yet. |

## 7. Things Removed From Marketing After Code Audit

These claims previously appeared on the website or in the in-app onboarding tour and have been removed because the code does not back them:

| Claim removed |
Reason |
| "Sealed Sender envelopes" |
No Sealed Sender implementation exists. (Signal-style "sealed sender" is a specific protocol feature that requires server-side blinding — Phantom Chat does not implement it.) |
| "iMessage doesn't have post-quantum encryption" |
Apple shipped **PQ3** on iMessage in iOS 17.4 (March 2024). The comparison table was corrected. |
| "Air-gapped servers" |
The backend runs on Firebase (Google Cloud). "Air-gapped" — meaning no network connection at all — is provably false for any cloud-hosted service. Replaced with "encrypted-at-rest infrastructure". |
| "No metadata stored" (in hero) |
The Privacy Policy enumerates what IS stored (username, push token, subscription). "No metadata" was an overclaim. Replaced with "No phone. No email. No tracking." |

## 8. How to Verify This Yourself

The TestFlight beta is open: **https://testflight.apple.com/join/rmSrMch9**

For a deeper look:

**Open Wireshark or a similar tool** on a Wi-Fi network with TestFlight running — confirm only encrypted Firestore + APNs traffic.
**Read the on-device logs** — enable Settings → Logs & Support → File Logging, do anything, then tap "Send Logs to Veilus" — the log file shows every cryptographic operation step-by-step.
**Request the cloud functions source** — happy to share `functions_index_consolidated.ts`

(~1700 lines) for review of the server-side claims.

For deeper questions: `support@veilusdigital.co`

.

— Curtis Tunaley Founder, Veilus Digital Western Australia
