How Bitchat Implements Private Groups with Creator-Signed State and Key Rotation
Bitchat implements private groups as lightweight on-device data structures where the creator cryptographically signs all roster and key updates using Ed25519, while a monotonic epoch counter enables secure symmetric key rotation.
Bitchat, developed by permissionlesstech, treats group metadata as local JSON files while protecting 32-byte symmetric keys in the device keychain. This architecture ensures that only the original creator can modify group membership or rotate encryption keys, creating a tamper-evident audit trail for all state changes.
Core Data Model in GroupProtocol.swift
The BitchatGroup struct defined in bitchat/Services/Groups/GroupProtocol.swift represents the authoritative state of a private group.
struct BitchatGroup: Codable, Equatable {
static let maxMembers = 16
static let groupIDLength = 16
static let keyLength = 32
let groupID: Data
var name: String
var epoch: UInt32
var members: [GroupMember]
let creatorFingerprint: String
…
}
Key fields include:
groupID– A 16-byte random identifier transmitted in clear text on every group-message packet.epoch– A monotonically increasing counter that increments with each key rotation, preventing rollback attacks.members– An array ofGroupMemberstructs capped at 16 entries, each containing:fingerprint: SHA-256 hash of the member's Noise static key (64 hex characters).signingKey: Ed25519 public key for creator-signature verification.nickname: Optional display name truncated to 64 bytes.
creatorFingerprint– The fingerprint of the sole identity authorized to sign group state transitions.
Secure Persistence with GroupStore.swift
The GroupStore class in bitchat/Services/Groups/GroupStore.swift handles atomic persistence of metadata and keychain storage of symmetric keys.
Group Creation
The createGroup(named:creator:) method generates a cryptographically random 16-byte groupID and 32-byte symmetric key, storing the key in the keychain via keychain.saveIdentityKey while persisting metadata as JSON in Application Support.
Key Retrieval
The key(forGroupID:) method retrieves the current epoch's key from the keychain using the per-group entry format groupKey-<hex>.
Roster Updates
updateRoster(groupID:members:) modifies the member list while preserving the existing key and epoch, useful for nickname changes without security implications.
Key Rotation
rotateKey(groupID:members:) performs atomic key rotation by generating a fresh random key, incrementing epoch, and calling upsert to replace both the stored group metadata and key simultaneously.
func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? {
guard let existing = group(withID: groupID),
let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
var rotated = existing
rotated.epoch = existing.epoch &+ 1
rotated.members = members
guard upsert(rotated, key: newKey) else { return nil }
return (rotated, newKey)
}
Creator-Signed State Verification
All invite messages (type 0x06) and key-update messages (type 0x07) carry a GroupStatePayload that must bear the creator's Ed25519 signature.
Constructing the Signed Payload
The GroupStatePayload.makeSigned method builds the payload through deterministic binary encoding:
-
Encode the roster using
GroupRosterCoding.encodeto produce a canonical binary blob. -
Construct the signing content by concatenating:
- The ASCII string
"bitchat-group-v1" - The 16-byte
groupID - The 4-byte
epochcounter - SHA-256 hash of the symmetric
key - SHA-256 hash of the roster blob
- SHA-256 hash of the group
name
- The ASCII string
-
Sign the resulting buffer using the creator's private key provided via the
signclosure.
static func makeSigned(
group: BitchatGroup,
key: Data,
sign: (Data) -> Data?
) -> GroupStatePayload? {
guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil }
let content = signingContent(groupID: group.groupID,
epoch: group.epoch,
key: key,
rosterBlob: rosterBlob,
name: group.name)
guard let signature = sign(content) else { return nil }
return GroupStatePayload(..., signature: signature)
}
Verifying Creator Signatures
Receivers validate incoming state updates using verifyCreatorSignature(), which recomputes the signing content, extracts the creator's public key from the roster, and validates the signature via GroupCrypto.verify. The verification enforces that the creator fingerprint appears in the roster and that the member count does not exceed the 16-member cap.
func verifyCreatorSignature() -> Bool {
guard let creator = members.first(where: { $0.fingerprint == creatorFingerprint }),
let rosterBlob = GroupRosterCoding.encode(members) else { return false }
let content = GroupStatePayload.signingContent(groupID: groupID,
epoch: epoch,
key: key,
rosterBlob: rosterBlob,
name: name)
return GroupCrypto.verify(signature: signature,
for: content,
publicKey: creator.signingKey)
}
Deterministic Roster Encoding
The GroupRosterCoding utility ensures canonical serialization for signature verification. The binary format follows:
[count: UInt8] (fingerprint[32] signingKey[32] nicknameLength[UInt8] nicknameBytes) * count
- Fingerprint: 32-byte binary representation of the SHA-256 hash.
- Signing Key: 32-byte Ed25519 public key.
- Nickname: Truncated to ≤ 64 bytes on valid UTF-8 character boundaries to prevent malleability attacks.
Any alteration to member order, addition of new members, or modification of nicknames produces a different roster blob, invalidating the creator's signature and protecting against unauthorized state changes.
End-to-End Group Lifecycle
1. Creating a Group
let store = GroupStore(keychain: myKeychain)
let creator = GroupMember(fingerprint: creatorFP,
signingKey: creatorSigningKey,
nickname: "Alice")
guard let newGroup = store.createGroup(named: "Dev Team", creator: creator) else {
fatalError("Group creation failed")
}
print("Created group \(newGroup.name) with ID \(newGroup.groupID.hexEncodedString())")
2. Building Creator-Signed Invites
let key = try store.key(forGroupID: newGroup.groupID)! // current epoch key
let payload = GroupStatePayload.makeSigned(
group: newGroup,
key: key,
sign: { data in
// Use the creator's Ed25519 private key
creatorSigningKey.sign(data: data)
})
guard let invite = payload?.encode() else { fatalError("Failed to encode payload") }
3. Verifying and Storing Incoming State
guard let received = GroupStatePayload.decode(inviteData),
received.verifyCreatorSignature(),
let key = received.key else {
fatalError("Invalid or unauthenticated invite")
}
let group = received.asGroup
store.upsert(group, key: key) // persist metadata + key
4. Rotating Keys After Member Removal
let updatedMembers = group.members.filter { $0.fingerprint != leavingMemberFP }
guard let rotation = store.rotateKey(groupID: group.groupID,
members: updatedMembers) else {
fatalError("Key rotation failed")
}
let rotationPayload = GroupStatePayload.makeSigned(
group: rotation.group,
key: rotation.key,
sign: { data in creatorSigningKey.sign(data: data) })
Summary
- Bitchat groups use a 16-byte random
groupIDand store metadata locally in JSON while protecting 32-byte symmetric keys in the device keychain. - Creator authority is enforced through Ed25519 signatures over deterministic binary encodings of group state, preventing unauthorized membership changes.
- Epoch counters enable secure key rotation; each rotation increments the epoch and generates a fresh symmetric key distributed via signed
0x07messages. - Roster integrity relies on
GroupRosterCoding, which produces canonical binary blobs that hash into the signed content, ensuring any tampering invalidates the signature. - Member limits are hard-coded to 16 participants, with each member identified by their Noise static key fingerprint and Ed25519 signing key.
Frequently Asked Questions
How does Bitchat prevent unauthorized users from adding members to a group?
Only the creator's Ed25519 private key can generate valid signatures for GroupStatePayload structures. The verifyCreatorSignature() method in GroupProtocol.swift ensures that the fingerprint embedded in the payload matches the signing key in the roster, and that the signature validates against the recomputed hash of the group state. Without access to the creator's private key, attackers cannot forge valid invite or key-update messages.
What happens to the group key when a member leaves?
The creator calls rotateKey(groupID:members:) in GroupStore.swift, which generates a cryptographically random 32-byte replacement key, increments the epoch counter, and atomically updates both the keychain entry and group metadata. The creator then distributes this new key via a signed key-update message (type 0x07), ensuring former members cannot decrypt future communications.
Why does Bitchat use an epoch counter instead of timestamps?
The epoch field serves as a monotonic sequence number that prevents rollback attacks. Unlike timestamps, which rely on synchronized clocks and can be manipulated, the epoch increments strictly within the rotateKey function and is cryptographically bound to the signed payload. Receivers reject any message bearing an epoch lower than their current stored value, ensuring forward secrecy and state consistency across the group.
How is the roster encoded to ensure signature verification works across different devices?
GroupRosterCoding.encode produces a deterministic binary blob using a specific TLV-like structure: a count byte followed by fixed-length fingerprint and signing key fields, plus length-prefixed nickname bytes. Nicknames are truncated to valid UTF-8 boundaries at 64 bytes. This canonical encoding ensures that identical rosters produce identical byte sequences on all platforms, allowing signature verification to succeed regardless of the device's JSON serialization preferences.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →