Seal the group roster (§8.9b)
The membership rows were a readable social graph: `gid -> user_id`, seven of them on production, saying exactly who was in which group. They are now an opaque 32-byte tag per member plus a roster encrypted under a group key the relay never sees. No account id appears in any group table. 🔴 **This defeats a database dump. It does not defeat the operator.** Every request is authenticated, so a running relay still sees which account asks about which group and can rebuild the graph from that. The claim it earns is "a dump does not reveal your groups" and no more; the spec says so in those words and /metadata will too. What made it possible is that the relay never routed group messages — fan-out has always been client-side and pairwise, so the roster was never a table the server had to read. Two things fell out of writing the tests: - **The client has to choose the gid.** The signature binds it, so a signed roster cannot be replayed into another group — and a relay-minted id cannot be signed over, because the client does not know it when it signs. The first draft generated it server-side and was unimplementable. - **A version counter is not optional.** A roster signature stays valid for ever, so an old roster can be posted back to re-add somebody who was just removed, with a signature that genuinely verifies. Versions must strictly increase; the guard is negative-checked. Additive and deletes nothing: the relay has no group key and cannot seal existing groups itself. Plaintext rows survive until the owner's client upgrades the group, and go in the same transaction that writes the blob. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f80cc3cee3
commit
4c21698d7e
@@ -872,6 +872,29 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
return try await r.leaveGroup(req, gid: gid, body: body(of: req), now: unixNow())
|
||||
}
|
||||
|
||||
// §8.9b — the same registry with the roster sealed. Separate routes
|
||||
// rather than a flag on the old ones: a group is plaintext or sealed and
|
||||
// never both, and one route trying to be either is where the check for
|
||||
// which kind it is gets forgotten.
|
||||
e2e.post("groups", "sealed") { req in
|
||||
try await r.createSealedGroup(req, body: body(of: req), now: unixNow())
|
||||
}
|
||||
e2e.post("groups", "fetch") { req in
|
||||
try await r.fetchSealedGroups(req, body: body(of: req), now: unixNow())
|
||||
}
|
||||
e2e.post("groups", ":gid", "roster") { req in
|
||||
guard let gid = req.parameters.get("gid"), isValidAttachmentToken(gid) else {
|
||||
throw WireError.badRequest("Malformed group id.")
|
||||
}
|
||||
return try await r.replaceRoster(req, gid: gid, body: body(of: req), now: unixNow())
|
||||
}
|
||||
e2e.post("groups", ":gid", "leave-sealed") { req in
|
||||
guard let gid = req.parameters.get("gid"), isValidAttachmentToken(gid) else {
|
||||
throw WireError.badRequest("Malformed group id.")
|
||||
}
|
||||
return try await r.leaveSealedGroup(req, gid: gid, body: body(of: req), now: unixNow())
|
||||
}
|
||||
|
||||
// §19 — the transparency log. Unauthenticated: a log only its own
|
||||
// users can read is not a transparency log.
|
||||
e2e.get("log", "head") { req in try await r.logHead(req) }
|
||||
|
||||
@@ -451,6 +451,48 @@ struct DropGroupNames: AsyncMigration {
|
||||
func revert(on database: any Database) async throws {}
|
||||
}
|
||||
|
||||
/// Seal the group roster (§8.9b).
|
||||
///
|
||||
/// 🔴 **Additive, and it deletes nothing.** A group created before this has its
|
||||
/// membership in `group_members` as readable `gid -> user_id` rows, and the
|
||||
/// relay cannot seal them itself: it has no group key and never will. Only the
|
||||
/// owner's client can write the encrypted blob, so the plaintext rows survive
|
||||
/// until that client upgrades the group — and are deleted in the same
|
||||
/// transaction that writes the blob, never before.
|
||||
///
|
||||
/// ⚠️ That means plaintext rosters persist for unupgraded groups. The
|
||||
/// alternative is deleting membership the clients still need to fan out with,
|
||||
/// which would break group messaging outright rather than protect anybody.
|
||||
struct SealGroupRosters: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
// ⚠️ One column per `update()`. Fluent renders several `.field()` calls
|
||||
// as a single ALTER TABLE with several ADD COLUMNs, which SQLite
|
||||
// rejects outright — and the relay ships on SQLite as well as Postgres,
|
||||
// so a migration that only runs on one of them is a migration that
|
||||
// breaks every self-hosted install using the default.
|
||||
try await database.schema(Group.schema).field("owner_key", .string).update()
|
||||
try await database.schema(Group.schema).field("roster_blob", .string).update()
|
||||
try await database.schema(Group.schema).field("roster_version", .int).update()
|
||||
try await database.schema(GroupTag.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("gid", .string, .required)
|
||||
.field("tag", .string, .required)
|
||||
.unique(on: "gid", "tag")
|
||||
.create()
|
||||
// The fetch is "give me the groups for these tags", so the tag is the
|
||||
// lookup. Without this it is a scan of every tag on the relay.
|
||||
try await (database as? any SQLDatabase)?
|
||||
.raw("CREATE INDEX IF NOT EXISTS group_tags_tag ON group_tags(tag)").run()
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(GroupTag.schema).delete()
|
||||
try await database.schema(Group.schema)
|
||||
.deleteField("owner_key").deleteField("roster_blob")
|
||||
.deleteField("roster_version").update()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-use capabilities to place one sealed envelope in an inbox (§21.4).
|
||||
///
|
||||
/// ⚠️ **The unique index is on the hash alone, not on `(user_id, hash)`.** A
|
||||
@@ -565,6 +607,7 @@ enum RelayMigrations {
|
||||
TrimUnreadMetadata(), AddPQIdentity(), AddEnclavePseudonym(), AllowNullEmail(),
|
||||
AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID(),
|
||||
AddDeviceLastSeen(), AddVoipToken(), AcceptExistingContacts(),
|
||||
BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames()]
|
||||
BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames(),
|
||||
SealGroupRosters()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,18 +157,70 @@ final class Group: Model, @unchecked Sendable {
|
||||
/// was already written: removing the code that writes it would have left
|
||||
/// every existing name sitting in the table.
|
||||
@Field(key: "owner_user_id") var ownerUserID: Int
|
||||
/// 🔴 **The owner as a KEY, not an account.** `owner_user_id` says who owns
|
||||
/// this group in a column anybody reading the database can follow back to a
|
||||
/// person. A public key says the same thing to the only party that needs to
|
||||
/// check it — the relay verifying a signature — and says nothing to a dump.
|
||||
///
|
||||
/// Optional only because groups created before sealing have no key. Those
|
||||
/// keep using `owner_user_id` until their owner's client upgrades them.
|
||||
@Field(key: "owner_key") var ownerKey: String?
|
||||
/// The member list, encrypted under the group key by whoever wrote it.
|
||||
/// Opaque here, and never parsed.
|
||||
@Field(key: "roster_blob") var rosterBlob: String?
|
||||
/// 🔴 **Monotonic, and the reason is replay.** Without it a captured older
|
||||
/// roster could be posted back to re-add somebody who had been removed —
|
||||
/// the signature on it is still valid, because it was genuinely signed.
|
||||
@Field(key: "roster_version") var rosterVersion: Int?
|
||||
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
|
||||
|
||||
init() {}
|
||||
|
||||
/// True once this group has been sealed. A group is one or the other and
|
||||
/// never both: the plaintext rows are deleted in the same transaction that
|
||||
/// writes the blob.
|
||||
var isSealed: Bool { ownerKey?.isEmpty == false }
|
||||
|
||||
static let maxMembers = 32
|
||||
|
||||
/// What a signature over a roster covers.
|
||||
///
|
||||
/// Built by concatenation rather than JSON for the same reason log leaves
|
||||
/// are: three independent client implementations have to produce these
|
||||
/// bytes identically, and an encoder is free to reorder or respace.
|
||||
static func rosterCanonical(gid: String, version: Int, blob: String) -> Data {
|
||||
Data("fngroup-v1|\(gid)|\(version)|\(blob)".utf8)
|
||||
}
|
||||
|
||||
/// What the list route reports in the `name` field for clients that predate
|
||||
/// the change and would otherwise render a blank row. The same word for
|
||||
/// every group, so it discloses nothing.
|
||||
static let placeholderName = "Group"
|
||||
}
|
||||
|
||||
/// One member's opaque claim on a group.
|
||||
///
|
||||
/// 🔴 **A random token, not a user id, and that is the whole point.** The
|
||||
/// membership rows used to map `gid -> user_id`, which is a readable social
|
||||
/// graph sitting in a table: seven rows on production said exactly who was in
|
||||
/// which group. A tag says "somebody holding this may read this group's roster"
|
||||
/// and nothing more.
|
||||
///
|
||||
/// ⚠️ **It does not hide membership from a running relay.** The fetch that
|
||||
/// presents a tag is authenticated, so the server still sees which account asks
|
||||
/// about which group. This defeats a stolen dump, not the operator — see
|
||||
/// SPEC §8.9b, which says so in those words.
|
||||
final class GroupTag: Model, @unchecked Sendable {
|
||||
static let schema = "group_tags"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
@Field(key: "gid") var gid: String
|
||||
/// Base64 of 32 random bytes, chosen by the client.
|
||||
@Field(key: "tag") var tag: String
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
final class GroupMember: Model, @unchecked Sendable {
|
||||
static let schema = "group_members"
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Vapor
|
||||
|
||||
/// §8.9b — group rosters the relay cannot read.
|
||||
///
|
||||
/// 🔴 **The relay does not route group messages, and that is what makes this
|
||||
/// possible.** Fan-out is client-side and pairwise (§6.7), so the roster was
|
||||
/// never a routing table the server had to read — it was a document the members
|
||||
/// happened to keep here. Nothing below parses a member list; the blob is
|
||||
/// opaque and the access control is a random token.
|
||||
///
|
||||
/// ⚠️ **This defeats a database dump. It does not defeat the operator.** Every
|
||||
/// request here is authenticated, so a running relay still sees which account
|
||||
/// asks about which group and can rebuild the graph from access patterns. The
|
||||
/// claim it earns is "a dump does not reveal your groups", and no more than
|
||||
/// that — see the disclosure on /metadata, which says so in those words.
|
||||
extension Routes {
|
||||
|
||||
/// The tag a caller must present, or nil when they presented nothing usable.
|
||||
///
|
||||
/// ⚠️ Length-checked before it is used as a lookup key. A tag is 32 random
|
||||
/// bytes; anything else is either a client bug or somebody probing, and
|
||||
/// neither should reach the database.
|
||||
private func validTag(_ tag: String) -> String? {
|
||||
guard let raw = Data(base64Encoded: tag), raw.count == 32 else { return nil }
|
||||
return tag
|
||||
}
|
||||
|
||||
private func verifyRosterSignature(
|
||||
ownerKeyB64: String, sig: String, gid: String, version: Int, blob: String
|
||||
) -> Bool {
|
||||
guard let keyRaw = Data(base64Encoded: ownerKeyB64), keyRaw.count == 32,
|
||||
let sigRaw = Data(base64Encoded: sig),
|
||||
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: keyRaw)
|
||||
else { return false }
|
||||
return key.isValidSignature(
|
||||
sigRaw, for: Group.rosterCanonical(gid: gid, version: version, blob: blob))
|
||||
}
|
||||
|
||||
// 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 input = try decodeBody(SealGroupRequest.self, from: body)
|
||||
|
||||
let tags = Set(input.tags.compactMap(validTag))
|
||||
guard !tags.isEmpty, tags.count <= Group.maxMembers else {
|
||||
throw WireError.badRequest(
|
||||
"A sealed group needs between one and \(Group.maxMembers) member tags.")
|
||||
}
|
||||
guard !input.roster_blob.isEmpty, input.roster_blob.count <= Self.maxRosterBlob else {
|
||||
throw WireError.badRequest("Roster too large.")
|
||||
}
|
||||
|
||||
// ⚠️ The client's id, checked for shape and for collision. See the note
|
||||
// on `SealGroupRequest.gid`: a relay-minted id cannot be covered by the
|
||||
// client's signature, and dropping the gid from what is signed would let
|
||||
// a roster signed for one group be replayed into another.
|
||||
let gid = input.gid
|
||||
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.")
|
||||
}
|
||||
|
||||
// 🔴 Signed BEFORE anything is written. The signature is what ties this
|
||||
// group to an owner; a group stored without one is a group nobody can
|
||||
// ever change, because every later edit is checked against this key.
|
||||
guard verifyRosterSignature(ownerKeyB64: input.owner_key, sig: input.sig,
|
||||
gid: gid, version: 1, blob: input.roster_blob)
|
||||
else { throw WireError.forbidden("Roster signature did not verify.") }
|
||||
|
||||
try await request.db.transaction { db in
|
||||
let group = 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.
|
||||
group.ownerUserID = 0
|
||||
group.ownerKey = input.owner_key
|
||||
group.rosterBlob = input.roster_blob
|
||||
group.rosterVersion = 1
|
||||
try await group.create(on: db)
|
||||
for tag in tags.sorted() {
|
||||
let row = GroupTag()
|
||||
row.gid = gid; row.tag = tag
|
||||
try await row.create(on: db)
|
||||
}
|
||||
}
|
||||
|
||||
return try .json(SealedGroupResponse(
|
||||
ok: true, gid: gid, owner_key: input.owner_key,
|
||||
roster_blob: input.roster_blob, roster_version: 1))
|
||||
}
|
||||
|
||||
// MARK: - POST /e2e/groups/fetch
|
||||
|
||||
/// "Give me the groups these tags open."
|
||||
///
|
||||
/// 🔴 **The client never asks what groups it is in**, because the relay
|
||||
/// cannot answer that about a sealed group — there is no account anywhere in
|
||||
/// the tables. It asks by the tokens it holds, and gets back only the groups
|
||||
/// they actually open.
|
||||
func fetchSealedGroups(_ request: Request, body: Data, now: Int) async throws -> Response {
|
||||
_ = try await authenticate(request, body: body, path: "/e2e/groups/fetch", now: now)
|
||||
let input = try decodeBody(FetchGroupsRequest.self, from: body)
|
||||
|
||||
let tags = Array(Set(input.tags.compactMap(validTag)).prefix(Self.maxTagsPerFetch))
|
||||
guard !tags.isEmpty else { return try .json(SealedGroupListResponse(groups: [])) }
|
||||
|
||||
let gids = Set(try await GroupTag.query(on: request.db)
|
||||
.filter(\.$tag ~~ tags).all().map(\.gid))
|
||||
var out: [SealedGroupResponse] = []
|
||||
for gid in gids.sorted() {
|
||||
guard let group = try await Group.find(gid, on: request.db),
|
||||
let key = group.ownerKey, let blob = group.rosterBlob
|
||||
else { continue }
|
||||
out.append(SealedGroupResponse(
|
||||
ok: nil, gid: gid, owner_key: key, roster_blob: blob,
|
||||
roster_version: group.rosterVersion ?? 1))
|
||||
}
|
||||
return try .json(SealedGroupListResponse(groups: out))
|
||||
}
|
||||
|
||||
// MARK: - POST /e2e/groups/{gid}/roster
|
||||
|
||||
func replaceRoster(
|
||||
_ request: Request, gid: String, body: Data, now: Int
|
||||
) async throws -> Response {
|
||||
_ = try await authenticate(
|
||||
request, body: body, path: "/e2e/groups/\(gid)/roster", now: now)
|
||||
let input = try decodeBody(ReplaceRosterRequest.self, from: body)
|
||||
|
||||
guard let group = try await Group.find(gid, on: request.db),
|
||||
let ownerKey = group.ownerKey
|
||||
else { throw WireError.notFound("No such sealed group.") }
|
||||
|
||||
// 🔴 **Strictly greater, not merely different.** A roster is signed, so
|
||||
// an OLD one stays valid for ever — replaying it would re-add somebody
|
||||
// the owner had just removed, with a signature that genuinely verifies.
|
||||
guard input.roster_version > (group.rosterVersion ?? 0) else {
|
||||
throw WireError.badRequest("Roster version must increase.")
|
||||
}
|
||||
guard !input.roster_blob.isEmpty, input.roster_blob.count <= Self.maxRosterBlob else {
|
||||
throw WireError.badRequest("Roster too large.")
|
||||
}
|
||||
guard verifyRosterSignature(ownerKeyB64: ownerKey, sig: input.sig, gid: gid,
|
||||
version: input.roster_version, blob: input.roster_blob)
|
||||
else { throw WireError.forbidden("Only the group owner can change the roster.") }
|
||||
|
||||
let tags = Set(input.tags.compactMap(validTag))
|
||||
guard !tags.isEmpty, tags.count <= Group.maxMembers else {
|
||||
throw WireError.badRequest(
|
||||
"A sealed group needs between one and \(Group.maxMembers) member tags.")
|
||||
}
|
||||
|
||||
// ⚠️ Handing the group on is signed by the CURRENT owner, in the same
|
||||
// request that proves they are the current owner. A separate transfer
|
||||
// route would be one more place to get that check wrong.
|
||||
if let next = input.new_owner_key {
|
||||
guard let raw = Data(base64Encoded: next), raw.count == 32 else {
|
||||
throw WireError.badRequest("Bad owner key.")
|
||||
}
|
||||
}
|
||||
|
||||
try await request.db.transaction { db in
|
||||
group.rosterBlob = input.roster_blob
|
||||
group.rosterVersion = input.roster_version
|
||||
if let next = input.new_owner_key { group.ownerKey = next }
|
||||
try await group.update(on: db)
|
||||
try await GroupTag.query(on: db).filter(\.$gid == gid).delete()
|
||||
for tag in tags.sorted() {
|
||||
let row = GroupTag()
|
||||
row.gid = gid; row.tag = tag
|
||||
try await row.create(on: db)
|
||||
}
|
||||
}
|
||||
return try .json(OKResponse(ok: true))
|
||||
}
|
||||
|
||||
// MARK: - POST /e2e/groups/{gid}/leave-sealed
|
||||
|
||||
/// Leaving is surrendering the tag that let you in.
|
||||
///
|
||||
/// ⚠️ The blob still lists the leaver until the owner rewrites it. That is
|
||||
/// the honest shape: the blob is what members read, the TAG is what the
|
||||
/// relay enforces, and only the owner can rewrite the blob. The leaver stops
|
||||
/// being able to fetch it immediately.
|
||||
func leaveSealedGroup(
|
||||
_ request: Request, gid: String, body: Data, now: Int
|
||||
) async throws -> Response {
|
||||
_ = try await authenticate(
|
||||
request, body: body, path: "/e2e/groups/\(gid)/leave-sealed", now: now)
|
||||
let input = try decodeBody(LeaveSealedGroupRequest.self, from: body)
|
||||
guard let tag = validTag(input.tag) else { throw WireError.badRequest("Bad tag.") }
|
||||
|
||||
try await request.db.transaction { db in
|
||||
try await GroupTag.query(on: db)
|
||||
.filter(\.$gid == gid).filter(\.$tag == tag).delete()
|
||||
// The last one out deletes the group rather than leaving a blob
|
||||
// nobody holds a tag for — which nothing could ever read or remove.
|
||||
let left = try await GroupTag.query(on: db).filter(\.$gid == gid).count()
|
||||
if left == 0, let group = try await Group.find(gid, on: db) {
|
||||
try await group.delete(on: db)
|
||||
}
|
||||
}
|
||||
return try .json(OKResponse(ok: true))
|
||||
}
|
||||
|
||||
/// Generous: a roster is a member list plus AEAD overhead, and 32 members of
|
||||
/// base64 identity material is well under this. Present so a client cannot
|
||||
/// use the group table as storage.
|
||||
static var maxRosterBlob: Int { 64 * 1024 }
|
||||
/// A client asks with the tags it holds, so this bounds a request rather
|
||||
/// than a person's group count.
|
||||
static var maxTagsPerFetch: Int { 256 }
|
||||
}
|
||||
@@ -349,6 +349,61 @@ struct CreateGroupRequest: Decodable {
|
||||
let members: [Int]?
|
||||
}
|
||||
|
||||
/// §8.9b — create a sealed group. No member ids anywhere.
|
||||
struct SealGroupRequest: Decodable {
|
||||
/// 🔴 **The CLIENT chooses the group id, and it has to.** The signature that
|
||||
/// makes a roster the owner's covers the gid — binding it, so a signed
|
||||
/// roster cannot be replayed into a different group. A relay-minted id
|
||||
/// cannot be signed over, because the client does not know it yet: the
|
||||
/// first draft of this route generated the gid server-side and was
|
||||
/// unimplementable by any client. It is 32 random bytes as hex; the relay
|
||||
/// checks the shape and that nobody is already using it.
|
||||
let gid: String
|
||||
let owner_key: String
|
||||
let roster_blob: String
|
||||
let tags: [String]
|
||||
let sig: String
|
||||
}
|
||||
|
||||
/// §8.9b — "give me the groups these tags open".
|
||||
///
|
||||
/// 🔴 The client asks by TAG, never "what groups am I in". The relay cannot
|
||||
/// answer the second question about a sealed group, which is the point.
|
||||
struct FetchGroupsRequest: Decodable {
|
||||
let tags: [String]
|
||||
}
|
||||
|
||||
/// §8.9b — replace a roster. Owner only, proven by signature.
|
||||
struct ReplaceRosterRequest: Decodable {
|
||||
let roster_blob: String
|
||||
let roster_version: Int
|
||||
let sig: String
|
||||
/// The full tag set after the change. Sent whole rather than as add/remove
|
||||
/// so the relay never has to reason about which tag belongs to whom.
|
||||
let tags: [String]
|
||||
/// Present only when the owner is handing the group on, signed by the
|
||||
/// CURRENT owner key.
|
||||
let new_owner_key: String?
|
||||
}
|
||||
|
||||
/// §8.9b — leave, by surrendering the tag that let you in.
|
||||
struct LeaveSealedGroupRequest: Decodable {
|
||||
let tag: String
|
||||
}
|
||||
|
||||
/// One sealed group, as the relay can describe it: an opaque blob and a number.
|
||||
struct SealedGroupResponse: Content {
|
||||
var ok: Bool?
|
||||
let gid: String
|
||||
let owner_key: String
|
||||
let roster_blob: String
|
||||
let roster_version: Int
|
||||
}
|
||||
|
||||
struct SealedGroupListResponse: Content {
|
||||
let groups: [SealedGroupResponse]
|
||||
}
|
||||
|
||||
struct UpdateMembersRequest: Decodable {
|
||||
let add: [Int]?
|
||||
let remove: [Int]?
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// §8.9b — group rosters the relay cannot read.
|
||||
///
|
||||
/// 🔴 **The property under test is that no account id reaches the database.**
|
||||
/// The old membership rows were a readable social graph: production held seven
|
||||
/// of them, saying exactly who was in which group. Sealed, a stolen dump yields
|
||||
/// opaque tokens and a blob nobody can open.
|
||||
///
|
||||
/// ⚠️ **This says nothing about what a running relay can observe.** Every
|
||||
/// request here is authenticated, so the operator still sees which account asks
|
||||
/// about which group. These tests hold down what is STORED. The spec states the
|
||||
/// limit in those words, and so does /metadata.
|
||||
@Suite("sealed groups")
|
||||
struct SealedGroupTests {
|
||||
|
||||
private let now = 1_760_000_000
|
||||
|
||||
private func member(_ h: RelayHarness, user: Int, device: String) async throws
|
||||
-> RelayHarness.TestDevice {
|
||||
let d = h.device(user: user, id: device)
|
||||
_ = try await h.register(d, on: h.db)
|
||||
return d
|
||||
}
|
||||
|
||||
private func tag() -> String {
|
||||
SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }.base64EncodedString()
|
||||
}
|
||||
|
||||
/// An owner key, and a signer for rosters under it.
|
||||
private struct Owner {
|
||||
let key: String
|
||||
private let priv: Curve25519.Signing.PrivateKey
|
||||
init() {
|
||||
priv = Curve25519.Signing.PrivateKey()
|
||||
key = priv.publicKey.rawRepresentation.base64EncodedString()
|
||||
}
|
||||
func sign(gid: String, version: Int, blob: String) -> String {
|
||||
let canonical = Group.rosterCanonical(gid: gid, version: version, blob: blob)
|
||||
return (try! priv.signature(for: canonical)).base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
private func blob(_ text: String) -> String {
|
||||
Data(text.utf8).base64EncodedString()
|
||||
}
|
||||
|
||||
private func createBody(gid: String, owner: Owner, blob: String, tags: [String],
|
||||
sig: String? = nil) -> String {
|
||||
let list = tags.map { "\"\($0)\"" }.joined(separator: ",")
|
||||
let signature = sig ?? owner.sign(gid: gid, version: 1, blob: blob)
|
||||
return #"{"gid":"\#(gid)","owner_key":"\#(owner.key)","roster_blob":"\#(blob)","tags":[\#(list)],"sig":"\#(signature)"}"#
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func create(_ h: RelayHarness, as d: RelayHarness.TestDevice, gid: String,
|
||||
owner: Owner, blob rosterBlob: String, tags: [String],
|
||||
sig: String? = nil) async throws -> Response {
|
||||
let body = createBody(gid: gid, owner: owner, blob: rosterBlob, tags: tags, sig: sig)
|
||||
return try await h.routes.createSealedGroup(
|
||||
h.request(.POST, "/e2e/groups/sealed", as: d, body: Data(body.utf8), now: now),
|
||||
body: Data(body.utf8), now: now)
|
||||
}
|
||||
|
||||
// MARK: - What reaches the database
|
||||
|
||||
/// 🔴 Asserted against every table, not the one that changed. An account id
|
||||
/// surviving anywhere in the group tables is a graph the relay still holds.
|
||||
@Test("a sealed group stores no account id anywhere")
|
||||
func noAccountIDsStored() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 41, device: "aaaa-0041")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
let tags = [tag(), tag()]
|
||||
|
||||
let made = try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: blob("an opaque roster"), tags: tags)
|
||||
#expect(made.status == .ok)
|
||||
|
||||
let groups = try await Group.query(on: h.db).all()
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].ownerUserID == 0, "a sealed group named an account")
|
||||
#expect(groups[0].isSealed)
|
||||
#expect(try await GroupMember.query(on: h.db).count() == 0,
|
||||
"plaintext membership rows were written")
|
||||
|
||||
let stored = try await GroupTag.query(on: h.db).all()
|
||||
#expect(stored.count == 2)
|
||||
for row in stored {
|
||||
#expect(Data(base64Encoded: row.tag)?.count == 32, "a tag was not 32 bytes")
|
||||
#expect(row.tag != "41", "an account id was stored as a tag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ The blob is never parsed, so nothing about its contents can be relied
|
||||
/// on by the relay — including that it is JSON, or that it is a list.
|
||||
@Test("the roster blob is stored opaquely and read back byte for byte")
|
||||
func blobIsOpaque() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 42, device: "aaaa-0042")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
let mine = tag()
|
||||
let sealedBlob = blob("\u{0}\u{1}not json at all\u{FFFF}")
|
||||
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: sealedBlob, tags: [mine])
|
||||
|
||||
let body = #"{"tags":["\#(mine)"]}"#
|
||||
let res = try await h.routes.fetchSealedGroups(
|
||||
h.request(.POST, "/e2e/groups/fetch", as: alice, body: Data(body.utf8), now: now),
|
||||
body: Data(body.utf8), now: now)
|
||||
let list = try JSONDecoder().decode(
|
||||
SealedGroupListResponse.self, from: Data(buffer: res.body.buffer ?? ByteBuffer()))
|
||||
#expect(list.groups.count == 1)
|
||||
#expect(list.groups[0].roster_blob == sealedBlob)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Who may read it
|
||||
|
||||
/// 🔴 A tag is the whole access control. Without one, a group is unreadable
|
||||
/// even to somebody who knows its id.
|
||||
@Test("a group is invisible to anyone not holding one of its tags")
|
||||
func onlyTagHoldersCanRead() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 43, device: "aaaa-0043")
|
||||
let mallory = try await member(h, user: 44, device: "bbbb-0044")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
let mine = tag()
|
||||
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: blob("roster"), tags: [mine])
|
||||
|
||||
// Mallory knows the gid and asks with a tag of her own making.
|
||||
let body = #"{"tags":["\#(tag())"]}"#
|
||||
let res = try await h.routes.fetchSealedGroups(
|
||||
h.request(.POST, "/e2e/groups/fetch", as: mallory,
|
||||
body: Data(body.utf8), now: now),
|
||||
body: Data(body.utf8), now: now)
|
||||
let list = try JSONDecoder().decode(
|
||||
SealedGroupListResponse.self, from: Data(buffer: res.body.buffer ?? ByteBuffer()))
|
||||
#expect(list.groups.isEmpty, "a stranger read a sealed group")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Who may change it
|
||||
|
||||
@Test("a roster signed by the wrong key is refused")
|
||||
func forgedOwnerRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 45, device: "aaaa-0045")
|
||||
let owner = Owner(), impostor = Owner()
|
||||
let gid = randomHexToken()
|
||||
let mine = tag()
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: blob("roster"), tags: [mine])
|
||||
|
||||
let next = blob("a roster the impostor prefers")
|
||||
let body = #"{"roster_blob":"\#(next)","roster_version":2,"sig":"\#(impostor.sign(gid: gid, version: 2, blob: next))","tags":["\#(mine)"]}"#
|
||||
await #expect(throws: WireError.self) {
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/roster", as: alice,
|
||||
body: Data(body.utf8), now: now),
|
||||
gid: gid, body: Data(body.utf8), now: now)
|
||||
}
|
||||
let group = try #require(try await Group.find(gid, on: h.db))
|
||||
#expect(group.rosterBlob == blob("roster"), "the forged roster was stored")
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 **The replay this version counter exists for.** A roster is signed, so
|
||||
/// an OLD one stays valid for ever — its signature really was made by the
|
||||
/// owner. Posting it back would re-add whoever was just removed.
|
||||
@Test("an older roster cannot be replayed over a newer one")
|
||||
func rosterRollbackRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 46, device: "aaaa-0046")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
let mine = tag(), leaver = tag()
|
||||
|
||||
let first = blob("alice and bob")
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: first, tags: [mine, leaver])
|
||||
|
||||
// Bob is removed: a new roster, and his tag goes with it.
|
||||
let second = blob("alice only")
|
||||
let up = #"{"roster_blob":"\#(second)","roster_version":2,"sig":"\#(owner.sign(gid: gid, version: 2, blob: second))","tags":["\#(mine)"]}"#
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/roster", as: alice,
|
||||
body: Data(up.utf8), now: now),
|
||||
gid: gid, body: Data(up.utf8), now: now)
|
||||
|
||||
// The genuinely-signed version 1 is posted back.
|
||||
let replay = #"{"roster_blob":"\#(first)","roster_version":1,"sig":"\#(owner.sign(gid: gid, version: 1, blob: first))","tags":["\#(mine)","\#(leaver)"]}"#
|
||||
await #expect(throws: WireError.self) {
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/roster", as: alice,
|
||||
body: Data(replay.utf8), now: now),
|
||||
gid: gid, body: Data(replay.utf8), now: now)
|
||||
}
|
||||
let group = try #require(try await Group.find(gid, on: h.db))
|
||||
#expect(group.rosterVersion == 2)
|
||||
let tags = try await GroupTag.query(on: h.db).filter(\.$gid == gid).all().map(\.tag)
|
||||
#expect(!tags.contains(leaver), "the removed member's tag came back")
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ A roster signed for one group must not be usable in another. That is
|
||||
/// what binding the gid into the signature buys.
|
||||
@Test("a roster signed for another group is refused")
|
||||
func crossGroupReplayRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 47, device: "aaaa-0047")
|
||||
let owner = Owner()
|
||||
let one = randomHexToken(), two = randomHexToken()
|
||||
let mine = tag()
|
||||
try await create(h, as: alice, gid: one, owner: owner,
|
||||
blob: blob("roster one"), tags: [mine])
|
||||
try await create(h, as: alice, gid: two, owner: owner,
|
||||
blob: blob("roster two"), tags: [mine])
|
||||
|
||||
// Signed for group one, posted at group two.
|
||||
let payload = blob("roster one, elsewhere")
|
||||
let body = #"{"roster_blob":"\#(payload)","roster_version":2,"sig":"\#(owner.sign(gid: one, version: 2, blob: payload))","tags":["\#(mine)"]}"#
|
||||
await #expect(throws: WireError.self) {
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(two)/roster", as: alice,
|
||||
body: Data(body.utf8), now: now),
|
||||
gid: two, body: Data(body.utf8), now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the owner can hand the group on, and the new key then signs")
|
||||
func ownershipTransfers() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 48, device: "aaaa-0048")
|
||||
let owner = Owner(), successor = Owner()
|
||||
let gid = randomHexToken()
|
||||
let mine = tag()
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: blob("roster"), tags: [mine])
|
||||
|
||||
let handed = blob("roster, handed on")
|
||||
let body = #"{"roster_blob":"\#(handed)","roster_version":2,"sig":"\#(owner.sign(gid: gid, version: 2, blob: handed))","tags":["\#(mine)"],"new_owner_key":"\#(successor.key)"}"#
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/roster", as: alice,
|
||||
body: Data(body.utf8), now: now),
|
||||
gid: gid, body: Data(body.utf8), now: now)
|
||||
|
||||
// The successor now signs, and the old owner no longer does.
|
||||
let next = blob("roster, by the successor")
|
||||
let ok = #"{"roster_blob":"\#(next)","roster_version":3,"sig":"\#(successor.sign(gid: gid, version: 3, blob: next))","tags":["\#(mine)"]}"#
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/roster", as: alice,
|
||||
body: Data(ok.utf8), now: now),
|
||||
gid: gid, body: Data(ok.utf8), now: now)
|
||||
|
||||
let stale = blob("roster, by the former owner")
|
||||
let refused = #"{"roster_blob":"\#(stale)","roster_version":4,"sig":"\#(owner.sign(gid: gid, version: 4, blob: stale))","tags":["\#(mine)"]}"#
|
||||
await #expect(throws: WireError.self) {
|
||||
_ = try await h.routes.replaceRoster(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/roster", as: alice,
|
||||
body: Data(refused.utf8), now: now),
|
||||
gid: gid, body: Data(refused.utf8), now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leaving
|
||||
|
||||
@Test("leaving surrenders the tag, and the last one out deletes the group")
|
||||
func leavingDeletesTheGroup() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 49, device: "aaaa-0049")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
let mine = tag(), theirs = tag()
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: blob("roster"), tags: [mine, theirs])
|
||||
|
||||
let one = #"{"tag":"\#(theirs)"}"#
|
||||
_ = try await h.routes.leaveSealedGroup(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/leave-sealed", as: alice,
|
||||
body: Data(one.utf8), now: now),
|
||||
gid: gid, body: Data(one.utf8), now: now)
|
||||
#expect(try await Group.find(gid, on: h.db) != nil, "one leaver removed the group")
|
||||
#expect(try await GroupTag.query(on: h.db).filter(\.$gid == gid).count() == 1)
|
||||
|
||||
let two = #"{"tag":"\#(mine)"}"#
|
||||
_ = try await h.routes.leaveSealedGroup(
|
||||
h.request(.POST, "/e2e/groups/\(gid)/leave-sealed", as: alice,
|
||||
body: Data(two.utf8), now: now),
|
||||
gid: gid, body: Data(two.utf8), now: now)
|
||||
#expect(try await Group.find(gid, on: h.db) == nil,
|
||||
"the last member out left a blob nobody holds a tag for")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shape
|
||||
|
||||
@Test("a tag that is not 32 bytes is refused")
|
||||
func malformedTagRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 50, device: "aaaa-0050")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
await #expect(throws: WireError.self) {
|
||||
_ = try await self.create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: self.blob("roster"), tags: ["shortish"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a group id already in use is refused")
|
||||
func duplicateGidRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let alice = try await member(h, user: 51, device: "aaaa-0051")
|
||||
let owner = Owner()
|
||||
let gid = randomHexToken()
|
||||
try await create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: blob("first"), tags: [tag()])
|
||||
await #expect(throws: WireError.self) {
|
||||
_ = try await self.create(h, as: alice, gid: gid, owner: owner,
|
||||
blob: self.blob("second"), tags: [self.tag()])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user