Skip to content

Group Messaging Technical

Yura Filatov edited this page Jul 4, 2026 · 3 revisions

Group Messaging — Technical Details

See Group Messaging for the non-technical overview.


Wire Format

New Bundle Mode

case group

Old builds decode .group as .unsupported → BundleError.unsupportedMode → "requires a newer version of Occulta."

OccultaBundle (top level)

OccultaBundle
├── version
├── secrecy
│   ├── mode = .group
│   ├── ephemeralPublicKey      // Data() — empty; per-recipient ephemeral lives in Recipient
│   └── prekeyID               // nil
├── ciphertext                  // AES-GCM(SealedPayload, sessionKey)
├── fingerprintNonce
├── senderFingerprint
└── group: GroupEnvelope?       // nil for all single-recipient bundles

GroupEnvelope

GroupEnvelope
├── version: UInt8              // envelope format version; 1 = trial-decryption slot-finding
├── blind: Data                 // HMAC-SHA256(key: groupID.rawBytes, msg: blindNonce) — fresh per bundle
├── blindNonce: Data            // 16 random bytes
└── recipients: [Recipient]     // one entry per member in the active layer at send time

blind carries no stable group identifier. The actual groupID lives only inside the AES-GCM-encrypted SealedPayload. A passive observer cannot correlate bundles with a stored Group record or across transports without the local DB key.

An inbound envelope claiming more recipients than Group.slotCount (32) is rejected before any trial-decryption work runs — no legitimately-created group can exceed that cap, so a larger count means a malformed or crafted bundle designed to force unbounded per-entry ECDH work on the receiving device.

Recipient

Recipient
├── secrecyContext: SecrecyContext
│   ├── mode                   // .forwardSecret, .forwardSecretNoPQ, .longTermFallback, or .longTermNoPQ — this recipient's own key path
│   ├── ephemeralPublicKey     // sender's ephemeral P-256 public key for this recipient
│   └── prekeyID               // UUID of consumed prekey, or nil on a fallback mode
└── wrappedPayload: Data        // AES-GCM(RecipientPayload, wrappingKey, AAD: blind)
RecipientPayload
├── sessionKey: Data                    // 32 bytes — decrypts the shared ciphertext
├── prekeyBatch: PrekeySyncBatch?
├── shardOperations: [ShardOperation]   // fixed-size, tier-padded (v1.9.1+ senders) — see "Vault Shard Distribution Over Groups"
├── custodyManifest: [UUID]             // fixed-size, tier-padded
├── custodyManifestCount: Int           // how many leading entries above are real
├── expectedShards: [UUID]              // fixed-size, tier-padded
├── expectedShardsCount: Int
└── shardMetadataAttempted: Bool        // whether custodyManifest/expectedShards were actually built for this recipient

shardOperations/custodyManifest/expectedShards/custodyManifestCount/expectedShardsCount/shardMetadataAttempted are absent entirely on payloads from pre-1.9.1 senders; decoding falls back to empty/0/false, which downstream code treats identically to "not attempted."

No cleartext identity hint in any recipient slot. The receiver finds their slot by trial-decryption — an observer with a target's public key cannot confirm membership without deriving a valid wrapping key.

ML-KEM shared secrets are never transmitted on the wire — they are retrieved from the stored contact record at wrap time.

Additional Authenticated Data

Outer ciphertext:

"v4".utf8 || JSON(.sortedKeys, SecrecyContext{mode:.group, ephemeralPublicKey:Data(), prekeyID:nil}) || blind

Per-recipient wrappedPayload:

blind

Binds the session key and prekey batch to this specific bundle's blind. A compromised wrapping key cannot substitute a session key from a different bundle — blind differs per send.


Crypto Flows

