SophaxChat · Code Richard Metzner

02 · In words, before code

docs/CRYPTO_SUMMARY.md

377 lines · Markdown

# SophaxChat — Cryptographic Protocol Summary

> Version 1.0 · 2026-03-28
> Prepared for independent security audit

All cryptographic operations use Apple's **CryptoKit** framework (backed by the Secure Enclave where available). No third-party cryptographic libraries are used.

---

## 1. Protocol Overview

```
First contact (X3DH)
  Alice fetches Bob's PreKeyBundle
  Alice performs X3DH → 32-byte master secret
  Alice sends Hello message (encrypted)
  Bob receives Hello → derives same master secret
       │
       ▼
Session established (Double Ratchet)
  Both sides hold DR state (root key, chain keys, ratchet key pair)
  Every message advances the ratchet → new message key
  Used message keys deleted immediately → forward secrecy
       │
       ▼
Message at rest
  StoredMessage → JSON → AES-256-GCM → .enc file in Application Support
       │
       ▼
Group messages (Sender Keys v2)
  Each member has own KDF chain (HMAC-SHA256)
  Sender key exchanged via DR channel on join / member change
```

---

## 2. Identity Keys

### Generation
On first launch, `IdentityManager.loadOrCreateKeys()` generates:
- **Ed25519 signing key pair** — `Curve25519.Signing.PrivateKey()` (system CSPRNG via SecRandomCopyBytes)
- **X25519 DH key pair** — `Curve25519.KeyAgreement.PrivateKey()` (system CSPRNG)

Both private keys are stored in Keychain with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. They are never written to disk outside the Keychain.

### peerID Derivation
```
peerID = SHA256(signingKeyPublic ∥ dhKeyPublic)[0..<16].hexString  // 32 hex chars
```

### Safety Number
```
safetyNumber = SHA512(signingKeyPublic ∥ dhKeyPublic)
             → 60 decimal digits in groups of 5
             → displayed for out-of-band verification
```

---

## 3. Prekeys (X3DH Setup Material)

**File:** `Sources/SophaxChatCore/Crypto/PreKeyManager.swift`

### Prekey types
| Type | Count | Rotation |
|------|-------|----------|
| Signed Prekey (SPK) | 1 | Monthly (or on demand) |
| One-Time Prekeys (OPK) | 10 initially, top up as consumed | Per-use (consumed on first DH) |

### PreKeyBundle (broadcast in Hello message)
```
PreKeyBundle {
    peerID:          String           // SHA256(signing ∥ dh)[:16]
    username:        String
    signingKeyPublic: Data            // Ed25519 32B
    dhIdentityKeyPublic: Data         // X25519 32B
    signedPreKeyPublic: Data          // X25519 32B
    signedPreKeySignature: Data       // Ed25519 signature of SPK
    oneTimePreKeyPublic: Data?        // X25519 32B (nil if exhausted)
    oneTimePreKeyID: String?          // UUID of consumed OPK
    tcpAddress: String?               // optional host:port for TCP
    avatarData: Data?                 // optional JPEG 64×64
}
```

SPK signature: `Ed25519.sign(signedPreKeyPublic)` — verifies the SPK was generated by the claimed identity.

---

## 4. Session Initiation — Extended Triple Diffie-Hellman (X3DH)

**File:** `Sources/SophaxChatCore/Crypto/X3DH.swift`

