Skip to content

Security: sophaxtechnologies/SophaxChat

Security

SECURITY.md

Security Architecture & Review

Last reviewed: 2026-04-04. Reviewed against source code; not an independent external audit.


Threat Model

SophaxChat is designed to protect against:

Threat Mitigation
Passive eavesdropping (network sniffing) End-to-end encryption (Double Ratchet + ChaCha20-Poly1305)
Active MITM attack Ed25519 signatures on every message; Safety Number QR verification
Server compromise No servers — P2P only
Compromised device (past messages) Forward secrecy (per-message keys via symmetric ratchet)
Compromised device (future messages) Break-in recovery (Double Ratchet DH ratchet with fresh Curve25519 keys)
Metadata analysis Anonymous identity (no phone/email); peerID = hash of identity keys
Replay attacks Message authentication + unique nonces; message-ID deduplication at storage layer
Key exhaustion / DoS Bounded skipped-key cache (max 1000); relay rate limiting (20/10s per peer)
Message forgery Ed25519 signatures on all wire messages
Sender identity leakage to relay nodes Sealed sender: ECDH(ephemeral, recipient DH) → ChaCha20-Poly1305 wraps inner message
Header metadata leakage to relay nodes Header Encryption (HE) variant of Double Ratchet — headers sealed with rotating header keys
Physical data-at-rest access AES-256-GCM encrypted message store + private keys in Keychain
iCloud backup exfiltration kSecAttrAccessibleWhenUnlockedThisDeviceOnly — keys never backed up or transferred
App switcher screenshot Blur overlay on willResignActive
App access without biometric App lock (Face ID / Touch ID / passcode, auto-lock on background)
DoS via oversized messages Attachment ≤ 512 KB; username ≤ 64 chars
Prekey exhaustion OTPK pool auto-replenished when below 50%; SPK rotated every 7 days

Cryptographic Primitives

All primitives are from Apple's CryptoKit (audited, hardware-accelerated):

Layer Operation Algorithm Key Size
Identity Signing Ed25519 256-bit
Identity Key agreement X25519 256-bit
Session init Key exchange X3DH (Signal spec)
Session init Key derivation HKDF-SHA256 256-bit
Messaging Symmetric ratchet HMAC-SHA256 256-bit
Messaging DH ratchet X25519 256-bit
Messaging AEAD encryption ChaCha20-Poly1305 256-bit
Header encryption AEAD ChaCha20-Poly1305 256-bit
Sealed sender Key derivation HKDF-SHA256 256-bit
Sealed sender AEAD ChaCha20-Poly1305 256-bit
Group Sender Keys KDF chain HMAC-SHA256 256-bit
Group encryption AEAD ChaCha20-Poly1305 256-bit
Storage At-rest encryption AES-256-GCM 256-bit
Identity verification Safety numbers SHA-512
Random number generation OS CSPRNG (via CryptoKit)

Protocol Details

Session Establishment (X3DH)

Based on the Signal X3DH specification.

