Pin the stored creator on incoming private-group state (#1720)

applyGroupState authorized state only against the creator named in the
state itself, not the creator of the group already stored under that
groupID, and handled removal before any existing-group check.

- applyGroupState: for a group already held, require the stored
  creator's fingerprint and signing key, and a non-stale epoch, before
  the removal branch.
- GroupStore.upsert refuses to change an existing group's creator;
  updateRoster keeps the creator's entry pinned; loadFromDisk re-checks
  the same invariants and keeps one entry per groupID.
- First ChatGroupCoordinator tests.

Ports the approach from #1587 by @areebahmeddd.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
jack 2026-09-23 19:47:10 +02:00 committed by GitHub
parent 0b5cae856d
commit 3257ced77e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 469 additions and 25 deletions

View File

@ -59,6 +59,16 @@ struct BitchatGroup: Codable, Equatable {
func member(withSigningKey signingKey: Data) -> GroupMember? {
members.first { $0.signingKey == signingKey }
}
/// Whether `other` names the same creator — fingerprint AND signing key —
/// as this group. A group keeps the creator it was created with: state
/// for a known groupID naming anyone else is a takeover attempt, however
/// validly it is self-signed.
func hasSameCreator(as other: BitchatGroup) -> Bool {
guard let creator, let otherCreator = other.creator else { return false }
return creator.fingerprint == otherCreator.fingerprint
&& creator.signingKey == otherCreator.signingKey
}
}
// MARK: - TLV helpers
@ -192,7 +202,9 @@ enum GroupRosterCoding {
/// key updates (0x07); receivers verify the creator signature — computed over
/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) —
/// against the creator's signing key pinned in the roster, and require the
/// Noise session peer to BE the creator before accepting any state.
/// Noise session peer to BE the creator before accepting any state. Both
/// checks are self-referential (the state names its own creator), so for a
/// group already held the creator must also match the stored one.
struct GroupStatePayload: Equatable {
let groupID: Data
let name: String

View File

@ -68,14 +68,20 @@ final class GroupStore: ObservableObject {
}
/// Inserts or replaces a group and its current key. Rejects rosters over
/// the hard cap or groups whose creator is missing from the roster.
/// the hard cap, groups whose creator is missing from the roster, and any
/// change to the creator (fingerprint or signing key) of a group already
/// held, so no caller can hand a known groupID to a different creator.
@discardableResult
func upsert(_ group: BitchatGroup, key: Data) -> Bool {
guard group.groupID.count == BitchatGroup.groupIDLength,
key.count == BitchatGroup.keyLength,
!group.members.isEmpty,
group.members.count <= BitchatGroup.maxMembers,
group.creator != nil else { return false }
guard key.count == BitchatGroup.keyLength,
Self.isWellFormed(group) else { return false }
if let existing = self.group(withID: group.groupID), !existing.hasSameCreator(as: group) {
SecureLogger.warning(
"Refusing group state: creator \(group.creatorFingerprint.prefix(8))… does not match stored creator \(existing.creatorFingerprint.prefix(8))…",
category: .security
)
return false
}
guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else {
SecureLogger.error("Failed to store group key in keychain", category: .security)
return false
@ -90,16 +96,17 @@ final class GroupStore: ObservableObject {
}
/// Updates the roster of an existing group without changing key or epoch
/// (creator-side invite). Enforces the member cap.
/// (creator-side invite). Enforces the member cap and keeps the creator's
/// roster entry (fingerprint and signing key) pinned.
@discardableResult
func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? {
guard let index = groups.firstIndex(where: { $0.groupID == groupID }),
!members.isEmpty,
members.count <= BitchatGroup.maxMembers,
members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil }
groups[index].members = members
guard let index = groups.firstIndex(where: { $0.groupID == groupID }) else { return nil }
var updated = groups[index]
updated.members = members
guard Self.isWellFormed(updated), groups[index].hasSameCreator(as: updated) else { return nil }
groups[index] = updated
persist()
return groups[index]
return updated
}
/// Rotates the group key (creator-side removal/rotation): new random key,
@ -139,6 +146,15 @@ final class GroupStore: ObservableObject {
"groupKey-\(groupID.hexEncodedString())"
}
/// Structural invariants every stored group satisfies: a well-sized ID,
/// a non-empty roster within the cap, and the creator in that roster.
private static func isWellFormed(_ group: BitchatGroup) -> Bool {
group.groupID.count == BitchatGroup.groupIDLength
&& !group.members.isEmpty
&& group.members.count <= BitchatGroup.maxMembers
&& group.creator != nil
}
private static func randomBytes(_ count: Int) -> Data? {
var bytes = Data(count: count)
let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in
@ -176,8 +192,15 @@ final class GroupStore: ObservableObject {
let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else {
return
}
// Only groups whose key survived in the keychain are usable.
groups = stored.filter { key(forGroupID: $0.groupID) != nil }
// Only groups whose key survived in the keychain are usable. Loading
// bypasses `upsert`, so re-check its invariants here too, and keep one
// entry per groupID so a group can never carry two creators.
var seenGroupIDs = Set<Data>()
groups = stored.filter { group in
Self.isWellFormed(group)
&& seenGroupIDs.insert(group.groupID).inserted
&& key(forGroupID: group.groupID) != nil
}
}
private static func defaultFileURL() -> URL? {

View File

@ -466,12 +466,14 @@ final class ChatGroupCoordinator {
/// Accepts creator-signed group state arriving as an invite. The Noise
/// session peer must BE the creator, the signature must verify against
/// the creator key pinned in the roster, and we must be in the roster.
/// For a group we already hold, the creator must be the stored one.
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
applyGroupState(from: peerID, payload: payload, isInvite: true)
}
/// Accepts creator-signed state updates (rotation/roster). A state whose
/// roster no longer includes us means we were removed: drop the group.
/// Accepts creator-signed state updates (rotation/roster). A state from
/// the stored creator whose roster no longer includes us means we were
/// removed: drop the group.
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
applyGroupState(from: peerID, payload: payload, isInvite: false)
}
@ -558,6 +560,30 @@ private extension ChatGroupCoordinator {
let myFingerprint = context.myNoiseFingerprint()
let existing = context.groupStore.group(withID: state.groupID)
if let existing {
// A group keeps the creator it was created with. The checks above
// only prove the sender is the creator the state names — anyone
// passes them by naming themselves and self-signing — so pin the
// creator's fingerprint AND signing key to the stored group.
// Both existing-group checks run before the removal branch, which
// drops the group without ever reaching `upsert`'s own pin.
guard existing.hasSameCreator(as: state.asGroup) else {
SecureLogger.warning(
"Dropping group state claiming creator \(state.creatorFingerprint.prefix(8))… for a group created by \(existing.creatorFingerprint.prefix(8))…",
category: .security
)
return
}
// Never regress the epoch: state travels over live Noise sessions,
// so an older epoch here is a stale (or misbehaving) creator
// device. That includes a stale removal, which must not undo a
// later re-invite.
guard state.epoch >= existing.epoch else {
SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
return
}
}
// A creator-signed roster that no longer includes us is a removal.
guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else {
if let existing {
@ -576,13 +602,6 @@ private extension ChatGroupCoordinator {
return
}
// Never regress the epoch: state travels over live Noise sessions,
// so an older epoch here is a stale (or misbehaving) creator device.
if let existing, state.epoch < existing.epoch {
SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
return
}
let isNewMembership = existing == nil
guard context.groupStore.upsert(state.asGroup, key: state.key) else {
SecureLogger.error("Failed to store group state for \(state.name)", category: .session)

View File

@ -0,0 +1,302 @@
//
// ChatGroupCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatGroupCoordinator`'s group-state authorization against a
// mock `ChatGroupContext` and a real in-memory `GroupStore`, with real
// Ed25519 creator signatures. The Noise session binding is modelled by
// `MockChatGroupContext.fingerprintsByPeerID` (what `meshFingerprint(for:)`
// reports for the authenticated session peer). Store-level pinning is
// covered by `GroupStoreTests`; wire/crypto by `GroupProtocolTests`.
//
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
@MainActor
private final class MockChatGroupContext: ChatGroupContext {
// Identity & state
let nickname = "me"
let myPeerID: PeerID
var selectedPrivateChatPeer: PeerID?
let groupStore = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
private let identity: TestIdentity
init(identity: TestIdentity) {
self.identity = identity
self.myPeerID = identity.peerID
}
func myNoiseFingerprint() -> String { identity.fingerprint }
func mySigningPublicKey() -> Data { identity.member.signingKey }
func signWithNoiseKey(_ data: Data) -> Data? { identity.sign(data) }
// Peers: the fingerprint each Noise session authenticated.
var fingerprintsByPeerID: [PeerID: String] = [:]
func getPeerIDForNickname(_ nickname: String) -> PeerID? { nil }
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func peerNickname(for peerID: PeerID) -> String? { nil }
func meshFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { nil }
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { nil }
func isFingerprintBlocked(_ fingerprint: String) -> Bool { false }
// Transport (outbound state is not exercised by these tests)
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {}
func broadcastGroupMessagePayload(_ payload: Data) {}
// Conversation intents
private(set) var removedPrivateChats: [PeerID] = []
private(set) var endPrivateChatCount = 0
private(set) var systemMessages: [String] = []
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { true }
func markPrivateChatUnread(_ peerID: PeerID) {}
func removePrivateChat(_ peerID: PeerID) { removedPrivateChats.append(peerID) }
func startPrivateChat(with peerID: PeerID) {}
func endPrivateChat() { endPrivateChatCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
func notifyUIChanged() {}
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {}
func clearRecordedEffects() {
removedPrivateChats.removeAll()
endPrivateChatCount = 0
systemMessages.removeAll()
}
}
// MARK: - Fixtures
/// A Noise identity (fingerprint → short peer ID) plus a real Ed25519
/// signing key, so creator signatures verify for real.
private struct TestIdentity {
let fingerprint: String
let nickname: String
private let signingKey = Curve25519.Signing.PrivateKey()
init(seed: UInt8, nickname: String) {
fingerprint = Data(repeating: seed, count: 32).hexEncodedString()
self.nickname = nickname
}
var peerID: PeerID { PeerID(str: String(fingerprint.prefix(16))) }
var member: GroupMember {
GroupMember(fingerprint: fingerprint, signingKey: signingKey.publicKey.rawRepresentation, nickname: nickname)
}
/// A roster entry claiming this identity's fingerprint and nickname but
/// pinning `forger`'s signing key — how a forged roster impersonates
/// real members.
func impersonated(by forger: TestIdentity) -> GroupMember {
GroupMember(fingerprint: fingerprint, signingKey: forger.member.signingKey, nickname: nickname)
}
func sign(_ data: Data) -> Data? { try? signingKey.signature(for: data) }
}
// MARK: - Tests
@MainActor
struct ChatGroupCoordinatorContextTests {
private let me = TestIdentity(seed: 0x0E, nickname: "me")
private let creator = TestIdentity(seed: 0xC1, nickname: "creator")
private let member = TestIdentity(seed: 0xA2, nickname: "member")
private let attacker = TestIdentity(seed: 0xEE, nickname: "creator")
private let groupID = Data((0..<16).map { UInt8($0) })
private let groupKey = Data(repeating: 0x42, count: 32)
private let joinedEpoch: UInt32 = 3
/// A creator-signed state payload, encoded for the wire.
private func statePayload(
epoch: UInt32,
members: [GroupMember],
creatorFingerprint: String,
key: Data,
signedBy signer: TestIdentity
) throws -> Data {
let group = BitchatGroup(
groupID: groupID,
name: "trail crew",
epoch: epoch,
members: members,
creatorFingerprint: creatorFingerprint
)
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: signer.sign))
return try #require(payload.encode())
}
/// State from the real creator over their authenticated session.
private func creatorState(epoch: UInt32, members: [GroupMember], key: Data) throws -> Data {
try statePayload(epoch: epoch, members: members, creatorFingerprint: creator.fingerprint, key: key, signedBy: creator)
}
/// Joins the group through the coordinator (the creator's invite), opens
/// its chat, and clears the join side effects. Both the creator and the
/// attacker hold Noise sessions with us.
private func makeJoinedGroup() throws -> (MockChatGroupContext, ChatGroupCoordinator, BitchatGroup) {
let context = MockChatGroupContext(identity: me)
let coordinator = ChatGroupCoordinator(context: context)
context.fingerprintsByPeerID[creator.peerID] = creator.fingerprint
context.fingerprintsByPeerID[attacker.peerID] = attacker.fingerprint
let invite = try creatorState(epoch: joinedEpoch, members: [creator.member, member.member, me.member], key: groupKey)
coordinator.handleGroupInvitePayload(from: creator.peerID, payload: invite)
let joined = try #require(context.groupStore.group(withID: groupID))
#expect(joined.creatorFingerprint == creator.fingerprint)
#expect(context.systemMessages.count == 1) // the join notice
context.selectedPrivateChatPeer = joined.peerID
context.clearRecordedEffects()
return (context, coordinator, joined)
}
private func expectUnchanged(_ context: MockChatGroupContext, joined: BitchatGroup) {
#expect(context.groupStore.group(withID: groupID) == joined)
#expect(context.groupStore.key(forGroupID: groupID) == groupKey)
#expect(context.removedPrivateChats.isEmpty)
#expect(context.endPrivateChatCount == 0)
#expect(context.systemMessages.isEmpty)
}
// MARK: Takeover
@Test
func nonCreatorNamingItselfCreatorAtHigherEpochIsRejected() throws {
let (context, coordinator, joined) = try makeJoinedGroup()
// Self-named creator, self-signed, at the highest epoch, with every
// real member re-pinned to the attacker's key. Passes the sender and
// signature checks; only the stored-creator pin stops it.
let hijack = try statePayload(
epoch: .max,
members: [attacker.member, me.member, creator.impersonated(by: attacker), member.impersonated(by: attacker)],
creatorFingerprint: attacker.fingerprint,
key: Data(repeating: 0xAB, count: 32),
signedBy: attacker
)
coordinator.handleGroupKeyUpdatePayload(from: attacker.peerID, payload: hijack)
expectUnchanged(context, joined: joined)
coordinator.handleGroupInvitePayload(from: attacker.peerID, payload: hijack)
expectUnchanged(context, joined: joined)
// The attacker's epoch did not lock the real creator out.
let rotatedKey = Data(repeating: 0x5A, count: 32)
let rotation = try creatorState(epoch: joinedEpoch + 1, members: [creator.member, member.member, me.member], key: rotatedKey)
coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: rotation)
#expect(context.groupStore.group(withID: groupID)?.epoch == joinedEpoch + 1)
#expect(context.groupStore.key(forGroupID: groupID) == rotatedKey)
}
@Test
func stateNamingTheStoredCreatorWithAnotherSigningKeyIsRejected() throws {
let (context, coordinator, joined) = try makeJoinedGroup()
// Even a session that resolves to the stored creator's fingerprint
// cannot re-pin the creator's signing key.
let impostorPeerID = PeerID(str: "00112233aabbccdd")
context.fingerprintsByPeerID[impostorPeerID] = creator.fingerprint
let repin = try statePayload(
epoch: joinedEpoch + 1,
members: [creator.impersonated(by: attacker), member.member, me.member],
creatorFingerprint: creator.fingerprint,
key: Data(repeating: 0xAB, count: 32),
signedBy: attacker
)
coordinator.handleGroupKeyUpdatePayload(from: impostorPeerID, payload: repin)
expectUnchanged(context, joined: joined)
}
// MARK: Deletion
@Test
func nonCreatorRosterOmittingUsDoesNotDeleteTheGroup() throws {
let (context, coordinator, joined) = try makeJoinedGroup()
let eviction = try statePayload(
epoch: joinedEpoch + 1,
members: [attacker.member, creator.impersonated(by: attacker)],
creatorFingerprint: attacker.fingerprint,
key: Data(repeating: 0xAB, count: 32),
signedBy: attacker
)
coordinator.handleGroupKeyUpdatePayload(from: attacker.peerID, payload: eviction)
expectUnchanged(context, joined: joined)
}
// MARK: Legitimate creator
@Test
func creatorRotationIsAccepted() throws {
let (context, coordinator, joined) = try makeJoinedGroup()
let rotatedKey = Data(repeating: 0x5A, count: 32)
let rotation = try creatorState(epoch: joinedEpoch + 1, members: [creator.member, me.member], key: rotatedKey)
coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: rotation)
let stored = try #require(context.groupStore.group(withID: groupID))
#expect(stored.epoch == joinedEpoch + 1)
#expect(stored.members == [creator.member, me.member])
#expect(stored.creator == joined.creator)
#expect(context.groupStore.key(forGroupID: groupID) == rotatedKey)
#expect(context.removedPrivateChats.isEmpty)
#expect(context.systemMessages.isEmpty) // not a new membership
}
@Test
func creatorRemovalStillRemovesTheGroup() throws {
let (context, coordinator, joined) = try makeJoinedGroup()
// What `notifyRemovedMember` sends: the rotated roster without us,
// under a throwaway all-zero key.
let removal = try creatorState(
epoch: joinedEpoch + 1,
members: [creator.member, member.member],
key: Data(count: BitchatGroup.keyLength)
)
coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: removal)
#expect(context.groupStore.group(withID: groupID) == nil)
#expect(context.groupStore.key(forGroupID: groupID) == nil)
#expect(context.removedPrivateChats == [joined.peerID])
#expect(context.endPrivateChatCount == 1)
#expect(context.systemMessages.count == 1) // "removed from group"
}
@Test
func staleCreatorStateIsRejectedIncludingStaleRemoval() throws {
let (context, coordinator, joined) = try makeJoinedGroup()
let staleRotation = try creatorState(
epoch: joinedEpoch - 1,
members: [creator.member, me.member],
key: Data(repeating: 0x5A, count: 32)
)
coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: staleRotation)
expectUnchanged(context, joined: joined)
// An older removal (e.g. delivered after a re-invite) must not undo
// the newer membership.
let staleRemoval = try creatorState(
epoch: joinedEpoch - 1,
members: [creator.member, member.member],
key: Data(count: BitchatGroup.keyLength)
)
coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: staleRemoval)
expectUnchanged(context, joined: joined)
}
}

View File

@ -81,6 +81,58 @@ struct GroupStoreTests {
#expect(store.group(withID: group.groupID)?.members == [creator])
}
// MARK: - Creator pinning
@Test func upsertRefusesToChangeTheCreatorOfAnExistingGroup() throws {
let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
let creator = makeMember(seed: 0xC1, nickname: "creator")
let me = makeMember(seed: 0x0E, nickname: "me")
let attacker = makeMember(seed: 0xEE, nickname: "creator")
let group = try #require(store.createGroup(named: "trip", creator: creator))
#expect(store.updateRoster(groupID: group.groupID, members: [creator, me]) != nil)
let stored = try #require(store.group(withID: group.groupID))
let realKey = try #require(store.key(forGroupID: group.groupID))
// Same groupID at a higher epoch with the attacker as creator…
let hijack = BitchatGroup(
groupID: group.groupID,
name: group.name,
epoch: .max,
members: [attacker, me],
creatorFingerprint: attacker.fingerprint
)
#expect(!store.upsert(hijack, key: Data(repeating: 0xAB, count: 32)))
// …or the creator's fingerprint re-pinned to the attacker's signing key.
let repinned = GroupMember(fingerprint: creator.fingerprint, signingKey: attacker.signingKey, nickname: creator.nickname)
var repin = stored
repin.epoch = .max
repin.members = [repinned, me]
#expect(!store.upsert(repin, key: Data(repeating: 0xAB, count: 32)))
#expect(store.group(withID: group.groupID) == stored)
#expect(store.key(forGroupID: group.groupID) == realKey)
// The boundary: the same creator still moves the group forward.
var rotated = stored
rotated.epoch = stored.epoch + 1
let newKey = Data(repeating: 0x5A, count: 32)
#expect(store.upsert(rotated, key: newKey))
#expect(store.group(withID: group.groupID)?.epoch == stored.epoch + 1)
#expect(store.key(forGroupID: group.groupID) == newKey)
}
@Test func updateRosterRefusesToRepinTheCreatorSigningKey() throws {
let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
let creator = makeMember(seed: 0xC1, nickname: "creator")
let me = makeMember(seed: 0x0E, nickname: "me")
let group = try #require(store.createGroup(named: "trip", creator: creator))
let repinned = GroupMember(fingerprint: creator.fingerprint, signingKey: Data(repeating: 0xEF, count: 32), nickname: creator.nickname)
#expect(store.updateRoster(groupID: group.groupID, members: [repinned, me]) == nil)
#expect(store.group(withID: group.groupID) == group)
}
// MARK: - Rotation
@Test func rotateKeyBumpsEpochAndReplacesKey() throws {
@ -136,6 +188,42 @@ struct GroupStoreTests {
#expect(reloaded.group(withID: group.groupID) == nil)
}
@Test func loadEnforcesTheUpsertInvariants() throws {
let keychain = MockKeychain()
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let creator = makeMember(seed: 0xC1, nickname: "creator")
let other = makeMember(seed: 0xEE, nickname: "other")
let kept: BitchatGroup
let orphaned: BitchatGroup
do {
let store = GroupStore(keychain: keychain, fileURL: fileURL)
kept = try #require(store.createGroup(named: "kept", creator: creator))
orphaned = try #require(store.createGroup(named: "orphaned", creator: creator))
}
// Loading bypasses `upsert`: a second entry handing `kept` to another
// creator, and an entry whose creator is missing from its roster.
let rehomed = BitchatGroup(
groupID: kept.groupID,
name: kept.name,
epoch: kept.epoch + 1,
members: [other],
creatorFingerprint: other.fingerprint
)
let creatorless = BitchatGroup(
groupID: orphaned.groupID,
name: orphaned.name,
epoch: orphaned.epoch,
members: orphaned.members,
creatorFingerprint: other.fingerprint
)
try JSONEncoder().encode([kept, rehomed, creatorless]).write(to: fileURL)
let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
#expect(reloaded.groups == [kept])
}
// MARK: - Panic wipe
@Test func wipeRemovesMetadataAndKeys() throws {