Reference: [Signal X3DH Specification](https://signal.org/docs/specifications/x3dh/)

### Sender (Alice) computes:
```
DH1 = X25519(Alice.IK_priv, Bob.SPK_pub)
DH2 = X25519(Alice.EK_priv, Bob.IK_pub)
DH3 = X25519(Alice.EK_priv, Bob.SPK_pub)
DH4 = X25519(Alice.EK_priv, Bob.OPK_pub)   // omitted if no OPK available

masterSecret = HKDF-SHA256(
    ikm  = 0xFF×32 ∥ DH1 ∥ DH2 ∥ DH3 [∥ DH4],
    salt = 0x00×32,
    info = "SophaxChat X3DH v1"
)
```

DH material is zeroed from memory immediately after deriving masterSecret.

The `0xFF×32` prefix follows the Signal spec to domain-separate the X3DH from other HKDF usages and prevent length extension.

### Receiver (Bob) computes:
Identical calculation with mirrored key roles; same masterSecret is derived.

### Associated data
```
AD = signingKeyPublic(Alice) ∥ signingKeyPublic(Bob)
```
Used as AEAD associated data in the initial Hello message.

### Hello message format
The first message (`.hello` WireMessageType) carries:
- Alice's PreKeyBundle (so Bob can verify her identity)
- The `senderEphemeralPublicKey` (Alice.EK_pub)
- The `consumedOPKID` so Bob can delete the used one-time prekey
- An encrypted `MessageContent` sealed with the derived masterSecret

---

## 5. Double Ratchet Algorithm

**File:** `Sources/SophaxChatCore/Crypto/DoubleRatchet.swift`

Reference: [Signal Double Ratchet Specification](https://signal.org/docs/specifications/doubleratchet/)

### State
```swift
struct DoubleRatchet {
    var rootKey:          Data            // 32B
    var sendingChainKey:  Data            // 32B
    var receivingChainKey: Data?          // 32B
    var sendRatchetKeyPair: X25519KeyPair // 32B private + 32B public
    var receiveRatchetPublicKey: Data?    // 32B
    var sendMessageNumber:    UInt32
    var receiveMessageNumber: UInt32
    var previousSendChainLen: UInt32
    // skipped message key cache (bounded, in-memory + Keychain)
}
```

### Ratchet steps

**Symmetric ratchet (chain key advance):**
```
messageKey   = HMAC-SHA256(chainKey, 0x01) → 32B → SymmetricKey
nextChainKey = HMAC-SHA256(chainKey, 0x02)
```

**DH ratchet (on new public key from peer):**
```
(newRootKey, newReceivingCK) = HKDF-SHA256(
    ikm  = rootKey ∥ X25519(sendRatchetKey_priv, peerRatchetKey_pub),
    info = "SophaxChat DR v1"
)
```

### Header encryption
Ratchet headers are encrypted with a separate header key (derived during ratchet init) so relay nodes cannot observe message numbers or ratchet public keys.

### Sealed sender
```
sealedEnvelope = ECIES(
    recipientDHPublicKey,
    ephemeralKeyPair,
    plaintext = WireMessage,
    info = "SophaxChat sealed sender"
)
```
The sender's peerID is included inside the encrypted envelope rather than in the outer header.

### Out-of-order messages
Skipped message keys are cached in memory (bounded to `MAX_SKIP = 100` per sender, 200 total). Cache is also persisted to Keychain with 7-day expiry to survive app restarts.

### Message encryption
```
(nonce, ciphertext, tag) = ChaCha20-Poly1305.seal(
    plaintext        = MessageContent JSON,
    key              = messageKey (32B),
    associatedData   = "groupID/senderPeerID" (group) or empty (DM)
)

wirePayload = nonce ∥ ciphertext ∥ tag
```

---

## 6. Group Encryption — Sender Keys v2

**Files:** `Sources/SophaxChatCore/Group/GroupTypes.swift`, `Sources/SophaxChatCore/ChatManager.swift`

Reference: [Signal Sender Keys](https://signal.org/docs/specifications/senderkeys/)

### Per-member KDF chain
Each group member maintains their own sender key chain:
```
messageKey_n   = HMAC-SHA256(chainKey_n, 0x01) → 32B SymmetricKey
chainKey_{n+1} = HMAC-SHA256(chainKey_n, 0x02)
```
`iteration` tracks how many steps have been consumed. The current `chainKey` is stored in Keychain (`"skd.mine.<groupID>"`).

### Key distribution
When a member joins or a key rotation occurs, a `SenderKeyDistributionMessage` is sent via the DR-encrypted channel:
```swift
SenderKeyDistributionMessage {
    groupID:   String
    chainKey:  Data    // current 32B chain key seed
    iteration: UInt32  // steps already consumed from this chain
}
```

### Bidirectional exchange
When member X receives an SKD from member Y they haven't seen before, X immediately replies with their own current SKD via the DR channel. This ensures convergence without a separate Welcome message structure.

### Key rotation on member leave
When a member leaves:
1. They broadcast `GroupMemberLeftMessage`
2. Each remaining member generates a new random 32-byte chain key (iteration reset to 0)
3. New SKD is sent to all remaining members via DR

### Replay protection
`handleSenderKeyDistribution` rejects distributions where `iteration < existingState.iteration`.

### Skipped key cache (out-of-order group messages)
Skipped group message keys are cached in memory and persisted to Keychain account `"skd.skipped.all"`, bounded to 200 entries, with 7-day automatic eviction.

---

## 7. Message Storage at Rest

**File:** `Sources/SophaxChatCore/Storage/MessageStore.swift`

```
Application Support/sophax_conversations/<peerID>.enc
```

```
nonce (12B) ∥ ciphertext ∥ GCM-tag (16B)
```

The master storage key is a 256-bit `SymmetricKey` stored in Keychain under `"message.store.key"`. Generated randomly on first use.

Each conversation is a separate file. File is completely re-encrypted on every write. Files are excluded from iCloud backup via `URLResourceValues.isExcludedFromBackup`.

---

## 8. Attachment Storage at Rest

**File:** `Sources/SophaxChatCore/Storage/AttachmentStore.swift`

```
Application Support/sophax_attachments/<UUID>
```

Same master key as MessageStore (`"message.store.key"`). Each attachment is a separate AES-256-GCM encrypted file. Referenced by UUID from `StoredMessage.attachmentID`.

---

## 9. Backup Encryption

**File:** `Sources/SophaxChatCore/Storage/BackupManager.swift`

Exports: messages, contacts, username, identity fingerprint. **Does not export private keys** (device-bound by design) — except for identity backup (see § 10).

```
"SXBK" (4B) ∥ 0x01 (version, 1B) ∥ salt (32B) ∥ nonce (12B) ∥ AES-256-GCM(ciphertext ∥ tag)
```

Key derivation:
```
backupKey = PBKDF2-HMAC-SHA256(
    password   = passphrase (UTF-8),
    salt       = random 32B,
    iterations = 600 000,
    keyLen     = 32
)
```

600 000 iterations makes brute-force ~600 000× harder than a single hash. Minimum passphrase: 16 characters (enforced in UI).

---

## 10. Identity Backup Encryption

**File:** `Sources/SophaxChatCore/Crypto/IdentityExport.swift`

Exports private identity keypair for cross-device restore.

```
"SXID" (4B) ∥ 0x01 (version, 1B) ∥ salt (32B) ∥ nonce (12B) ∥ AES-256-GCM(ciphertext ∥ tag)
```

Ciphertext contains JSON: `{signingKeyPrivate, dhKeyPrivate, username, exportedAt}`

Same KDF as backup (PBKDF2-HMAC-SHA256, 600 000 iterations). Minimum passphrase: 12 characters.

**Security note:** Importing an identity backup replaces the current Keychain keys. All existing DR sessions become invalid (different key material). Peers will see a key change alert and must re-verify Safety Numbers.

---

## 11. Key Storage Summary

| Key | Keychain account | Type | Accessible |
|-----|-----------------|------|-----------|
| Ed25519 signing private | `identity.signing` | 32B raw | WhenUnlockedThisDeviceOnly |
| X25519 DH private | `identity.dh` | 32B raw | WhenUnlockedThisDeviceOnly |
| Username | `user.username` | UTF-8 | WhenUnlockedThisDeviceOnly |
| Avatar JPEG | `identity.avatar` | Data | WhenUnlockedThisDeviceOnly |
| Signed prekey (current) | `prekey.signed.current` | JSON | WhenUnlockedThisDeviceOnly |
| One-time prekeys | `prekey.opk.<id>` | JSON | WhenUnlockedThisDeviceOnly |
| DR session state (per peer) | `dr.session.<peerID>` | JSON | WhenUnlockedThisDeviceOnly |
| Message storage master key | `message.store.key` | 32B SymmetricKey | WhenUnlockedThisDeviceOnly |
| App lock enabled flag | `settings.applock` | 1B | WhenUnlockedThisDeviceOnly |
| My sender key (per group) | `skd.mine.<groupID>` | JSON SenderKeyState | WhenUnlockedThisDeviceOnly |
| Peer sender keys (per group) | `skd.peers.<groupID>` | JSON dict | WhenUnlockedThisDeviceOnly |
| Skipped group msg keys | `skd.skipped.all` | JSON | WhenUnlockedThisDeviceOnly |
| Verified peers | `verified.peers` | JSON dict | WhenUnlockedThisDeviceOnly |

All items use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`: not migratable, not iCloud-backed, accessible only when device is unlocked.

---

## 12. Transport Security

### MultipeerConnectivity
Apple's MCSession establishes an authenticated, encrypted session between peers. Certificate-based authentication is used internally by the framework. SophaxChat adds application-layer E2EE on top.

### Tor hidden services
Messages routed over Tor use `.onion` v3 addresses derived from the Ed25519 identity key:
```
onionHostname = Base32(SHA3-256(Ed25519PublicKey) ∥ version(0x03) ∥ checksum) + ".onion"
```
Tor provides anonymity at the network layer. SophaxChat E2EE provides confidentiality at the application layer.

### TCP (mDNS / LAN)
Direct TCP connections over local network use application-layer E2EE (same wire protocol as Bluetooth). No TLS layer is added — encryption is entirely at the application level.

---

## 13. Random Number Generation

All key generation and nonce generation uses system CSPRNG:
- `Curve25519.Signing.PrivateKey()` — calls `SecRandomCopyBytes` internally (Apple CryptoKit)
- `Curve25519.KeyAgreement.PrivateKey()` — same
- `SymmetricKey(size:)` — same
- `ChaChaPoly.Nonce()` — same
- `AES.GCM.Nonce()` — same

No custom PRNG or seeding is performed.

---

## 14. Recommended Review Files

| Priority | File | Contents |
|----------|------|----------|
| P0 | `Sources/SophaxChatCore/Crypto/X3DH.swift` | X3DH sender/receiver |
| P0 | `Sources/SophaxChatCore/Crypto/DoubleRatchet.swift` | DR algorithm |
| P0 | `Sources/SophaxChatCore/ChatManager.swift` | sealMessage, unsealMessage, handleHello, handleChatMessage |
| P1 | `Sources/SophaxChatCore/Group/GroupTypes.swift` | SenderKeyState, KDF |
| P1 | `Sources/SophaxChatCore/Crypto/PreKeyManager.swift` | X3DH prekey management |
| P1 | `Sources/SophaxChatCore/Crypto/KeychainManager.swift` | All key storage |
| P1 | `Sources/SophaxChatCore/Storage/BackupManager.swift` | Backup encryption |
| P1 | `Sources/SophaxChatCore/Crypto/IdentityExport.swift` | Identity key export |
| P2 | `Sources/SophaxChatCore/Network/NetworkProtocol.swift` | Wire message types |
| P2 | `Sources/SophaxChatCore/Storage/MessageStore.swift` | Message storage |