diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a538db0f7..40de7c9f7 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "769a9973cc2072509500c7e8d45c1a9dafbba00e8cb7a9451e70f57c98481658", + "originHash" : "e5f1ba52e5331c4be2f0112091373db97282453fcb23e452e257d42eaa1ec130", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash-shared-core-spm", "state" : { - "revision" : "7c48adfd001f076e5a6d9a3c0a76347d435836bb", - "version" : "0.16.0" + "revision" : "01c05c5d7a7a2ed234189e806801b03058f2dc16", + "version" : "0.17.0" } }, { diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index 7441a3b29..17b2cebee 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -18,7 +18,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.17.0")) }() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 2183f7709..336dab800 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -172,7 +172,7 @@ extension EncryptedChatClient: ConversationMessaging { func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? { switch await opener(conversationID, owner: owner) { case .seal(let seal): seal - case .awaitingKey, .unsupported: nil + case .awaitingKey, .unsupported, .unusableKey: nil } } diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index 54b1a116d..0d16511fc 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -49,7 +49,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.17.0")) }() let package = Package( diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index 0570720fb..1287d13b4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -96,10 +96,11 @@ public final class ChatNotificationClient: Sendable { continuation.resume(with: $0) } } - // An invite-joined member has only the invite's copy until they store their own. - guard let own = result.ownEnvelope else { throw ErrorGetKeyEnvelope.noEnvelope } - return own - } + return result.material + }, + // The extension only reads: an invite-joined member's push stays undecrypted until the + // app opens the invite's copy and stores the member's own envelope. + opensInviteBundles: false ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 31745cfdf..560423fe1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -467,14 +467,13 @@ extension FlipClient { } } - /// Fetches the chat key `conversationID` wrapped for the signed-in user and who wrapped it. - public func getKeyEnvelope(owner: KeyPair, conversationID: ConversationID) async throws -> (envelope: ConversationKeyEnvelope, wrappedBy: UserID?) { + /// Fetches the chat key `conversationID` wrapped for the signed-in user and who wrapped it, or + /// the invite's copy for a member who joined by invite and hasn't stored their own yet. + public func getKeyEnvelope(owner: KeyPair, conversationID: ConversationID) async throws -> GroupKeyMaterial { let result = try await withCheckedThrowingContinuation { c in chatService.getKeyEnvelope(owner: owner, conversationID: conversationID) { c.resume(with: $0) } } - // An invite-joined member has only the invite's copy until they store their own envelope. - guard let own = result.ownEnvelope else { throw ErrorGetKeyEnvelope.noEnvelope } - return own + return result.material } // MARK: - Invites - diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 350b2a0ad..be981f58a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -900,11 +900,13 @@ final class ChatService: Sendable { /// returns the invite's copy of the chat key instead. It is not a usable own envelope. case invite(InviteKeyBundle, wrappedBy: UserID?, wrappedByPublicKey: PublicKey?) - /// The user's own envelope and who wrapped it, or `nil` while only the invite copy exists. - var ownEnvelope: (envelope: ConversationKeyEnvelope, wrappedBy: UserID?)? { + /// What ``GroupKeyEnvelopes`` opens the chat key from. + var material: GroupKeyMaterial { switch self { - case .own(let envelope, let wrappedBy, _): (envelope, wrappedBy) - case .invite: nil + case .own(let envelope, let wrappedBy, _): + .own(envelope: envelope, wrappedBy: wrappedBy) + case .invite(let bundle, let wrappedBy, let wrappedByPublicKey): + .invite(bundle, wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift index 16618e06d..7b328a1e5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift @@ -88,7 +88,8 @@ public actor ChatKeyring { selfUserID: UserID, resolveKey: @escaping ResolveKey, setEnvelope: @escaping GroupKeyEnvelopes.SetEnvelope, - getEnvelope: @escaping GroupKeyEnvelopes.GetEnvelope + getEnvelope: @escaping GroupKeyEnvelopes.GetEnvelope, + opensInviteBundles: Bool ) { let groupKeys = GroupKeyEnvelopes( sealer: SharedCoreGroupKeySealer(), @@ -96,7 +97,8 @@ public actor ChatKeyring { selfUserID: selfUserID, setEnvelope: setEnvelope, getEnvelope: getEnvelope, - resolveKey: resolveKey + resolveKey: resolveKey, + opensInviteBundles: opensInviteBundles ) self.init(owner: owner, selfUserID: selfUserID, resolveKey: resolveKey) { conversationID, creator in try await groupKeys.groupKey(for: conversationID, creator: creator) @@ -120,6 +122,8 @@ public actor ChatKeyring { return .awaitingKey } catch is GroupKeyUnavailable { return .awaitingKey + } catch is GroupKeyUnusable { + return .unusableKey } catch { // No peer to decrypt with (a group), or a key shared-core rejects: nothing will open these. return .unsupported @@ -171,8 +175,9 @@ public actor ChatKeyring { return key } - /// A private group's seal. Public groups have no key; an envelope from an untrusted wrapper - /// will never open, while any other failure (network, not yet admitted) may on a later try. + /// A private group's seal. Public groups have no key; an envelope from an untrusted wrapper or + /// an invite copy that failed a check will never open, while any other failure (network, not yet + /// admitted) may on a later try. private func groupSeal(for conversation: Conversation) async throws -> ChatSeal { guard conversation.isPrivate else { throw NoPeer() } let key: Data @@ -180,6 +185,8 @@ public actor ChatKeyring { key = try await groupKey(conversation.id, conversation.creator) } catch let error as GroupKeyNotSelfWrapped { throw error + } catch let error as GroupKeyUnusable { + throw error } catch { throw GroupKeyUnavailable() } @@ -202,6 +209,9 @@ public enum ChatOpener: Sendable { case awaitingKey /// Nothing will ever decrypt this chat's messages on this client. case unsupported + /// A private group joined by invite whose copy of the chat key failed a check. The viewer is a + /// member with no usable key; messages stay undecrypted and unmarked, and leaving is the repair. + case unusableKey /// `messages` with every one awaiting decryption decrypted or marked with why it couldn't be. public func open(_ messages: [ConversationMessage]) -> [ConversationMessage] { @@ -212,9 +222,9 @@ public enum ChatOpener: Sendable { public func open(_ message: ConversationMessage) -> ConversationMessage { guard message.isAwaitingDecryption else { return message } switch self { - case .seal(let seal): return seal.open(message) - case .awaitingKey: return message - case .unsupported: return message.opened(.failure(.unsupported)) + case .seal(let seal): return seal.open(message) + case .awaitingKey, .unusableKey: return message + case .unsupported: return message.opened(.failure(.unsupported)) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 1d5cae617..0afa889f7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -84,8 +84,8 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { public enum SystemEvent: Hashable, Sendable { /// `userID` joined the group, directly or, when `admittedBy` is set, admitted by that user. case userJoined(userID: UserID, admittedBy: UserID?) - /// `userID` joined through an invite link created by `invitedBy`, when the server names them. - case userJoinedViaInvite(userID: UserID, invitedBy: UserID?) + /// `userID` joined through an invite link created by `invitedBy`. + case userJoinedViaInvite(userID: UserID, invitedBy: UserID) /// The users this event names, in reading order, without duplicates and without `selfID`. public func namedUserIDs(excluding selfID: UserID?) -> [UserID] { @@ -98,7 +98,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { return ids case .userJoinedViaInvite(let userID, let invitedBy): var ids: [UserID] = [] - for id in [invitedBy, userID].compactMap({ $0 }) where id != selfID && !ids.contains(id) { + for id in [invitedBy, userID] where id != selfID && !ids.contains(id) { ids.append(id) } return ids @@ -582,7 +582,10 @@ extension ConversationMessage.Content { let admitter = admitted.hasAdmittedBy ? try? UUID(data: admitted.admittedBy.value) : nil return .userJoined(userID: userID, admittedBy: admitter) case .invited(let invited): - let inviter = invited.hasInvitedBy ? try? UUID(data: invited.invitedBy.value) : nil + // `invited_by` is required on the wire; one that doesn't parse reads as a plain join. + guard invited.hasInvitedBy, let inviter = try? UUID(data: invited.invitedBy.value) else { + return .userJoined(userID: userID, admittedBy: nil) + } return .userJoinedViaInvite(userID: userID, invitedBy: inviter) case .direct, .none: return .userJoined(userID: userID, admittedBy: nil) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift index 59f541af1..5d3be395f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift @@ -20,22 +20,58 @@ public protocol GroupKeySealing: Sendable { /// Opens an envelope `wrapper` made for `owner` in `conversationID`. func unwrap(_ envelope: ConversationKeyEnvelope, owner: KeyPair, wrapper: PublicKey, conversationID: ConversationID) throws -> Data + + /// Opens the invite's copy of `conversationID`'s chat key for `owner`. `wrappedByCreator` is + /// whether the bundle's wrapper is the group's creator. Throws ``GroupKeyUnusable`` when the + /// bundle fails any check. + func open(_ bundle: InviteKeyBundle, owner: KeyPair, creatorPublicKey: PublicKey, wrappedByCreator: Bool, conversationID: ConversationID) throws -> Data +} + +/// What the server holds for the viewer in a private group: their own envelope, or, after joining +/// by invite and before storing one, the invite's copy of the chat key. +public enum GroupKeyMaterial: Sendable { + case own(envelope: ConversationKeyEnvelope, wrappedBy: UserID?) + case invite(InviteKeyBundle, wrappedBy: UserID?, wrappedByPublicKey: PublicKey?) } /// Thrown when the stored envelope was wrapped by someone other than the viewer or the group's /// creator, the only wrappers a member trusts. public struct GroupKeyNotSelfWrapped: Error {} +/// Thrown when the invite's copy of the chat key failed a check: a wrapper other than the creator, +/// a bad signature, or an envelope that won't open. The viewer is a member with no usable key, and +/// fetching again won't change that. +public struct GroupKeyUnusable: Error, Equatable { + public enum Step: Sendable, Equatable { + case wrappedBy, signature, secretOpen, keyOpen + } + + /// The check the bundle failed. + public let failsAt: Step + + public init(failsAt: Step) { + self.failsAt = failsAt + } +} + +/// Thrown when an invite bundle can't be opened yet: this instance doesn't open bundles, or the +/// group's creator isn't known. A later fetch may succeed. +public struct InviteBundleNotOpened: Error {} + /// The signed-in user's private-group chat keys, held in memory only. /// /// A key is never written to disk: the server keeps the viewer's self-wrapped envelope, which /// opens with the owner key, so after a relaunch the key is fetched and opened again on first use. /// A creator whose key was lost before its envelope landed sees `noEnvelope` and generates a new /// one; nothing was encrypted under the lost key. +/// +/// A member who joined by invite holds only the invite's copy of the key until they store their +/// own envelope. It is opened and re-wrapped for the viewer on first use; until that write lands, +/// each launch opens the invite's copy again. public actor GroupKeyEnvelopes { public typealias SetEnvelope = @Sendable (ConversationID, ConversationKeyEnvelope) async throws -> Void - public typealias GetEnvelope = @Sendable (ConversationID) async throws -> (envelope: ConversationKeyEnvelope, wrappedBy: UserID?) + public typealias GetEnvelope = @Sendable (ConversationID) async throws -> GroupKeyMaterial /// Fetches a user's Ed25519 public key, used to open the envelope a creator wrapped. public typealias ResolveKey = @Sendable (UserID) async throws -> PublicKey @@ -45,6 +81,7 @@ public actor GroupKeyEnvelopes { private let setEnvelope: SetEnvelope private let getEnvelope: GetEnvelope private let resolveKey: ResolveKey + private let opensInviteBundles: Bool private var keys: [ConversationID: Data] = [:] @@ -54,7 +91,8 @@ public actor GroupKeyEnvelopes { selfUserID: UserID, setEnvelope: @escaping SetEnvelope, getEnvelope: @escaping GetEnvelope, - resolveKey: @escaping ResolveKey + resolveKey: @escaping ResolveKey, + opensInviteBundles: Bool = true ) { self.sealer = sealer self.owner = owner @@ -62,6 +100,7 @@ public actor GroupKeyEnvelopes { self.setEnvelope = setEnvelope self.getEnvelope = getEnvelope self.resolveKey = resolveKey + self.opensInviteBundles = opensInviteBundles } /// Generates `conversationID`'s chat key and stores the creator's own envelope: the second @@ -93,11 +132,14 @@ public actor GroupKeyEnvelopes { /// with the creator's public key and is then re-wrapped for the viewer, best effort, so later /// fetches need only the owner key. Any other wrapper throws ``GroupKeyNotSelfWrapped``. When /// the viewer is `creator` and no envelope exists yet, a new key is generated and stored. + /// + /// The invite's copy is opened the same way, then re-wrapped for the viewer, best effort. One + /// that fails a check throws ``GroupKeyUnusable``. public func groupKey(for conversationID: ConversationID, creator: UserID?) async throws -> Data { if let key = keys[conversationID] { return key } - let stored: (envelope: ConversationKeyEnvelope, wrappedBy: UserID?) + let stored: GroupKeyMaterial do { stored = try await getEnvelope(conversationID) } catch ErrorGetKeyEnvelope.noEnvelope where creator == selfUserID { @@ -109,19 +151,46 @@ public actor GroupKeyEnvelopes { } let key: Data - if stored.wrappedBy == nil || stored.wrappedBy == selfUserID { - key = try sealer.unwrap(stored.envelope, owner: owner, wrapper: owner.publicKey, conversationID: conversationID) - } else if let wrappedBy = stored.wrappedBy, wrappedBy == creator { - let creatorKey = try await resolveKey(wrappedBy) - key = try sealer.unwrap(stored.envelope, owner: owner, wrapper: creatorKey, conversationID: conversationID) + switch stored { + case .own(let envelope, let wrappedBy): + if wrappedBy == nil || wrappedBy == selfUserID { + key = try sealer.unwrap(envelope, owner: owner, wrapper: owner.publicKey, conversationID: conversationID) + } else if let wrappedBy, wrappedBy == creator { + let creatorKey = try await resolveKey(wrappedBy) + key = try sealer.unwrap(envelope, owner: owner, wrapper: creatorKey, conversationID: conversationID) + await storeSelfWrapped(key, in: conversationID) + } else { + throw GroupKeyNotSelfWrapped() + } + case .invite(let bundle, let wrappedBy, let wrappedByPublicKey): + key = try await open(bundle, wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey, creator: creator, in: conversationID) await storeSelfWrapped(key, in: conversationID) - } else { - throw GroupKeyNotSelfWrapped() } keys[conversationID] = key return key } + /// The chat key in the invite's copy, checked in the contract's order: the inviter must be the + /// group's creator before any key is looked at. The creator's key is the one resolved for them + /// when it can be, otherwise the one the server sent with the bundle; the contract accepts that + /// the server could report a false key there, the same trust every envelope rests on. + private func open(_ bundle: InviteKeyBundle, wrappedBy: UserID?, wrappedByPublicKey: PublicKey?, creator: UserID?, in conversationID: ConversationID) async throws -> Data { + guard opensInviteBundles, let creator else { + throw InviteBundleNotOpened() + } + guard wrappedBy == creator else { + throw GroupKeyUnusable(failsAt: .wrappedBy) + } + let creatorKey: PublicKey + do { + creatorKey = try await resolveKey(creator) + } catch { + guard let wrappedByPublicKey else { throw error } + creatorKey = wrappedByPublicKey + } + return try sealer.open(bundle, owner: owner, creatorPublicKey: creatorKey, wrappedByCreator: true, conversationID: conversationID) + } + /// Replaces the creator-wrapped envelope with the viewer's own. A failure leaves the creator's /// in place, which still opens next time. private func storeSelfWrapped(_ key: Data, in conversationID: ConversationID) async { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SharedCoreGroupKeySealer.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SharedCoreGroupKeySealer.swift index 7b4e2b6d6..94aa278d6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SharedCoreGroupKeySealer.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SharedCoreGroupKeySealer.swift @@ -15,6 +15,10 @@ public struct SharedCoreGroupKeySealer: GroupKeySealing, @unchecked Sendable { /// The wire `KeyEnvelope.Scheme` this client reads and writes. public static let scheme = Flipcash_Chat_V1_KeyEnvelope.Scheme.x25519Xchacha20Poly1305 + /// The wire schemes of the two envelopes in an invite's copy of the chat key. + static let inviteKeyScheme = Flipcash_Chat_V1_InviteKeyEnvelope.Scheme.xchacha20Poly1305 + static let inviteSecretScheme = Flipcash_Chat_V1_InviteSecretEnvelope.Scheme.x25519Xchacha20Poly1305 + private let cipher: any ChatCipher public init(cipher: any ChatCipher = DefaultChatCipher.shared) { @@ -54,6 +58,35 @@ public struct SharedCoreGroupKeySealer: GroupKeySealing, @unchecked Sendable { return bytes.data(bytes: key) } + public func open(_ bundle: InviteKeyBundle, owner: KeyPair, creatorPublicKey: PublicKey, wrappedByCreator: Bool, conversationID: ConversationID) throws -> Data { + // A scheme this build doesn't know fails as ``unwrap(_:owner:wrapper:conversationID:)`` does, + // not as the wrong key: a newer build may open it. + guard bundle.keyEnvelope.scheme == Self.inviteKeyScheme.rawValue, + bundle.secretEnvelope.scheme == Self.inviteSecretScheme.rawValue else { + throw UnsupportedKeyEnvelopeScheme() + } + do { + return try SharedInviteCipher.openKeyBundle( + member: SharedEd25519.KeyPair(publicKey: owner.publicKey.data, privateKey: owner.privateKey.data), + creatorPublicKey: creatorPublicKey.data, + wrappedByCreator: wrappedByCreator, + chatId: conversationID.data, + inviteId: bundle.inviteID.data, + keyEnvelope: SharedInviteCipher.KeyEnvelope( + nonce: bundle.keyEnvelope.nonce, + ciphertext: bundle.keyEnvelope.ciphertext, + signature: bundle.keyEnvelope.signature + ), + secretEnvelope: SharedInviteCipher.SecretEnvelope( + nonce: bundle.secretEnvelope.nonce, + ciphertext: bundle.secretEnvelope.ciphertext + ) + ) + } catch let error as SharedInviteCipher.BundleError { + throw GroupKeyUnusable(failsAt: .init(error)) + } + } + private static func keyPair(_ owner: KeyPair) -> SharedCore.KeyPair { let bytes = SharedBytes.shared return SharedCore.KeyPair( @@ -65,3 +98,14 @@ public struct SharedCoreGroupKeySealer: GroupKeySealing, @unchecked Sendable { /// Thrown for a key envelope in a scheme this client cannot open. public struct UnsupportedKeyEnvelopeScheme: Error {} + +private extension GroupKeyUnusable.Step { + init(_ error: SharedInviteCipher.BundleError) { + switch error { + case .wrappedBy: self = .wrappedBy + case .signature: self = .signature + case .secretOpen: self = .secretOpen + case .keyOpen: self = .keyOpen + } + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 86026107b..33804e5c7 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -615,7 +615,6 @@ nonisolated extension Database { var systemUserId: UUID? var systemAdmittedBy: UUID? var systemInvitedBy: UUID? - var systemViaInvite: Bool? if let sealed = message.sealed { encryptedScheme = sealed.scheme encryptedNonce = sealed.nonce @@ -666,7 +665,6 @@ nonisolated extension Database { case .userJoinedViaInvite(let userID, let invitedBy): systemUserId = userID systemInvitedBy = invitedBy - systemViaInvite = true case nil: break } @@ -714,7 +712,6 @@ nonisolated extension Database { m.systemUserId <- systemUserId, m.systemAdmittedBy <- systemAdmittedBy, m.systemInvitedBy <- systemInvitedBy, - m.systemViaInvite <- systemViaInvite, m.cashPaymentsJson <- Self.encodeCashPayments(message.cashPayments), m.cashReceiverId <- message.cashReceiverID ) @@ -948,9 +945,11 @@ nonisolated extension Database { case 6: guard let text = row[m.text] else { return nil } let event = row[m.systemUserId].map { userID in - row[m.systemViaInvite] == true - ? ConversationMessage.SystemEvent.userJoinedViaInvite(userID: userID, invitedBy: row[m.systemInvitedBy]) - : ConversationMessage.SystemEvent.userJoined(userID: userID, admittedBy: row[m.systemAdmittedBy]) + if let invitedBy = row[m.systemInvitedBy] { + ConversationMessage.SystemEvent.userJoinedViaInvite(userID: userID, invitedBy: invitedBy) + } else { + ConversationMessage.SystemEvent.userJoined(userID: userID, admittedBy: row[m.systemAdmittedBy]) + } } content = .system(fallbackText: text, event: event) default: diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 74c6aae84..830941386 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -404,11 +404,9 @@ nonisolated public struct ConversationMessageTable: Sendable { // `systemUserId` is set. public let systemUserId = Expression ("systemUserId") public let systemAdmittedBy = Expression ("systemAdmittedBy") - // A `kind` 6 row's `SystemEvent.userJoinedViaInvite`: the user who created the invite, when the - // server names them. Set only together with `systemUserId` and never with `systemAdmittedBy`; - // `systemViaInvite` is what marks the row as an invite join, since `systemInvitedBy` may be nil. + // A `kind` 6 row's `SystemEvent.userJoinedViaInvite`: the user who created the invite. Set only + // together with `systemUserId` and never with `systemAdmittedBy`; set means an invite join. public let systemInvitedBy = Expression ("systemInvitedBy") - public let systemViaInvite = Expression ("systemViaInvite") // JSON-encoded `[CashPayment]` of a multi-receiver cash row; nil for every other row. public let cashPaymentsJson = Expression ("cashPaymentsJson") // The member a single-receiver group cash row paid; nil for DM cash and every other row. @@ -711,7 +709,6 @@ nonisolated extension Database { t.column(conversationMessageTable.systemUserId) t.column(conversationMessageTable.systemAdmittedBy) t.column(conversationMessageTable.systemInvitedBy) - t.column(conversationMessageTable.systemViaInvite) t.column(conversationMessageTable.cashPaymentsJson) t.column(conversationMessageTable.cashReceiverId) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift index 36c8f584f..19db060db 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift @@ -202,6 +202,26 @@ struct ChatKeyringTests { #expect(await groupRing { _, _ in throw GroupKeyNotSelfWrapped() }.open(message, in: group).decryptFailure == .unsupported) } + @Test("An invite copy that failed a check is the wrong-key state: messages stay covered, and sending fails") + func privateGroupUnusableInviteCopy() async throws { + let group = privateGroup() + let sender = ChatSeal(cipher: DefaultChatCipher.shared, groupKey: groupKey, conversationID: group.id, selfUserID: bobID) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = MessageID(value: 1).proto + $0.senderID = bobID.proto + $0.content = [try! sender.seal(text: "hello", repliedTo: nil)] + } + let message = try #require(ConversationMessage(proto)) + let ring = groupRing { _, _ in throw GroupKeyUnusable(failsAt: .signature) } + + guard case .unusableKey = await ring.opener(for: group) else { + Issue.record("expected the wrong-key opener") + return + } + #expect(await ring.open(message, in: group).isAwaitingDecryption) + await #expect(throws: GroupKeyUnusable.self) { try await ring.sealForSending(in: group) } + } + @Test("Plaintext messages pass through untouched") func plaintext() async { let resolver = Resolver(.failure(Offline())) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 79fe23254..389ed3015 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -88,7 +88,7 @@ struct ConversationModelMappingTests { #expect(message.content == .system(fallbackText: "fallback", event: .userJoinedViaInvite(userID: joiner, invitedBy: inviter))) } - @Test("An invited join without invited_by still reads as an invite join") + @Test("An invited join without a parseable invited_by reads as a plain join") func invitedWithoutInviter() throws { let joiner = UUID() let message = try systemMessage { @@ -97,7 +97,7 @@ struct ConversationModelMappingTests { $0.invited = .init() } } - #expect(message.content == .system(fallbackText: "fallback", event: .userJoinedViaInvite(userID: joiner, invitedBy: nil))) + #expect(message.content == .system(fallbackText: "fallback", event: .userJoined(userID: joiner, admittedBy: nil))) } @Test("A join with no method is a direct join") diff --git a/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopesTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopesTests.swift index b3f6e0b6a..04e6abcaa 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopesTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopesTests.swift @@ -20,9 +20,20 @@ struct GroupKeyEnvelopesTests { private let lock = NSLock() private var _generated: [Data] = [] private var _unwrappedBy: [PublicKey] = [] + private var _bundleOpens: [(creatorPublicKey: PublicKey, wrappedByCreator: Bool)] = [] + private var _bundleError: GroupKeyUnusable? var generated: [Data] { lock.withLock { _generated } } var unwrappedBy: [PublicKey] { lock.withLock { _unwrappedBy } } + var bundleOpens: [(creatorPublicKey: PublicKey, wrappedByCreator: Bool)] { lock.withLock { _bundleOpens } } + /// When set, every bundle fails this check. + var bundleError: GroupKeyUnusable? { + get { lock.withLock { _bundleError } } + set { lock.withLock { _bundleError = newValue } } + } func recordUnwrap(by wrapper: PublicKey) { lock.withLock { _unwrappedBy.append(wrapper) } } + func recordBundleOpen(_ creatorPublicKey: PublicKey, _ wrappedByCreator: Bool) { + lock.withLock { _bundleOpens.append((creatorPublicKey, wrappedByCreator)) } + } func next() -> Data { lock.withLock { let key = Data(repeating: UInt8(_generated.count + 1), count: 32) @@ -42,6 +53,15 @@ struct GroupKeyEnvelopesTests { keys.recordUnwrap(by: wrapper) return envelope.ciphertext } + + /// Opens to the key envelope's ciphertext, failing as shared-core does when the wrapper + /// isn't the creator. + func open(_ bundle: InviteKeyBundle, owner: KeyPair, creatorPublicKey: PublicKey, wrappedByCreator: Bool, conversationID: ConversationID) throws -> Data { + keys.recordBundleOpen(creatorPublicKey, wrappedByCreator) + if let error = keys.bundleError { throw error } + guard wrappedByCreator else { throw GroupKeyUnusable(failsAt: .wrappedBy) } + return bundle.keyEnvelope.ciphertext + } } private final class Server: @unchecked Sendable { @@ -51,6 +71,9 @@ struct GroupKeyEnvelopesTests { private var _gets = 0 var wrappedBy: UserID? var setError: Error? + var getError: Error? + /// What the server returns for an invite-joined member until they store their own envelope. + var inviteCopy: GroupKeyMaterial? /// Lets a set replace a stored envelope, as the server does for a member's self-wrap. var replaces = false @@ -73,11 +96,15 @@ struct GroupKeyEnvelopesTests { } } - func get() throws -> (envelope: ConversationKeyEnvelope, wrappedBy: UserID?) { + func get() throws -> GroupKeyMaterial { try lock.withLock { _gets += 1 - guard let _stored else { throw ErrorGetKeyEnvelope.noEnvelope } - return (_stored, wrappedBy) + if let getError { throw getError } + guard let _stored else { + if let inviteCopy { return inviteCopy } + throw ErrorGetKeyEnvelope.noEnvelope + } + return .own(envelope: _stored, wrappedBy: wrappedBy) } } } @@ -89,7 +116,7 @@ struct GroupKeyEnvelopesTests { private let creatorID = UUID() private let creatorKey = KeyPair.generate()!.publicKey - private func envelopes(_ server: Server) -> GroupKeyEnvelopes { + private func envelopes(_ server: Server, opensInviteBundles: Bool = true) -> GroupKeyEnvelopes { GroupKeyEnvelopes( sealer: FakeSealer(keys: keys), owner: owner, @@ -99,7 +126,20 @@ struct GroupKeyEnvelopesTests { resolveKey: { [creatorID, creatorKey] userID in guard userID == creatorID else { throw ErrorGetKeyEnvelope.notFound } return creatorKey - } + }, + opensInviteBundles: opensInviteBundles + ) + } + + private func inviteCopy(key: Data, wrappedBy: UserID?, wrappedByPublicKey: PublicKey? = nil) -> GroupKeyMaterial { + .invite( + InviteKeyBundle( + inviteID: InviteID(data: Data(repeating: 7, count: 32)), + keyEnvelope: InviteKeyEnvelope(scheme: 1, nonce: Data(), ciphertext: key, signature: Data()), + secretEnvelope: InviteSecretEnvelope(scheme: 1, nonce: Data(), ciphertext: Data()) + ), + wrappedBy: wrappedBy, + wrappedByPublicKey: wrappedByPublicKey ) } @@ -212,4 +252,144 @@ struct GroupKeyEnvelopesTests { #expect(server.sets == 0) #expect(keys.generated.isEmpty) } + + // MARK: - Invite copy - + + @Test("An invite-joined member opens the invite's copy with the creator's key, then stores their own") + func inviteCopyOpensAndSelfWraps() async throws { + let key = Data(repeating: 4, count: 32) + let server = Server() + server.inviteCopy = inviteCopy(key: key, wrappedBy: creatorID) + let sut = envelopes(server) + + #expect(try await sut.groupKey(for: chat, creator: creatorID) == key) + #expect(keys.bundleOpens.map(\.creatorPublicKey) == [creatorKey]) + #expect(keys.bundleOpens.map(\.wrappedByCreator) == [true]) + #expect(server.sets == 1) + #expect(server.stored?.nonce == owner.publicKey.data) + #expect(server.stored?.ciphertext == key) + + // Cached: no second fetch, and a fresh instance opens the stored self-wrap, not the bundle. + #expect(try await sut.groupKey(for: chat, creator: creatorID) == key) + #expect(server.gets == 1) + #expect(try await envelopes(server).groupKey(for: chat, creator: creatorID) == key) + #expect(keys.bundleOpens.count == 1) + } + + @Test("A bundle that fails a check is the wrong-key state, and nothing is stored") + func inviteCopyRejected() async throws { + let server = Server() + server.inviteCopy = inviteCopy(key: Data(repeating: 4, count: 32), wrappedBy: creatorID) + keys.bundleError = GroupKeyUnusable(failsAt: .signature) + let sut = envelopes(server) + + await #expect(throws: GroupKeyUnusable(failsAt: .signature)) { + try await sut.groupKey(for: chat, creator: creatorID) + } + #expect(server.sets == 0) + } + + @Test("A bundle someone other than the creator wrapped is rejected, never re-keyed") + func inviteCopyWrongWrapper() async throws { + let server = Server() + server.inviteCopy = inviteCopy(key: Data(repeating: 4, count: 32), wrappedBy: UUID(), wrappedByPublicKey: KeyPair.generate()!.publicKey) + let sut = envelopes(server) + + await #expect(throws: GroupKeyUnusable(failsAt: .wrappedBy)) { + try await sut.groupKey(for: chat, creator: creatorID) + } + // Rejected before the bundle is opened or any key is used. + #expect(keys.bundleOpens.isEmpty) + #expect(keys.generated.isEmpty) + #expect(server.sets == 0) + } + + @Test("A failed self-wrap still returns the key, and the next launch opens the invite's copy again") + func inviteCopySelfWrapFailsThenRetries() async throws { + let key = Data(repeating: 4, count: 32) + let server = Server() + server.inviteCopy = inviteCopy(key: key, wrappedBy: creatorID) + server.setError = ErrorSetKeyEnvelope.unknown + + #expect(try await envelopes(server).groupKey(for: chat, creator: creatorID) == key) + #expect(server.stored == nil) + + server.setError = nil + #expect(try await envelopes(server).groupKey(for: chat, creator: creatorID) == key) + #expect(keys.bundleOpens.count == 2) + #expect(server.stored?.ciphertext == key) + } + + @Test("A failed fetch caches nothing, so the next call fetches and opens the invite's copy") + func inviteCopyFetchFailsThenRetries() async throws { + let key = Data(repeating: 4, count: 32) + let server = Server() + server.inviteCopy = inviteCopy(key: key, wrappedBy: creatorID) + server.getError = ErrorGetKeyEnvelope.transportFailure + let sut = envelopes(server) + + await #expect(throws: ErrorGetKeyEnvelope.self) { + try await sut.groupKey(for: chat, creator: creatorID) + } + + server.getError = nil + #expect(try await sut.groupKey(for: chat, creator: creatorID) == key) + } + + @Test("The creator's resolved key is preferred over the one sent with the bundle") + func inviteCopyPrefersResolvedKey() async throws { + let key = Data(repeating: 4, count: 32) + let server = Server() + server.inviteCopy = inviteCopy(key: key, wrappedBy: creatorID, wrappedByPublicKey: KeyPair.generate()!.publicKey) + + #expect(try await envelopes(server).groupKey(for: chat, creator: creatorID) == key) + #expect(keys.bundleOpens.map(\.creatorPublicKey) == [creatorKey]) + } + + @Test("A bundle with no wrapped_by is rejected even when a key was sent") + func inviteCopyNoWrapper() async throws { + let server = Server() + server.inviteCopy = inviteCopy(key: Data(repeating: 4, count: 32), wrappedBy: nil, wrappedByPublicKey: creatorKey) + + await #expect(throws: GroupKeyUnusable(failsAt: .wrappedBy)) { + try await envelopes(server).groupKey(for: chat, creator: creatorID) + } + #expect(keys.bundleOpens.isEmpty) + } + + @Test("The creator's key falls back to the one sent with the bundle when it can't be resolved") + func inviteCopyFallsBackToSentKey() async throws { + let key = Data(repeating: 4, count: 32) + let unresolved = UUID() + let sentKey = KeyPair.generate()!.publicKey + let server = Server() + server.inviteCopy = inviteCopy(key: key, wrappedBy: unresolved, wrappedByPublicKey: sentKey) + + #expect(try await envelopes(server).groupKey(for: chat, creator: unresolved) == key) + #expect(keys.bundleOpens.map(\.creatorPublicKey) == [sentKey]) + } + + @Test("An instance that doesn't open invite copies leaves them unopened and writes nothing") + func inviteCopyNotOpenedByExtension() async throws { + let server = Server() + server.inviteCopy = inviteCopy(key: Data(repeating: 4, count: 32), wrappedBy: creatorID) + let sut = envelopes(server, opensInviteBundles: false) + + await #expect(throws: InviteBundleNotOpened.self) { + try await sut.groupKey(for: chat, creator: creatorID) + } + #expect(keys.bundleOpens.isEmpty) + #expect(server.sets == 0) + } + + @Test("An invite copy whose creator isn't known yet is retried later, not marked wrong-key") + func inviteCopyCreatorUnknown() async throws { + let server = Server() + server.inviteCopy = inviteCopy(key: Data(repeating: 4, count: 32), wrappedBy: creatorID) + + await #expect(throws: InviteBundleNotOpened.self) { + try await envelopes(server).groupKey(for: chat, creator: nil) + } + #expect(keys.bundleOpens.isEmpty) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SharedCoreGroupKeySealerTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SharedCoreGroupKeySealerTests.swift index 897632762..5c12cee13 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SharedCoreGroupKeySealerTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SharedCoreGroupKeySealerTests.swift @@ -45,4 +45,17 @@ struct SharedCoreGroupKeySealerTests { try sealer.unwrap(envelope, owner: member, wrapper: host.publicKey, conversationID: other) } } + + @Test("A bundle in a scheme this build doesn't know is unsupported, not the wrong key") + func inviteBundleUnknownScheme() { + let bundle = InviteKeyBundle( + inviteID: InviteID(data: Data(repeating: 1, count: 32)), + keyEnvelope: InviteKeyEnvelope(scheme: 99, nonce: Data(count: 24), ciphertext: Data(count: 48), signature: Data(count: 64)), + secretEnvelope: InviteSecretEnvelope(scheme: 1, nonce: Data(count: 24), ciphertext: Data(count: 48)) + ) + + #expect(throws: UnsupportedKeyEnvelopeScheme.self) { + try sealer.open(bundle, owner: member, creatorPublicKey: host.publicKey, wrappedByCreator: true, conversationID: chat) + } + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift index db9ecf67c..471e57d0e 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift @@ -69,7 +69,6 @@ struct SystemNoticeStringsTests { func invitedJoin() { #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: bob), known: [ada: "Ada", bob: "Bob"]) == "Ada joined the group") #expect(text(.userJoinedViaInvite(userID: me, invitedBy: bob)) == "You joined the group") - #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: nil)) == "fallback") } @Test("A notice with no event shows its fallback text") diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 960a9e54b..7b04b908b 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -575,8 +575,7 @@ struct DatabaseConversationsTests { [system(1, .userJoined(userID: joiner, admittedBy: nil)), system(2, .userJoined(userID: joiner, admittedBy: admitter)), system(3, nil), - system(4, .userJoinedViaInvite(userID: joiner, invitedBy: admitter)), - system(5, .userJoinedViaInvite(userID: joiner, invitedBy: nil))], + system(4, .userJoinedViaInvite(userID: joiner, invitedBy: admitter))], conversationID: id ) let loaded = try database.getConversationMessages(conversationID: id) @@ -585,7 +584,6 @@ struct DatabaseConversationsTests { .system(fallbackText: "notice 2", event: .userJoined(userID: joiner, admittedBy: admitter)), .system(fallbackText: "notice 3", event: nil), .system(fallbackText: "notice 4", event: .userJoinedViaInvite(userID: joiner, invitedBy: admitter)), - .system(fallbackText: "notice 5", event: .userJoinedViaInvite(userID: joiner, invitedBy: nil)), ]) } @@ -791,6 +789,19 @@ struct DatabaseConversationsTests { #expect(loaded.isMuted(at: expiry) == false) } + @Test("canInvite round-trips, so a host's invite entry points survive a cold start") + func viewerStateCanInviteRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + var stored = conversation(byte: 1) + stored.viewerState = ConversationViewerState(canInvite: true) + + try database.upsertConversation(stored) + + let loaded = try #require(try database.getConversations().first) + #expect(loaded.viewerState?.canInvite == true) + } + @Test("A conversation with no viewer state round-trips as none") func viewerStateAbsentRoundTrip() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/Regressions/Regression_6ace7ba489c0031619d1c6c8.swift b/FlipcashTests/Regressions/Regression_6ace7ba489c0031619d1c6c8.swift index 13d9a4369..239fe43e2 100644 --- a/FlipcashTests/Regressions/Regression_6ace7ba489c0031619d1c6c8.swift +++ b/FlipcashTests/Regressions/Regression_6ace7ba489c0031619d1c6c8.swift @@ -42,7 +42,8 @@ struct Regression_6ace7ba { selfUserID: selfUserID, resolveKey: { _ in throw ErrorGetKeyEnvelope.notFound }, setEnvelope: { _, _ in }, - getEnvelope: { _ in (envelope, nil) } + getEnvelope: { _ in .own(envelope: envelope, wrappedBy: nil) }, + opensInviteBundles: false ) let seal = try #require(try await keyring.sealForSending(in: privateGroup())) diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index 946c4e921..9b7e0de5e 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -20,7 +20,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.17.0")) }() let package = Package( diff --git a/ShareExtension/ShareModel.swift b/ShareExtension/ShareModel.swift index 022ed15c2..d2972a29f 100644 --- a/ShareExtension/ShareModel.swift +++ b/ShareExtension/ShareModel.swift @@ -310,7 +310,10 @@ final class ShareModel { }, getEnvelope: { conversationID in try await flipClient.getKeyEnvelope(owner: owner, conversationID: conversationID) - } + }, + // Like the notification extension, this only opens an invite-joined member's own + // envelope; the app opens the invite's copy and stores it. + opensInviteBundles: false ) let conversation = try await flipClient.getChat(owner: owner, conversationID: chat.id) let seal = try await keyring.sealForSending(in: conversation)