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:
co-authored by
Claude Opus 4.8
parent
4c21698d7e
commit
c832b1c989
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user