Alice                                          Bob
──────────────────────────────────────────────────────
         ←─── Hello (Bob's PreKeyBundle) ────

Alice computes:
  EK_A = ephemeral key pair
  DH1 = DH(IK_A, SPK_B)    — identity auth
  DH2 = DH(EK_A, IK_B)     — ephemeral x identity
  DH3 = DH(EK_A, SPK_B)    — ephemeral x signed prekey
  DH4 = DH(EK_A, OPK_B)   [if OPK available]
  SK  = HKDF(0xFF×32 ∥ DH1 ∥ DH2 ∥ DH3 [∥ DH4])

         ──── InitiateSession (EK_A, first DR message) ────→

Bob computes: same DH operations symmetrically → same SK

Message Encryption (Double Ratchet with Header Encryption)

Based on the Signal Double Ratchet specification §4.3.

  • DH Ratchet: New Curve25519 key pair generated on each direction change. Provides break-in recovery.
  • Symmetric Ratchet: KDF_CK(chainKey) via HMAC-SHA256. Provides per-message forward secrecy.
  • Header Encryption: RatchetHeader (ratchet public key, counters) is sealed with rotating header keys derived from the same HKDF chain. Relay nodes see only opaque ciphertext.
  • Body AEAD: ChaCha20-Poly1305(messageKey, plaintext, AAD = associatedData ∥ encryptedHeader) — header and body are cryptographically bound.

Sealed Sender

Sender generates ephemeral key pair (EK_s).
sharedSecret = ECDH(EK_s_private, recipient_DH_public)
sealingKey   = HKDF-SHA256(sharedSecret, info="SophaxChat_SealedSender_v1", len=32)
encryptedPayload = ChaChaPoly(sealingKey, JSON(innerWireMessage))

Relay nodes see origin + target + EK_s_public + ciphertext. The inner sender identity is revealed only to the recipient.

Group Messaging (Sender Keys v2)

Each group member independently maintains a KDF chain:

messageKey_n   = HMAC-SHA256(chainKey_n, 0x01)   — encrypt/decrypt one message
chainKey_{n+1} = HMAC-SHA256(chainKey_n, 0x02)   — advance chain

The sender's chain key is distributed to all group members via the per-peer Double Ratchet channel (so only authenticated members can read it). Group messages are independently encrypted by each sender using their current message key, then broadcast to all members.

Properties:

  • Per-message forward secrecy: Compromising chainKey_n exposes only messages ≥ n.
  • Break-in recovery: Each sender independently ratchets; no shared state to compromise.
  • No transcript server: Messages flood the local mesh; only members with valid chain keys can decrypt.

Wire Message Authentication

Every network message is signed with the sender's Ed25519 identity key:

signingBytes = type ∥ payload ∥ senderID ∥ ISO8601(timestamp)
signature    = Ed25519.sign(signingBytes, with: signingPrivateKey)

Recipients verify this signature before processing. Session-initiation messages are self-authenticating (the verification key is carried inline in the bundle).


Key Storage

Key Material Keychain Account Access Control
Ed25519 identity signing key identity.signing WhenUnlockedThisDeviceOnly
X25519 identity DH key identity.dh WhenUnlockedThisDeviceOnly
Signed prekey spk.<id> WhenUnlockedThisDeviceOnly
One-time prekeys otpk.<id> WhenUnlockedThisDeviceOnly
Double Ratchet session state session.<peerID> WhenUnlockedThisDeviceOnly
Message storage master key storage.master WhenUnlockedThisDeviceOnly
Group sender key (my chain) skd.mine.<groupID> WhenUnlockedThisDeviceOnly
Group peer sender keys skd.peers.<groupID> WhenUnlockedThisDeviceOnly
Encrypted messages App filesystem AES-256-GCM encrypted

kSecAttrAccessibleWhenUnlockedThisDeviceOnly:

  • Keys are not backed up to iCloud
  • Keys are not transferred when setting up a new device
  • Keys are not accessible when the device is locked
  • Keys are wiped on factory reset

Security Review Findings

HIGH

H-1 — No group re-keying on member change

When a member leaves a group, remaining members continue using their existing sender key chains. The departed member retains all chain states from the time of departure and can decrypt any group traffic they subsequently capture. There is no automatic re-keying on member leave or removal.

Mitigation required: After a member change, all remaining members should generate fresh sender keys and redistribute them via the DR channel.


MEDIUM

M-1 — Group sender key: skipped messages are irrecoverable

The receiver fast-forwards the sender chain up to MAX_SKIP = 100 iterations for out-of-order delivery, but discards intermediate message keys (not cached). Any message that arrives after being skipped over cannot be decrypted.

Recommendation: Cache skipped message keys in a bounded table (as the Double Ratchet layer already does).

M-2 — Sender key distribution may arrive after messages in high-load scenarios

When a group member joins, their sender key distribution (SKD) is sent via the DR channel to all members. If the joiner's first group messages arrive before the SKD (possible in a high-traffic mesh), those messages cannot be decrypted.

Recommendation: Include the current chain state (not freshly generated) in subsequent SKD resends; or hold outgoing group messages briefly until SKD delivery is confirmed.

M-3 — One-time prekey exhaustion window

If Bob's OTPK pool is empty, Alice initiates X3DH without DH4, removing the one-time component from the session secret. Auto-replenishment occurs when the pool falls below 50%, but there is a brief window of reduced security.

M-4 — Notification previews may expose content on lock screen

UNNotificationContent previews are shown on the lock screen by default, potentially revealing message content before the user unlocks the app.

Recommendation: Set hiddenPreviewsBodyPlaceholder and use UNNotificationContent.threadIdentifier to group without revealing content.


LOW

L-1 — No transcript consistency for groups

Decentralized group messaging cannot guarantee all members receive the same messages in the same order without a trusted coordinator or threshold scheme. A malicious member can selectively withhold or reorder messages.

L-2 — Group message timestamps are sender-controlled

GroupWireMessage.timestamp is set by the sender. A malicious or clock-skewed sender can cause messages to appear in the wrong order in the UI.

L-3 — ISO8601DateFormatter allocated per signingBytes() call

Performance micro-issue; no security impact.


What SophaxChat Does NOT Protect Against

  • Traffic analysis: Packet timing and size correlations on the mesh can reveal communication patterns.
  • Endpoint compromise: A compromised device has access to all Keychain material and plaintext in memory.
  • Selective delivery attacks: Group members can selectively withhold messages from other members.
  • Identity bootstrapping (TOFU): First contact is trust-on-first-use. Safety Number QR scan is the only out-of-band verification mechanism.
  • Physical device seizure: Bypasses all software-layer protections.
  • Bluetooth range limitations: Mesh is limited to ~100m (WiFi Direct) or ~30m (Bluetooth LE).
  • No key recovery: If a device is wiped or lost, all message history and identity keys are permanently gone. There is intentionally no backup or recovery mechanism.

2026-04 Internal Audit — Fixed Findings

The following findings were identified and addressed during an internal review conducted ahead of a planned third-party audit. All HIGH findings are fixed.

HIGH — Fixed

H1 · Integer underflow in Double Ratchet skip-key calculation

File: Sources/SophaxChatCore/Crypto/DoubleRatchet.swift Status: ✅ Fixed

A malicious peer could send a message with messageNumber < receiveMessageCount. The original signed-Int subtraction produced a negative totalSkipped value that bypassed the maxSkippedMessages guard, potentially triggering unbounded key derivation (memory exhaustion / DoS).

Fix: Changed to UInt32 subtraction after an explicit guard target > state.receiveMessageCount check, making the arithmetic inherently non-negative.

H2 · Timing side-channel in PIN verification

File: Sources/SophaxChatCore/Crypto/KeychainManager.swift Status: ✅ Fixed

Both verifyRealLockPIN and verifyDuressPIN used Swift's Data == operator, which short-circuits on the first differing byte. An attacker with physical device access could brute-force a 4-digit PIN faster via timing analysis.

Fix: Replaced with timingsafe_bcmp — the same constant-time comparison already used in SerializableSymmetricKey (CryptoTypes.swift).

H3 · TCP frame size: UInt32 → Int conversion before bounds check

File: Sources/SophaxChatCore/Network/TCPTransport.swift Status: ✅ Fixed

The raw UInt32 frame length was implicitly converted to Int before comparison against maxFrameSize, obscuring overflow risk and making the guard's safety property non-obvious.

Fix: Added an explicit guard Int(lengthU32) <= TCPTransport.maxFrameSize check before using the value, then assigned to a named length: Int constant.


MEDIUM — Fixed

M1 · Prekey bundle replay window too wide (24 hours)

File: Sources/SophaxChatCore/Crypto/CryptoTypes.swift Status: ✅ Fixed

maxPreKeyBundleAge was 86 400 s (24 h), giving a passive adversary a full day to replay a captured bundle. Reduced to 3 600 s (1 h).

M3 · No rate limiting for incoming typing / reaction messages

File: Sources/SophaxChatCore/ChatManager.swift Status: ✅ Fixed

.typing and .reaction wire messages were processed unconditionally, allowing a malicious peer to flood the UI thread.

Fix: Added a per-peer token bucket (TokenBucket struct): .typing — 10 events / 10 s; .reaction — 20 events / 10 s. Excess messages are silently dropped before decoding.


MEDIUM — Deferred

M2 · DHConcat intermediates not explicitly zeroed

File: Sources/SophaxChatCore/Crypto/X3DH.swift Status: ⚠️ Deferred

CryptoKit's SharedSecret does not expose a public zeroing API. Deferred until CryptoKit provides a zeroing interface or a third-party auditor identifies a concrete exploitation path on iOS 17+.


LOW — Fixed

L2 · No length guard on Keychain account keys

File: Sources/SophaxChatCore/Crypto/KeychainManager.swift Status: ✅ Fixed — guard account.count <= 256 added before Keychain API calls.

L3 · DoubleRatchet importState: no pre-check on JSON data size

File: Sources/SophaxChatCore/Crypto/DoubleRatchet.swift Status: ✅ Fixed — guard data.count < 100_000 added before JSONDecoder call.


LOW — Deferred

L1 · MessageStore filenames derived from raw peerID

File: Sources/SophaxChatCore/Storage/MessageStore.swift Status: ⚠️ Deferred — changing the filename scheme (e.g. to SHA256(peerID)) would break existing installations without a migration path. Risk is low: the directory is under FileProtectionType.complete (encrypted at rest, inaccessible when device is locked). Will be addressed alongside a planned storage-layer migration.


Responsible Disclosure

Please do not open public GitHub issues for security vulnerabilities.

Use GitHub Security Advisories — private, end-to-end encrypted between reporter and maintainers, no central server involved.

We aim to respond within 72 hours. Coordinated disclosure window: 90 days.

There aren’t any published security advisories