Seal a group that predates sealing, in place

This is how the plaintext rows actually go away. Sealing was additive —
the relay has no group key and cannot seal anything itself — so a group
made before §8.9b keeps its readable `gid -> user_id` rows until its
owner's client upgrades it. Production has seven such rows.

Creating a sealed group at an existing gid now upgrades it: the blob and
owner key are written, `owner_user_id` is zeroed, and the plaintext
membership is deleted IN THE SAME TRANSACTION. Deleting it earlier would
destroy a roster the clients still need to fan out with; deleting it in a
later pass would leave a window where both exist.

Two guards, because the upgrade path opens a door that has to stay shut:

- Only the group's owner may seal it. Otherwise anybody who learned a gid
  could seal somebody else's group under their own key and own it.
- An already-sealed group can never be re-created, or the same trick works
  a second time against a group that had already protected itself.

Both negative-checked: relaxing the owner test fails two assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 09:23:29 -04:00
co-authored by Claude Opus 4.8
parent 4c21698d7e
commit c832b1c989
2 changed files with 110 additions and 8 deletions
+27 -8
View File
@@ -42,7 +42,8 @@ extension Routes {
// MARK: - POST /e2e/groups/sealed
func createSealedGroup(_ request: Request, body: Data, now: Int) async throws -> Response {
_ = try await authenticate(request, body: body, path: "/e2e/groups/sealed", now: now)
let caller = try await authenticate(
request, body: body, path: "/e2e/groups/sealed", now: now)
let input = try decodeBody(SealGroupRequest.self, from: body)
let tags = Set(input.tags.compactMap(validTag))
@@ -62,8 +63,20 @@ extension Routes {
guard isValidAttachmentToken(gid) else {
throw WireError.badRequest("Malformed group id.")
}
guard try await Group.find(gid, on: request.db) == nil else {
throw WireError.badRequest("That group id is already in use.")
// 🔴 **An UPGRADE is allowed; a takeover is not.** A group that already
// exists may be sealed in place — that is how the plaintext rows for
// groups made before §8.9b ever go away — but only if it is still
// plaintext AND the caller is its owner. A sealed group can never be
// re-created, because that would let anybody who learned a gid replace
// its owner key and take it.
let existing = try await Group.find(gid, on: request.db)
if let existing {
guard !existing.isSealed else {
throw WireError.badRequest("That group id is already in use.")
}
guard existing.ownerUserID == caller.userID else {
throw WireError.forbidden("Only the group owner can seal it.")
}
}
// 🔴 Signed BEFORE anything is written. The signature is what ties this
@@ -74,16 +87,22 @@ extension Routes {
else { throw WireError.forbidden("Roster signature did not verify.") }
try await request.db.transaction { db in
let group = Group()
let group = existing ?? Group()
group.id = gid
// ⚠️ Still written, and still zero. The column is NOT NULL from the
// original schema and older clients read it; a sealed group has no
// owning account to name, so it names none.
// ⚠️ Zeroed. The column is NOT NULL from the original schema and
// older clients read it; a sealed group has no owning account to
// name, so it names none — and on an upgrade this is the moment the
// owner's account id stops being recorded.
group.ownerUserID = 0
group.ownerKey = input.owner_key
group.rosterBlob = input.roster_blob
group.rosterVersion = 1
try await group.create(on: db)
if existing == nil { try await group.create(on: db) } else { try await group.update(on: db) }
// 🔴 **The plaintext membership goes here, and only here.** Deleting
// it before the blob was written would destroy a roster the clients
// still need to fan out with; deleting it in a later pass would
// leave a window where both exist. Same transaction, or not at all.
try await GroupMember.query(on: db).filter(\.$gid == gid).delete()
for tag in tags.sorted() {
let row = GroupTag()
row.gid = gid; row.tag = tag
@@ -309,6 +309,89 @@ struct SealedGroupTests {
}
}
// MARK: - Upgrading a group that predates sealing
/// 🔴 **This is how the plaintext rows actually go away.** Sealing was
/// additive: the relay has no group key and cannot seal anything itself, so
/// a group made before §8.9b keeps its readable `gid -> user_id` rows until
/// its owner's client upgrades it. Production had seven such rows.
@Test("sealing an existing group deletes its plaintext membership")
func upgradingDeletesPlaintextRows() async throws {
try await withRelay { h in
let alice = try await member(h, user: 60, device: "aaaa-0060")
let bob = try await member(h, user: 61, device: "bbbb-0061")
// A group as it looked before sealing.
let gid = randomHexToken()
let legacy = Group()
legacy.id = gid
legacy.ownerUserID = alice.userID
try await legacy.create(on: h.db)
for uid in [alice.userID, bob.userID] {
let m = GroupMember(); m.gid = gid; m.userID = uid
try await m.create(on: h.db)
}
#expect(try await GroupMember.query(on: h.db).filter(\.$gid == gid).count() == 2)
let owner = Owner()
let res = try await create(h, as: alice, gid: gid, owner: owner,
blob: blob("sealed roster"), tags: [tag(), tag()])
#expect(res.status == .ok)
#expect(try await GroupMember.query(on: h.db).filter(\.$gid == gid).count() == 0,
"the readable membership rows survived the upgrade")
let group = try #require(try await Group.find(gid, on: h.db))
#expect(group.isSealed)
#expect(group.ownerUserID == 0, "the owner's account id survived the upgrade")
}
}
/// ⚠️ Only the owner. Otherwise anybody who learned a gid could seal
/// somebody else's group under their own key and own it.
@Test("a non-owner cannot seal an existing group")
func onlyTheOwnerMayUpgrade() async throws {
try await withRelay { h in
let alice = try await member(h, user: 62, device: "aaaa-0062")
let mallory = try await member(h, user: 63, device: "bbbb-0063")
let gid = randomHexToken()
let legacy = Group()
legacy.id = gid
legacy.ownerUserID = alice.userID
try await legacy.create(on: h.db)
await #expect(throws: WireError.self) {
_ = try await self.create(h, as: mallory, gid: gid, owner: Owner(),
blob: self.blob("mine now"), tags: [self.tag()])
}
let group = try #require(try await Group.find(gid, on: h.db))
#expect(!group.isSealed, "a stranger sealed somebody else's group")
}
}
/// 🔴 A sealed group can never be re-created. Allowing it would let anyone
/// who learned a gid replace the owner key and take the group — the upgrade
/// path opened exactly that door, and this is what keeps it shut.
@Test("an already-sealed group cannot be re-created")
func sealedGroupsCannotBeTakenOver() async throws {
try await withRelay { h in
let alice = try await member(h, user: 64, device: "aaaa-0064")
let mallory = try await member(h, user: 65, device: "bbbb-0065")
let owner = Owner()
let gid = randomHexToken()
try await create(h, as: alice, gid: gid, owner: owner,
blob: blob("roster"), tags: [tag()])
let impostor = Owner()
await #expect(throws: WireError.self) {
_ = try await self.create(h, as: mallory, gid: gid, owner: impostor,
blob: self.blob("mine now"), tags: [self.tag()])
}
let group = try #require(try await Group.find(gid, on: h.db))
#expect(group.ownerKey == owner.key, "the owner key was replaced")
}
}
// MARK: - Shape
@Test("a tag that is not 32 bytes is refused")