Encrypt

  1. Generate random 256-bit sessionKey
  2. Generate blindNonce (16 random bytes); compute blind = HMAC-SHA256(key: groupID.rawBytes, msg: blindNonce)
  3. Encode SealedPayload (message + groupID inside; prekeyBatch = nil)
  4. AES.GCM.seal(payload, using: sessionKey, authenticating: outerAAD) → ciphertext
  5. For each member in the active layer:
    • If prekey available: generate ephemeral P-256 keypair, derive wrappingKey via ECDH + ML-KEM, consume prekey, set mode = .forwardSecret (or .forwardSecretNoPQ without ML-KEM material)
    • If no prekey: derive wrappingKey via long-term identity ECDH + ML-KEM, set mode = .longTermFallback (or .longTermNoPQ)
    • If eligible for shard content (.groupShardCapable, ML-KEM material present, and a prekey was available for this specific send) — resolve real shardOperations/custodyManifest/expectedShards via ShardCustodyManager; otherwise leave all three empty and mark shardMetadataAttempted = false
    • Build RecipientPayload(sessionKey:, prekeyBatch: batchIfNeeded(for:), ...)
  6. Compute the shared per-field tier (ShardPadding.tier(for:)) from the real maximum count across every member in this send, and pad every member's three shard arrays — eligible or not — up to that tier with random filler, so wrappedPayload length never reveals which members are shard-eligible
  7. For each member: AES.GCM.seal(RecipientPayload, using: wrappingKey, authenticating: blind) → wrappedPayload; assemble Recipient(secrecyContext, wrappedPayload)
  8. Assemble GroupEnvelope(version: 1, blind, blindNonce, recipients), attach to bundle, serialize

Decrypt

  1. Decode bundle — group != nil → take group path; check GroupEnvelope.version == 1, else reject; reject if recipients.count > Group.slotCount
  2. For each Recipient entry (trial-decryption):
    • Derive candidate wrappingKey from secrecyContext.mode
    • Attempt AES.GCM.open(wrappedPayload, authenticating: blind) — first success is our slot
  3. If no slot opened → throw GroupDecryptError.recipientSlotNotFound
  4. Decode RecipientPayload from the opened bytes
  5. If RecipientPayload.prekeyBatch != nil → store batch for the sender
  6. De-pad this recipient's own shard content: real shardOperations entries are filtered by kind != .unsupported; custodyManifest/expectedShards are truncated to their real-count fields only if shardMetadataAttempted is true. If this recipient's own slot decrypted via .longTermFallback/.longTermNoPQ, all shard content is discarded regardless of what the sender sent — shard content requires forward secrecy independently on both the sending and receiving side, so a stale or misbehaving sender can't push it through a fallback bundle
  7. AES.GCM.open(ciphertext, authenticating: outerAAD, using: sessionKey) → SealedPayload
  8. Read SealedPayload.groupID — if nil, throw GroupDecryptError.missingGroupID
  9. Verify SealedPayload.senderProof == HMAC-SHA256(key: sessionKey, msg: senderPublicKey)

Vault Shard Distribution Over Groups

Available in: v1.9.1+

A group send can also carry Shamir secret-sharing custody traffic (see Vault & Secret Sharing) to eligible members, alongside the regular message.

Eligibility, per member, per send:

  • Contact's maxBundleVersion resolves to .groupShardCapable (proven by receipt — see Version Gating)
  • Contact has ML-KEM material on file
  • A prekey was available for this send — forward secrecy is required; shard content never travels on the long-term-key fallback path, matching the single-recipient rule

A member failing any gate simply gets an all-filler shard section and still receives the message normally — nothing about shard eligibility can abort or degrade the send for anyone else.

Uniform sizing: custodyManifest/expectedShards/shardOperations are padded to the smallest tier (2, 4, 8, 16, ...) that fits the real maximum across every member in the send, so every recipient's wrappedPayload is the same length regardless of whether they carry real shard content.

Distinguishing "empty" from "not attempted": custodyManifestCount/expectedShardsCount == 0 is ambiguous on its own — it means either "the sender attempted this and genuinely found nothing" (a real signal: e.g. loss detection, or an intentional revoke-all via processExpectedShards) or "the sender never attempted this" (ineligible member, or their vault was locked at send time). shardMetadataAttempted disambiguates the two explicitly. custodyManifest and expectedShards are always attempted together, or not at all, for a given recipient — never independently — so this single flag covers both fields.

Defense in depth on receipt: the receiving device independently strips all shard content whenever its own recipient slot decrypted via a fallback mode, regardless of what the sender's payload claims — it does not rely solely on the sender having gated correctly.


SwiftData — Group Entity

@Model Group
├── encryptedID: Data?             // UUID, encrypted
├── encryptedName: Data?           // display name
├── realMemberSlots: [Data]        // depth 0 (real layer) — always 32 entries
├── duressMemberSlots: [Data]      // depth 1 (first duress layer) — always 32 entries
├── deeperMemberSlots: [[Data]]    // depths 2...31 — 30 independent arrays of 32 entries each
└── encryptedCreatedAt: Data?      // second-precision TimeInterval

