Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion CrossPlatformVectors/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
2 changes: 1 addition & 1 deletion FlipcashCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,17 @@ 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(),
owner: owner,
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)
Expand All @@ -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
Expand Down Expand Up @@ -171,15 +175,18 @@ 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
do {
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()
}
Expand All @@ -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] {
Expand All @@ -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))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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] = [:]

Expand All @@ -54,14 +91,16 @@ 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
self.selfUserID = selfUserID
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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading