Files
bitchat/bitchatTests/Services/GroupProtocolTests.swift
81a10f73f0 Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Private groups: creator-managed encrypted group chat over the mesh

Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs:

Protocol
- MessageType.groupMessage = 0x25: broadcast packets with a cleartext
  16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as
  AEAD AAD), inner Ed25519 sender signature over
  "bitchat-group-msg-v1"|groupID|messageID|timestamp|content
- NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07:
  creator-signed group state (key, epoch, roster) 1:1 over Noise; signature
  over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise
  session peer must BE the creator
- SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens
  1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients
  ignore unknown bits and answer with types they know
- PeerCapabilities.localSupported now advertises .groups

Storage
- GroupStore: symmetric keys in the keychain, roster/name/epoch as
  protected JSON in Application Support; wiped in panicClearAllData()

Behavior
- Non-members relay 0x25 like any broadcast but cannot read it; group
  messages join gossip-sync backfill with the public-message window
- Receivers drop wrong-epoch envelopes, bad sender signatures, and
  senders missing from the creator-signed roster
- Fire-and-flood delivery (no per-member acks in v1)

UI
- Groups open as chat windows through the private-chat sheet (virtual
  "group_" peer IDs); groups section in the people sheet; /group
  create/invite/remove/leave/list commands; invitees get a system message
  + notification and the group appears in their people sheet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes

Addresses the Codex review and adversarial-review findings on #1383:

- TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to
  65535 and truncating, so an oversize group message fails to seal and
  surfaces send_failed rather than shipping ciphertext recipients drop.
- Roster nicknames truncate on a Character boundary (never mid-scalar), so a
  multi-byte nickname can no longer make the whole signed roster undecodable.
- Invites now bump the epoch (rotate the key) like removals, giving every
  roster change a strictly-increasing epoch so out-of-order invite states no
  longer last-writer-wins a just-added member back out.
- Removing a member now sends them a creator-signed roster-without-them under
  a throwaway all-zero key (never the rotated key), so their client
  deactivates the group and surfaces "removed" instead of going silently dark.
- /block is enforced in the group receive path: a blocked member's messages
  are dropped from display and notifications, consistent with every other
  inbound path.
- Media affordances are disabled in group chats (both computed sites) so the
  composer can't strand a media placeholder that never sends; media-in-groups
  is a documented v2 item.
- Creator signature now covers the group name and the sender signature covers
  the epoch (wire-format-affecting; needs Android parity before ship).
- Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered
  receipts can never leak into group conversations under a future refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:59:55 +02:00

375 lines
14 KiB
Swift

