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>
This commit is contained in:
jack
2026-07-06 21:17:15 +02:00
co-authored by Claude Fable 5
parent 7ad19ab4c3
commit ccccb2dd8c
6 changed files with 191 additions and 35 deletions
+73 -1
View File
@@ -142,7 +142,7 @@ struct GroupProtocolTests {
Issue.record("roster should encode")
return
}
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob)
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name)
let payload = GroupStatePayload(
groupID: groupID,
name: group.name,
@@ -299,4 +299,76 @@ struct GroupProtocolTests {
#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)
}
}