-
Notifications
You must be signed in to change notification settings - Fork 0
Group Messaging Technical
See Group Messaging for the non-technical overview.
case groupOld builds decode .group as .unsupported → BundleError.unsupportedMode → "requires a newer version of Occulta."
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
├── 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
├── 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.
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.
- Generate random 256-bit
sessionKey - Generate
blindNonce(16 random bytes); computeblind = HMAC-SHA256(key: groupID.rawBytes, msg: blindNonce) - Encode
SealedPayload(message +groupIDinside;prekeyBatch = nil) -
AES.GCM.seal(payload, using: sessionKey, authenticating: outerAAD)→ciphertext - For each member in the active layer:
- If prekey available: generate ephemeral P-256 keypair, derive
wrappingKeyvia ECDH + ML-KEM, consume prekey, setmode = .forwardSecret(or.forwardSecretNoPQwithout ML-KEM material) - If no prekey: derive
wrappingKeyvia long-term identity ECDH + ML-KEM, setmode = .longTermFallback(or.longTermNoPQ) - If eligible for shard content (
.groupShardCapable, ML-KEM material present, and a prekey was available for this specific send) — resolve realshardOperations/custodyManifest/expectedShardsviaShardCustodyManager; otherwise leave all three empty and markshardMetadataAttempted = false - Build
RecipientPayload(sessionKey:, prekeyBatch: batchIfNeeded(for:), ...)
- If prekey available: generate ephemeral P-256 keypair, derive
- 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, sowrappedPayloadlength never reveals which members are shard-eligible - For each member:
AES.GCM.seal(RecipientPayload, using: wrappingKey, authenticating: blind)→wrappedPayload; assembleRecipient(secrecyContext, wrappedPayload) - Assemble
GroupEnvelope(version: 1, blind, blindNonce, recipients), attach to bundle, serialize
- Decode bundle —
group != nil→ take group path; checkGroupEnvelope.version == 1, else reject; reject ifrecipients.count > Group.slotCount - For each
Recipiententry (trial-decryption):- Derive candidate
wrappingKeyfromsecrecyContext.mode - Attempt
AES.GCM.open(wrappedPayload, authenticating: blind)— first success is our slot
- Derive candidate
- If no slot opened → throw
GroupDecryptError.recipientSlotNotFound - Decode
RecipientPayloadfrom the opened bytes - If
RecipientPayload.prekeyBatch != nil→ store batch for the sender - De-pad this recipient's own shard content: real
shardOperationsentries are filtered bykind != .unsupported;custodyManifest/expectedShardsare truncated to their real-count fields only ifshardMetadataAttemptedis 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 -
AES.GCM.open(ciphertext, authenticating: outerAAD, using: sessionKey)→SealedPayload - Read
SealedPayload.groupID— if nil, throwGroupDecryptError.missingGroupID - Verify
SealedPayload.senderProof == HMAC-SHA256(key: sessionKey, msg: senderPublicKey)
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
maxBundleVersionresolves 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.
@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 TimeIntervalAll fields encrypted under the local DB key. persistentModelID is the only plaintext identifier and reveals nothing about the group.
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.
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.
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.
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 .groupShardCapableA 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.1Both are proven by receipt, not self-reported — a contact who has never sent a bundle has maxBundleVersion == nil and satisfies neither.
- 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
-
deeperMemberSlotsis 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.blindis per-bundle and derived fromgroupIDvia HMAC — bundles for the same group produce distinct values across sends; an interceptor cannot cluster them without knowinggroupID, 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 —
wrappedPayloadlength does not reveal which members are shard-eligible -
encryptedCreatedAtstored 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