//
// GroupProtocolTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct GroupProtocolTests {
// MARK: - Fixtures
/// Deterministic member identity: an Ed25519 keypair plus the 64-hex
/// fingerprint the roster pins.
private struct TestIdentity {
let signingKey: Curve25519.Signing.PrivateKey
let fingerprint: String
init(seed: UInt8) {
signingKey = Curve25519.Signing.PrivateKey()
fingerprint = Data(repeating: seed, count: 32).hexEncodedString()
}
var member: GroupMember {
GroupMember(
fingerprint: fingerprint,
signingKey: signingKey.publicKey.rawRepresentation,
nickname: "peer-\(fingerprint.prefix(4))"
)
}
func sign(_ data: Data) -> Data? {
try? signingKey.signature(for: data)
}
}
private let creator = TestIdentity(seed: 0xC1)
private let member = TestIdentity(seed: 0xA2)
private let outsider = TestIdentity(seed: 0xE3)
private let groupID = Data((0..<16).map { UInt8($0) })
private let key = Data(repeating: 0x42, count: 32)
private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup {
BitchatGroup(
groupID: groupID,
name: "trail crew",
epoch: epoch,
members: [creator.member, member.member] + extraMembers,
creatorFingerprint: creator.fingerprint
)
}
// MARK: - State payload (invite / key update)
@Test func statePayloadRoundTripAndSignatureVerify() throws {
let group = makeGroup()
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
let encoded = try #require(payload.encode())
let decoded = try #require(GroupStatePayload.decode(encoded))
#expect(decoded == payload)
#expect(decoded.groupID == groupID)
#expect(decoded.name == "trail crew")
#expect(decoded.key == key)
#expect(decoded.epoch == 1)
#expect(decoded.members == group.members)
#expect(decoded.creatorFingerprint == creator.fingerprint)
#expect(decoded.verifyCreatorSignature())
#expect(decoded.asGroup == group)
}
@Test func forgedCreatorSignatureIsRejected() throws {
let group = makeGroup()
// Signed by a member who is in the roster but is NOT the creator.
let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign))
#expect(!forged.verifyCreatorSignature())
// An outsider signing is equally rejected.
let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign))
#expect(!outsiderForged.verifyCreatorSignature())
}
@Test func tamperedStateFailsSignature() throws {
let group = makeGroup()
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
// Bumping the epoch invalidates the signature.
let epochTampered = GroupStatePayload(
groupID: payload.groupID,
name: payload.name,
key: payload.key,
epoch: payload.epoch + 1,
members: payload.members,
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!epochTampered.verifyCreatorSignature())
// So does swapping the key.
let keyTampered = GroupStatePayload(
groupID: payload.groupID,
name: payload.name,
key: Data(repeating: 0x99, count: 32),
epoch: payload.epoch,
members: payload.members,
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!keyTampered.verifyCreatorSignature())
// And so does adding a member to the roster.
let rosterTampered = GroupStatePayload(
groupID: payload.groupID,
name: payload.name,
key: payload.key,
epoch: payload.epoch,
members: payload.members + [outsider.member],
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!rosterTampered.verifyCreatorSignature())
}
@Test func creatorMissingFromRosterIsRejected() throws {
// State claiming a creator whose fingerprint is not in the roster has
// no key to verify against and must fail closed.
let group = BitchatGroup(
groupID: groupID,
name: "orphan",
epoch: 1,
members: [member.member],
creatorFingerprint: creator.fingerprint
)
guard let rosterBlob = GroupRosterCoding.encode(group.members) else {
Issue.record("roster should encode")
return
}
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name)
let payload = GroupStatePayload(
groupID: groupID,
name: group.name,
key: key,
epoch: 1,
members: group.members,
creatorFingerprint: creator.fingerprint,
signature: creator.sign(content) ?? Data()
)
#expect(!payload.verifyCreatorSignature())
}
@Test func rosterCapIsEnforcedOnTheWire() {
// 17 members cannot be encoded (hard cap is 16)…
let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member }
#expect(GroupRosterCoding.encode(seventeen) == nil)
// …and a hand-built blob claiming 17 members fails to decode.
let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member }
guard var blob = GroupRosterCoding.encode(sixteen) else {
Issue.record("16-member roster should encode")
return
}
#expect(GroupRosterCoding.decode(blob)?.count == 16)
blob[blob.startIndex] = 17
#expect(GroupRosterCoding.decode(blob) == nil)
}
// MARK: - Message seal / open
@Test func messageRoundTrip() throws {
let group = makeGroup()
let timestampMs: UInt64 = 1_750_000_000_000
let sealed = try GroupCrypto.sealMessage(
content: "summit at noon",
messageID: "msg-1",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: timestampMs,
groupID: groupID,
epoch: group.epoch,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
#expect(envelope.groupID == groupID)
#expect(envelope.epoch == group.epoch)
let plaintext = try GroupCrypto.openMessage(envelope, key: key)
#expect(plaintext.messageID == "msg-1")
#expect(plaintext.content == "summit at noon")
#expect(plaintext.senderNickname == "alice")
#expect(plaintext.timestampMs == timestampMs)
#expect(plaintext.senderSigningKey == member.member.signingKey)
// The roster resolves the sender; an outsider's key would not.
#expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil)
#expect(group.member(withSigningKey: outsider.member.signingKey) == nil)
}
@Test func wrongKeyFailsToDecrypt() throws {
let sealed = try GroupCrypto.sealMessage(
content: "hi",
messageID: "msg-2",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
#expect(throws: GroupCryptoError.decryptionFailed) {
_ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32))
}
}
@Test func epochIsBoundIntoTheCiphertext() throws {
// Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD:
// a rotated-out member cannot replay old ciphertext into a new epoch.
let sealed = try GroupCrypto.sealMessage(
content: "hi",
messageID: "msg-3",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
let relabeled = GroupMessageEnvelope(
groupID: envelope.groupID,
epoch: 2,
nonce: envelope.nonce,
ciphertext: envelope.ciphertext
)
#expect(throws: GroupCryptoError.decryptionFailed) {
_ = try GroupCrypto.openMessage(relabeled, key: key)
}
}
@Test func badSenderSignatureIsRejected() throws {
// A key-holder who signs with a key other than the one they claim
// (or garbage) is dropped even though decryption succeeds.
let sealed = try GroupCrypto.sealMessage(
content: "spoof",
messageID: "msg-4",
senderNickname: "mallory",
senderSigningKey: member.member.signingKey, // claims member's key…
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: outsider.sign // …but signs with the outsider's
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
#expect(throws: GroupCryptoError.badSenderSignature) {
_ = try GroupCrypto.openMessage(envelope, key: key)
}
}
@Test func tamperedCiphertextFailsToOpen() throws {
let sealed = try GroupCrypto.sealMessage(
content: "hi",
messageID: "msg-5",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
var flipped = envelope.ciphertext
flipped[flipped.startIndex] ^= 0x01
let tampered = GroupMessageEnvelope(
groupID: envelope.groupID,
epoch: envelope.epoch,
nonce: envelope.nonce,
ciphertext: flipped
)
#expect(throws: GroupCryptoError.decryptionFailed) {
_ = try GroupCrypto.openMessage(tampered, key: key)
}
}
@Test func malformedEnvelopesAreRejected() {
#expect(GroupMessageEnvelope.decode(Data()) == nil)
#expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil)
#expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil)
}
// MARK: - Oversize / UTF-8 safety (Codex findings)
@Test func oversizeMessageContentFailsToSealInsteadOfTruncating() {
// A content whose UTF-8 exceeds the 16-bit TLV length must fail to
// seal (surfacing send_failed) rather than silently truncate into a
// ciphertext recipients would drop.
let oversize = String(repeating: "a", count: 70_000)
#expect(throws: (any Error).self) {
_ = try GroupCrypto.sealMessage(
content: oversize,
messageID: "big",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
}
}
@Test func multiByteNicknameTruncatesOnScalarBoundary() throws {
// 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split
// the 21st scalar and make the roster undecodable. Truncation must
// land on a Character boundary so the blob round-trips.
let euros = String(repeating: "€", count: 40)
let wide = GroupMember(
fingerprint: creator.fingerprint,
signingKey: creator.member.signingKey,
nickname: euros
)
let blob = try #require(GroupRosterCoding.encode([wide]))
let decoded = try #require(GroupRosterCoding.decode(blob))
#expect(decoded.count == 1)
#expect(Data(decoded[0].nickname.utf8).count <= 64)
#expect(decoded[0].nickname.allSatisfy { $0 == "€" })
#expect(!decoded[0].nickname.isEmpty)
}
// MARK: - Signable-bytes forward-proofing
@Test func creatorSignatureCoversName() throws {
let group = makeGroup()
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
#expect(payload.verifyCreatorSignature())
// Swapping only the display name must invalidate the creator signature.
let renamed = GroupStatePayload(
groupID: payload.groupID,
name: "totally different name",
key: payload.key,
epoch: payload.epoch,
members: payload.members,
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!renamed.verifyCreatorSignature())
}
@Test func messageSignatureCoversEpoch() {
// The signed bytes differ by epoch, so a signature captured at one
// epoch cannot verify when re-sealed under a later epoch key.
let atEpoch1 = GroupCrypto.messageSigningContent(
groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x"
)
let atEpoch2 = GroupCrypto.messageSigningContent(
groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x"
)
#expect(atEpoch1 != atEpoch2)
}
}