All fields encrypted under the local DB key. persistentModelID is the only plaintext identifier and reveals nothing about the group.

Multi-Layer Membership

Changed in v1.9.1. Each of the 32 depths (0 = real, 1...31 = duress) maintains its own completely independent 32-slot member array, matching the multi-layer decoy model Secure Mode already provides for individual contacts via visibleThroughDepth (see Secure Mode). Entering a deeper duress PIN shows a genuinely different decoy member set than any shallower depth.

Prior to v1.9.1, every duress depth beyond the first shared a single duressMemberSlots array — a group's decoy membership was identical at every coercion depth, which broke the documented multi-layer guarantee for group membership specifically (Bug 73). Individual contact visibility was never affected by this; only group membership was.

Groups created before v1.9.1 start with an empty deeperMemberSlots and are padded to full size the first time their membership is next edited, and — since v1.9.1 — also eagerly for every stored group at app launch, so a group nobody edits doesn't keep a distinctly smaller row shape than every fully-padded group indefinitely.

Fixed-Slot Model

Each depth's array always contains exactly 32 entries. Real member slots hold AES-GCM(padded contactIdentifier) — identifiers are zero-padded to 128 bytes before encryption, producing a fixed 156-byte ciphertext (12-byte nonce + 128-byte data + 16-byte tag). Unused slots hold 156 cryptographically random bytes, indistinguishable in size and appearance.

On every add or remove at any depth, every depth's array is fully recomputed with fresh nonces — not just the depth that changed. A database diff between any two snapshots shows every depth's slots changed, regardless of which single depth's membership was actually edited, so the touched depth itself is never identifiable from the diff alone.

Group.members(atDepth:) / addMember(_:atDepth:) / removeMember(_:atDepth:) address any of the 32 depths directly; the active depth for a given operation is Manager.Security.currentDepth.

Reclassification Cleanup

Reclassifying a contact as sensitive from depth 0 (the one depth guaranteed not to be under coercion — duress PINs never route there) removes that specific contact from every group's duress-depth (1...31) membership, leaving every other member and the real layer untouched (Group.purgeMembersFromDuressDepths(_:)). Every depth is still re-encrypted uniformly regardless of which depths actually changed, for the same diff-safety reason as above. Reclassification performed from any other depth never triggers this cleanup — that depth's non-coercion status can't be guaranteed, and deeper depths may hold deliberately pre-built decoy content for a future coercion scenario that a shallower action shouldn't be able to destroy.


Version Gating

A contact can be added to a group only if their maxBundleVersion reflects app version ≥ 1.9.0:

resolveTargetVersion(for: contact).supportsGroups   // true for .groupCapable and .groupShardCapable

A contact is additionally eligible to receive real shard content over group sends (see "Vault Shard Distribution Over Groups") only if:

resolveTargetVersion(for: contact) == .groupShardCapable   // app version ≥ 1.9.1

Both are proven by receipt, not self-reported — a contact who has never sent a bundle has maxBundleVersion == nil and satisfies neither.


Forensic Trace Properties

  • No plaintext fields on any group record
  • 32 independent depths × 32 slots × 156 bytes each per group — member count at any depth is not derivable without the local DB key
  • Full recompute with fresh nonces on every write, across every depth — no diff attack can identify which slot or depth changed
  • deeperMemberSlots is padded eagerly at app launch, not only lazily on first edit, so an inactive group can't be singled out by row shape alone (see "Multi-Layer Membership")
  • Reclassification cleanup removes only the specific contact just hidden from duress-depth membership, not the entire membership set, while still re-encrypting every depth uniformly so the touched depth isn't identifiable
  • GroupEnvelope.blind is per-bundle and derived from groupID via HMAC — bundles for the same group produce distinct values across sends; an interceptor cannot cluster them without knowing groupID, which is only inside the encrypted payload
  • Per-recipient shard-content arrays are tier-padded to a shared size across every recipient in a send — wrappedPayload length does not reveal which members are shard-eligible
  • encryptedCreatedAt stored at second precision — millisecond correlation attacks not possible
  • Hard deletion only — no tombstones
  • Accepted: bundle recipient count (N) and bundle size are visible to anyone holding the bundle — inherent to the one-bundle design

Clone this wiki locally