Got the '/inbox' APIs and encryption/decryption/validation working

Added a few types to make the code more readable
Added the inbox request to the polling
Added a couple of properties to the TSContactThread to indicate the originating open group to support SOGS DMs
Added code to store the latest message id for an open group inbox
Added a bunch of documentation from the API docs into the OpenGroupAPI (and associated models)
Updated the OpenGroupAPI to match the latest docs
Fixed the incorrect structure of the SendDirectMessageRequest
Fixed an incorrect inbox endpoint path
Tweaked the batch response handling so it wouldn't fail to parse all responses if a single one failed
Renamed IdPrefix to SessionId.Prefix and cleaned up the type to be more readable & self-documenting
This commit is contained in:
Morgan Pretty 2022-02-25 11:59:29 +11:00
parent faa8918cd4
commit dbead5e3c8
40 changed files with 1221 additions and 324 deletions

View file

@ -774,7 +774,7 @@
FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; };
FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; };
FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; };
FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */; };
FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201D27B0D87C00FEA984 /* SessionId.swift */; };
FD5D202027B0E67900FEA984 /* String+Encoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201F27B0E67800FEA984 /* String+Encoding.swift */; };
FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; };
FD705A8C278CDB5600F16121 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */; };
@ -793,7 +793,7 @@
FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; };
FDC4380927B31D4E00C60D73 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* Error.swift */; };
FDC4380B27B31D7E00C60D73 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380A27B31D7E00C60D73 /* Request.swift */; };
FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */; };
FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; };
FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; };
FDC4381A27B34EBA00C60D73 /* LegacyCompactPollBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */; };
FDC4381C27B354AC00C60D73 /* LegacyPublicKeyBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */; };
@ -837,7 +837,6 @@
FDC4389D27BA01F000C60D73 /* TestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4389C27BA01F000C60D73 /* TestStorage.swift */; };
FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A327BB107F00C60D73 /* UserBanRequest.swift */; };
FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; };
FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */; };
FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; };
FDC438AC27BB145200C60D73 /* UserDeleteMessagesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */; };
FDC438AE27BB148700C60D73 /* UserDeleteMessagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */; };
@ -1915,7 +1914,7 @@
FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; };
FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; };
FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = "<group>"; };
FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPrefix.swift; sourceTree = "<group>"; };
FD5D201D27B0D87C00FEA984 /* SessionId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionId.swift; sourceTree = "<group>"; };
FD5D201F27B0E67800FEA984 /* String+Encoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Encoding.swift"; sourceTree = "<group>"; };
FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = "<group>"; };
FD705A8B278CDB5600F16121 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = "<group>"; };
@ -1937,7 +1936,7 @@
FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = "<group>"; };
FDC4380827B31D4E00C60D73 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = "<group>"; };
FDC4380A27B31D7E00C60D73 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator16Byte.swift; sourceTree = "<group>"; };
FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = "<group>"; };
FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = "<group>"; };
FDC4381927B34EBA00C60D73 /* LegacyCompactPollBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyCompactPollBody.swift; sourceTree = "<group>"; };
FDC4381B27B354AC00C60D73 /* LegacyPublicKeyBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPublicKeyBody.swift; sourceTree = "<group>"; };
@ -1979,7 +1978,6 @@
FDC4389C27BA01F000C60D73 /* TestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStorage.swift; sourceTree = "<group>"; };
FDC438A327BB107F00C60D73 /* UserBanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBanRequest.swift; sourceTree = "<group>"; };
FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUnbanRequest.swift; sourceTree = "<group>"; };
FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissionsRequest.swift; sourceTree = "<group>"; };
FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModeratorRequest.swift; sourceTree = "<group>"; };
FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesRequest.swift; sourceTree = "<group>"; };
FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDeleteMessagesResponse.swift; sourceTree = "<group>"; };
@ -2553,7 +2551,7 @@
C38EF23D255B6D66007E1867 /* UIView+OWS.h */,
C38EF23E255B6D66007E1867 /* UIView+OWS.m */,
C38EF2EF255B6DBB007E1867 /* Weak.swift */,
FD5D201D27B0D87C00FEA984 /* IdPrefix.swift */,
FD5D201D27B0D87C00FEA984 /* SessionId.swift */,
);
path = General;
sourceTree = "<group>";
@ -3846,7 +3844,7 @@
FDC4381F27B36ADC00C60D73 /* Endpoint.swift */,
FDC4380827B31D4E00C60D73 /* Error.swift */,
FDC4381627B32EC700C60D73 /* Personalization.swift */,
FDC4381427B329CE00C60D73 /* NonceGenerator16Byte.swift */,
FDC4381427B329CE00C60D73 /* NonceGenerator.swift */,
FDC438C027BB4E6800C60D73 /* Dependencies.swift */,
FDC438C227BB512200C60D73 /* SodiumProtocols.swift */,
);
@ -3871,7 +3869,6 @@
FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */,
FDC438A327BB107F00C60D73 /* UserBanRequest.swift */,
FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */,
FDC438A727BB11CD00C60D73 /* UserPermissionsRequest.swift */,
FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */,
FDC438AB27BB145200C60D73 /* UserDeleteMessagesRequest.swift */,
FDC438AD27BB148700C60D73 /* UserDeleteMessagesResponse.swift */,
@ -5087,7 +5084,7 @@
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */,
C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */,
B8BC00C0257D90E30032E807 /* General.swift in Sources */,
FD5D201E27B0D87C00FEA984 /* IdPrefix.swift in Sources */,
FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */,
C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */,
C3D9E41F25676C870040E4F3 /* OWSPrimaryStorageProtocol.swift in Sources */,
C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */,
@ -5211,7 +5208,7 @@
FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */,
FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */,
C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */,
FDC4381527B329CE00C60D73 /* NonceGenerator16Byte.swift in Sources */,
FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */,
C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */,
FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */,
C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */,
@ -5278,7 +5275,6 @@
C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */,
C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */,
C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */,
FDC438A827BB11CD00C60D73 /* UserPermissionsRequest.swift in Sources */,
C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */,
FDC438C127BB4E6800C60D73 /* Dependencies.swift in Sources */,
FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */,

View file

@ -66,4 +66,5 @@ protocol MessageCellDelegate : AnyObject {
func openURL(_ url: URL)
func handleReplyButtonTapped(for viewItem: ConversationViewItem)
func showUserDetails(for sessionID: String)
func startThread(with sessionID: String, openGroupServer: String, openGroupPublicKey: String)
}

View file

@ -497,12 +497,27 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
let location = gestureRecognizer.location(in: self)
if profilePictureView.frame.contains(location) && VisibleMessageCell.shouldShowProfilePicture(for: viewItem) {
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
guard !message.isOpenGroupMessage else { return } // Do not show user details to prevent spam
delegate?.showUserDetails(for: message.authorId)
} else if replyButton.frame.contains(location) {
// For open groups only attempt to start a conversation if the author has a blinded id
if message.isOpenGroupMessage {
guard SessionId.Prefix(from: message.authorId) == .blinded else { return }
guard let openGroup: OpenGroup = Storage.shared.getOpenGroup(for: message.uniqueThreadId) else { return }
delegate?.startThread(
with: message.authorId,
openGroupServer: openGroup.server,
openGroupPublicKey: openGroup.publicKey
)
}
else {
delegate?.showUserDetails(for: message.authorId)
}
}
else if replyButton.frame.contains(location) {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
reply()
} else {
}
else {
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
}
}

View file

@ -34,30 +34,32 @@ extension Storage {
if let sender: String = message.sender, let openGroupID: String = openGroupID {
guard let userEdKeyPair: Box.KeyPair = Storage.shared.getUserED25519KeyPair() else { return nil }
switch IdPrefix(with: sender) {
case .blinded:
let sodium: Sodium = Sodium()
let serverNameParts: [String.SubSequence] = openGroupID.split(separator: ".")
let serverName: String = serverNameParts[0..<(serverNameParts.count - 1)].joined(separator: ".")
// Note: This is horrible but it doesn't look like there is going to be a nicer way to do it...
guard let serverPublicKey: String = Storage.shared.getOpenGroupPublicKey(for: serverName) else {
return nil
}
guard let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: sodium.genericHash) else {
return nil
}
isOutgoingMessage = (sender == IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey))
case .standard, .unblinded:
isOutgoingMessage = (
message.sender == getUserPublicKey() ||
sender == IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey)
)
case .none:
isOutgoingMessage = false
if let senderSessionId: SessionId = SessionId(from: sender) {
switch senderSessionId.prefix {
case .blinded:
let sodium: Sodium = Sodium()
let serverNameParts: [String.SubSequence] = openGroupID.split(separator: ".")
let serverName: String = serverNameParts[0..<(serverNameParts.count - 1)].joined(separator: ".")
// Note: This is horrible but it doesn't look like there is going to be a nicer way to do it...
guard let serverPublicKey: String = Storage.shared.getOpenGroupPublicKey(for: serverName) else {
return nil
}
guard let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: sodium.genericHash) else {
return nil
}
isOutgoingMessage = (sender == SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString)
case .standard, .unblinded:
isOutgoingMessage = (
message.sender == getUserPublicKey() ||
sender == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString
)
}
}
else {
isOutgoingMessage = false
}
}
else {

View file

@ -110,6 +110,25 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi
fileIds: fileIds
)
}
else if rawDestination.removePrefix("openGroupInbox(") {
guard rawDestination.removeSuffix(")") else { return nil }
let components = rawDestination
.split(separator: ",")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard components.count == 3 else { return nil }
let server: String = components[0]
let openGroupPublicKey: String = components[1]
let blindedPublicKey: String = components[2]
destination = .openGroupInbox(
server: server,
openGroupPublicKey: openGroupPublicKey,
blindedPublicKey: blindedPublicKey
)
}
else {
return nil
}
@ -141,6 +160,9 @@ public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCodi
"openGroupV4(\(room), \(server), \(whisperToString), \(whisperModsString), [\(fileIdString)])",
forKey: "destination"
)
case .openGroupInbox(let server, let openGroupPublicKey, let blindedPublicKey):
coder.encode("openGroupInbox(\(server), \(openGroupPublicKey), \(blindedPublicKey)", forKey: "destination")
}
coder.encode(id, forKey: "id")

View file

@ -12,9 +12,22 @@ public extension Message {
whisperMods: Bool = false,
fileIds: [Int64]? = nil // TODO: Handle 'fileIds'
)
case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String)
static func from(_ thread: TSThread) -> Message.Destination {
if let thread = thread as? TSContactThread {
if SessionId.Prefix(from: thread.contactSessionID()) == .blinded {
guard let server: String = thread.originalOpenGroupServer, let publicKey: String = thread.originalOpenGroupPublicKey else {
preconditionFailure("Attempting to send message to blinded id without the Open Group information")
}
return .openGroupInbox(
server: server,
openGroupPublicKey: publicKey,
blindedPublicKey: thread.contactSessionID()
)
}
return .contact(publicKey: thread.contactSessionID())
}

View file

@ -70,24 +70,39 @@ extension OpenGroupAPI {
// MARK: - BatchSubResponse<T>
struct BatchSubResponse<T: Codable>: Codable {
/// The numeric http response code (e.g. 200 for success)
let code: Int32
/// This should always include the content type of the request
let headers: [String: String]
let body: T
/// The body of the request; will be plain json if content-type is `application/json`, otherwise it will be base64 encoded data
let body: T?
/// A flag to indicate that there was a body but it failed to parse
let failedToParseBody: Bool
}
// MARK: - BatchRequestInfo<T, R>
struct BatchRequestInfo<T: Encodable, R: Codable>: BatchRequestInfoType {
struct BatchRequestInfo<T: Encodable>: BatchRequestInfoType {
let request: Request<T>
let responseType: Codable.Type
var endpoint: Endpoint { request.endpoint }
init(request: Request<T>, responseType: R.Type) {
init<R: Codable>(request: Request<T>, responseType: R.Type) {
self.request = request
self.responseType = BatchSubResponse<R>.self
}
init(request: Request<T>) {
self.init(
request: request,
responseType: NoResponse.self
)
}
func toSubRequest() -> BatchSubRequest {
return BatchSubRequest(request: request)
}
@ -97,7 +112,21 @@ extension OpenGroupAPI {
typealias BatchRequest = [BatchSubRequest]
typealias BatchResponseTypes = [Codable.Type]
typealias BatchResponse = [(OnionRequestResponseInfoType, Codable)]
typealias BatchResponse = [(OnionRequestResponseInfoType, Codable?)]
}
extension OpenGroupAPI.BatchSubResponse {
init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
let body: T? = try? container.decode(T.self, forKey: .body)
self = OpenGroupAPI.BatchSubResponse(
code: try container.decode(Int32.self, forKey: .code),
headers: try container.decode([String: String].self, forKey: .headers),
body: body,
failedToParseBody: (body == nil && T.self != OpenGroupAPI.NoResponse.self)
)
}
}
// MARK: - BatchRequestInfoType

View file

@ -7,13 +7,24 @@ extension OpenGroupAPI {
enum CodingKeys: String, CodingKey {
case id
case sender
case posted = "posted_at"
case expires = "expires_at"
case base64EncodedData = "data"
case base64EncodedMessage = "message"
}
/// The unique integer message id
public let id: Int64
/// The (blinded) Session ID of the sender of the message
public let sender: String
/// Unix timestamp when the message was received by SOGS
public let posted: TimeInterval
/// Unix timestamp when SOGS will expire and delete the message
public let expires: TimeInterval
public let base64EncodedData: String
/// The encrypted message body
public let base64EncodedMessage: String
}
}

View file

@ -51,10 +51,10 @@ extension OpenGroupAPI.Message {
throw OpenGroupAPI.Error.parsingFailed
}
// Verify the signature based on the IdPrefix
// Verify the signature based on the SessionId.Prefix type
let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded())
switch IdPrefix(with: sender) {
switch SessionId.Prefix(from: sender) {
case .blinded:
guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else {
SNLog("Ignoring message with invalid signature.")

View file

@ -10,8 +10,13 @@ extension OpenGroupAPI {
case pinnedBy = "pinned_by"
}
/// The numeric message id
let id: Int64
/// The unix timestamp when the message was pinned
let pinnedAt: TimeInterval
/// The session ID of the admin who pinned this message (which is not necessarily the same as the author of the message)
let pinnedBy: String
}
}

View file

@ -6,15 +6,15 @@ extension OpenGroupAPI {
public struct Room: Codable {
enum CodingKeys: String, CodingKey {
case token
case created
case name
case description
case imageId = "image_id"
case infoUpdates = "info_updates"
case messageSequence = "message_sequence"
case created
case activeUsers = "active_users"
case activeUsersCutoff = "active_users_cutoff"
case imageId = "image_id"
case pinnedMessages = "pinned_messages"
case admin
@ -29,40 +29,118 @@ extension OpenGroupAPI {
case read
case defaultRead = "default_read"
case defaultAccessible = "default_accessible"
case write
case defaultWrite = "default_write"
case upload
case defaultUpload = "default_upload"
}
/// The room token as used in a URL, e.g. "sudoku"
public let token: String
public let created: TimeInterval
/// The room name typically shown to users, e.g. "Sodoku Solvers"
public let name: String
/// Text description of the room, e.g. "All the best sodoku discussion!"
public let description: String?
/// Monotonic integer counter that increases whenever the room's metadata changes
public let infoUpdates: Int64
/// Monotonic room post counter that increases each time a message is posted, edited, or deleted in this room
///
/// Note that changes to this field do not imply an update the room's info_updates value, nor vice versa
public let messageSequence: Int64
/// Unix timestamp (as a float) of the room creation time. Note that unlike earlier versions of SOGS, this is a proper
/// seconds-since-epoch unix timestamp, not a javascript-style millisecond value
public let created: TimeInterval
/// Number of recently active users in the room over a recent time period (as given in the active_users_cutoff value)
///
/// Users are considered "active" if they have accessed the room (checking for new messages, etc.) at least once in the given period
///
/// **Note:** changes to this field do not update the room's info_updates value
public let activeUsers: Int64
/// The length of time (in seconds) of the active_users period. Defaults to a week (604800), but the open group administrator can configure it
public let activeUsersCutoff: Int64
/// File ID of an uploaded file containing the room's image
///
/// Omitted if there is no image
public let imageId: Int64?
public let infoUpdates: Int64
public let messageSequence: Int64
public let activeUsers: Int64
public let activeUsersCutoff: Int64
/// Array of pinned message information (omitted entirely if there are no pinned messages)
public let pinnedMessages: [PinnedMessage]?
/// This flag is `true` if the current user has admin permissions in the room
public let admin: Bool
/// This flag is `true` if the current user is a global admin
///
/// This is not exclusive of `globalModerator`/`moderator`/`admin` (a global admin will have all four set to `true`)
public let globalAdmin: Bool
/// Array of Session IDs of the room's publicly viewable moderators
///
/// This does not include room moderator nor hidden admins
public let admins: [String]
/// Array of Session IDs of the room's publicly hidden admins
///
/// This field is only included if the requesting user has moderator or admin permissions, and is omitted if empty
public let hiddenAdmins: [String]?
/// This flag is `true` if the current user has moderator permissions in the room
public let moderator: Bool
/// This flag is `true` if the current user is a global moderator
///
/// This is not exclusive of `moderator` (a global moderator will have both flags set to `true`)
public let globalModerator: Bool
/// Array of Session IDs of the room's publicly viewable moderators
///
/// This does not include room administrators nor hidden moderators
public let moderators: [String]
/// Array of Session IDs of the room's publicly hidden moderators
///
/// This field is only included if the requesting user has moderator or admin permissions, and is omitted if empty
public let hiddenModerators: [String]?
/// This flag indicates whether the **current** user has permission to read the room's messages
///
/// **Note:** If this value is `false` the user only has access the room metadata
public let read: Bool
public let defaultRead: Bool
/// This field indicates whether new users have read permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultRead: Bool?
/// This field indicates whether new users have access permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultAccessible: Bool?
/// This flag indicates whether the **current** user has permission to post messages in the room
public let write: Bool
public let defaultWrite: Bool
/// This field indicates whether new users have write permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultWrite: Bool?
/// This flag indicates whether the **current** user has permission to upload files to the room
public let upload: Bool
public let defaultUpload: Bool
/// This field indicates whether new users have upload permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultUpload: Bool?
}
}
@ -74,15 +152,15 @@ extension OpenGroupAPI.Room {
self = OpenGroupAPI.Room(
token: try container.decode(String.self, forKey: .token),
created: try container.decode(TimeInterval.self, forKey: .created),
name: try container.decode(String.self, forKey: .name),
description: try? container.decode(String.self, forKey: .description),
imageId: try? container.decode(Int64.self, forKey: .imageId),
infoUpdates: try container.decode(Int64.self, forKey: .infoUpdates),
messageSequence: try container.decode(Int64.self, forKey: .messageSequence),
created: try container.decode(TimeInterval.self, forKey: .created),
activeUsers: try container.decode(Int64.self, forKey: .activeUsers),
activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff),
imageId: try? container.decode(Int64.self, forKey: .imageId),
pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages),
admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false),
@ -96,12 +174,12 @@ extension OpenGroupAPI.Room {
hiddenModerators: try? container.decode([String].self, forKey: .hiddenModerators),
read: try container.decode(Bool.self, forKey: .read),
defaultRead: ((try? container.decode(Bool.self, forKey: .defaultRead)) ?? false),
defaultRead: try? container.decode(Bool.self, forKey: .defaultRead),
defaultAccessible: try? container.decode(Bool.self, forKey: .defaultAccessible),
write: try container.decode(Bool.self, forKey: .write),
defaultWrite: ((try? container.decode(Bool.self, forKey: .defaultWrite)) ?? false),
defaultWrite: try? container.decode(Bool.self, forKey: .defaultWrite),
upload: try container.decode(Bool.self, forKey: .upload),
defaultUpload: ((try? container.decode(Bool.self, forKey: .defaultUpload)) ?? false)
defaultUpload: try? container.decode(Bool.self, forKey: .defaultUpload)
)
}
}

View file

@ -17,6 +17,7 @@ extension OpenGroupAPI {
case read
case defaultRead = "default_read"
case defaultAccessible = "default_accessible"
case write
case defaultWrite = "default_write"
case upload
@ -25,22 +26,65 @@ extension OpenGroupAPI {
case details
}
/// The room token as used in a URL, e.g. "sudoku"
public let token: String?
public let activeUsers: Int64?
public let admin: Bool?
public let globalAdmin: Bool?
/// Number of recently active users in the room over a recent time period (as given in the active_users_cutoff value)
///
/// Users are considered "active" if they have accessed the room (checking for new messages, etc.) at least once in the given period
///
/// **Note:** changes to this field do not update the room's info_updates value
public let activeUsers: Int64
public let moderator: Bool?
public let globalModerator: Bool?
/// This flag is `true` if the current user has admin permissions in the room
public let admin: Bool
public let read: Bool?
/// This flag is `true` if the current user is a global admin
///
/// This is not exclusive of `globalModerator`/`moderator`/`admin` (a global admin will have all four set to `true`)
public let globalAdmin: Bool
/// This flag is `true` if the current user has moderator permissions in the room
public let moderator: Bool
/// This flag is `true` if the current user is a global moderator
///
/// This is not exclusive of `moderator` (a global moderator will have both flags set to `true`)
public let globalModerator: Bool
/// This flag indicates whether the **current** user has permission to read the room's messages
///
/// **Note:** If this value is `false` the user only has access the room metadata
public let read: Bool
/// This field indicates whether new users have read permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultRead: Bool?
public let write: Bool?
/// This field indicates whether new users have access permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultAccessible: Bool?
/// This flag indicates whether the **current** user has permission to post messages in the room
public let write: Bool
/// This field indicates whether new users have write permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultWrite: Bool?
public let upload: Bool?
/// This flag indicates whether the **current** user has permission to upload files to the room
public let upload: Bool
/// This field indicates whether new users have upload permissions in the room
///
/// It is included in the response only if the requesting user has moderator or admin permissions
public let defaultUpload: Bool?
/// The full room metadata (as would be returned by the `/rooms/{roomToken}` endpoint)
///
/// Only populated and different if the `info_updates` counter differs from the provided `info_updated` value
public let details: Room?
}
@ -59,6 +103,7 @@ extension OpenGroupAPI.RoomPollInfo {
globalModerator: room.globalModerator,
read: room.read,
defaultRead: room.defaultRead,
defaultAccessible: room.defaultAccessible,
write: room.write,
defaultWrite: room.defaultWrite,
upload: room.upload,
@ -67,3 +112,32 @@ extension OpenGroupAPI.RoomPollInfo {
)
}
}
// MARK: - Decoding
extension OpenGroupAPI.RoomPollInfo {
public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
self = OpenGroupAPI.RoomPollInfo(
token: try container.decode(String.self, forKey: .token),
activeUsers: try container.decode(Int64.self, forKey: .activeUsers),
admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false),
globalAdmin: ((try? container.decode(Bool.self, forKey: .globalAdmin)) ?? false),
moderator: ((try? container.decode(Bool.self, forKey: .moderator)) ?? false),
globalModerator: ((try? container.decode(Bool.self, forKey: .globalModerator)) ?? false),
read: try container.decode(Bool.self, forKey: .read),
defaultRead: try? container.decode(Bool.self, forKey: .defaultRead),
defaultAccessible: try? container.decode(Bool.self, forKey: .defaultAccessible),
write: try container.decode(Bool.self, forKey: .write),
defaultWrite: try? container.decode(Bool.self, forKey: .defaultWrite),
upload: try container.decode(Bool.self, forKey: .upload),
defaultUpload: try? container.decode(Bool.self, forKey: .defaultUpload),
details: try? container.decode(OpenGroupAPI.Room.self, forKey: .details)
)
}
}

View file

@ -4,14 +4,14 @@ import Foundation
extension OpenGroupAPI {
public struct SendDirectMessageRequest: Codable {
let data: Data
let message: Data
// MARK: - Encodable
public func encode(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encode(data.base64EncodedString(), forKey: .data)
try container.encode(message.base64EncodedString(), forKey: .message)
}
}
}

View file

@ -12,10 +12,34 @@ extension OpenGroupAPI {
case fileIds = "files"
}
/// The serialized message body (encoded in base64 when encoding)
let data: Data
/// A 64-byte Ed25519 signature of the message body, signed by the current user's keys (encoded in base64 when
/// encoding - ie. 88 base64 chars)
let signature: Data
/// If present this indicates that this message is a whisper that should only be shown to the given user (via their sessionId)
let whisperTo: String?
let whisperMods: Bool
/// If `true`, then this message will be visible to moderators but not ordinary users
///
/// If this and `whisper_to` are used together then the message will be visible to the given user and any room
/// moderators (this can be used, for instance, to issue a warning to a user that only the user and other mods can see)
///
/// **Note:** Only moderators may set this flag
let whisperMods: Bool?
/// Array of file IDs of new files uploaded as attachments of this post
///
/// This is required to preserve uploads for the default expiry period (15 days, unless otherwise configured by the SOGS
/// administrator); uploaded files that are not attached to a post will be deleted much sooner
///
/// If any of the given file ids are already associated with another message then the association is ignored (i.e. the files remain
/// associated with the original message)
///
/// When submitting a message edit this field must contain the IDs of any newly uploaded files that are part of the edit; existing
/// attachment IDs may also be included, but are not required
let fileIds: [Int64]?
// MARK: - Initialization
@ -24,7 +48,7 @@ extension OpenGroupAPI {
data: Data,
signature: Data,
whisperTo: String? = nil,
whisperMods: Bool = false,
whisperMods: Bool? = nil,
fileIds: [Int64]? = nil
) {
self.data = data
@ -42,7 +66,7 @@ extension OpenGroupAPI {
try container.encode(data.base64EncodedString(), forKey: .data)
try container.encode(signature.base64EncodedString(), forKey: .signature)
try container.encodeIfPresent(whisperTo, forKey: .whisperTo)
try container.encode(whisperMods, forKey: .whisperMods)
try container.encodeIfPresent(whisperMods, forKey: .whisperMods)
try container.encodeIfPresent(fileIds, forKey: .fileIds)
}
}

View file

@ -4,9 +4,25 @@ import Foundation
extension OpenGroupAPI {
public struct UpdateMessageRequest: Codable {
/// The serialized message body (encoded in base64 when encoding)
let data: Data
/// A 64-byte Ed25519 signature of the message body, signed by the current user's keys (encoded in base64 when
/// encoding - ie. 88 base64 chars)
let signature: Data
/// Array of file IDs of new files uploaded as attachments of this post
///
/// This is required to preserve uploads for the default expiry period (15 days, unless otherwise configured by the SOGS
/// administrator); uploaded files that are not attached to a post will be deleted much sooner
///
/// If any of the given file ids are already associated with another message then the association is ignored (i.e. the files remain
/// associated with the original message)
///
/// This field must contain the IDs of any newly uploaded files that are part of the edit; existing attachment IDs may also be
/// included, but are not required
let fileIds: [Int64]?
// MARK: - Encodable
public func encode(to encoder: Encoder) throws {

View file

@ -4,8 +4,28 @@ import Foundation
extension OpenGroupAPI {
struct UserBanRequest: Codable {
/// List of one or more room tokens from which the user should be banned (the invoking user must be a `moderator`
/// of all of the given rooms
///
/// This may be set to the single-element list ["*"] to ban the user from all rooms in which the invoking user has `moderator`
/// permissions (the call will succeed if the calling user is a moderator in at least one channel)
///
/// Exclusive of `global`
let rooms: [String]?
/// If true then apply the ban at the server-wide global level: the user will be banned from the server entirelynot merely from
/// all rooms, but also from calling any other server request (the invoking user must be a global `moderator` in order to add
/// a global ban
///
/// Exclusive of rooms
let global: Bool?
/// Optional value specifying a time limit on the ban, in seconds
///
/// The applied ban will expire and be removed after the given interval - If omitted (or `null`) then the ban is permanent
///
/// If this endpoint is called multiple times then the timeout of the last call takes effect (eg. a permanent ban can be replaced
/// with a time-limited ban by calling the endpoint again with a timeout value, and vice versa)
let timeout: TimeInterval?
}
}

View file

@ -25,7 +25,7 @@ extension OpenGroupAPI {
/// that may also be present.
///
/// See the `admin` parameter description for information on how `admin` and `moderator` parameters interact.
let moderator: Bool
let moderator: Bool?
/// If `true` then this user will be granted moderator and admin permissions to the given rooms or server. Admin permissions are
/// required to appoint new moderators or administrators and to alter room info such as the image, adding/removing pinned messages,
@ -51,7 +51,7 @@ extension OpenGroupAPI {
/// - `moderator=false, admin=false`: exactly the same as above.
/// - `moderator=false, admin=true`: this combination is *not* *permitted* (because admin permissions imply moderator
/// permissions) and will result in Bad Request error if given.
let admin: Bool
let admin: Bool?
/// Whether this user should be a "visible" moderator or admin in the specified rooms (or globally). Visible moderators are identified to all
/// room users (e.g. via a special status badge in Session clients).

View file

@ -1,13 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
extension OpenGroupAPI {
struct UserPermissionsRequest: Codable {
let rooms: [String]
let timeout: TimeInterval
let read: Bool
let write: Bool
let upload: Bool
}
}

View file

@ -4,7 +4,18 @@ import Foundation
extension OpenGroupAPI {
struct UserUnbanRequest: Codable {
/// List of one or more room tokens from which the user should be banned (the invoking user must be a `moderator`
/// of all of the given rooms
///
/// This may be set to the single-element list ["*"] to ban the user from all rooms in which the invoking user has `moderator`
/// permissions (the call will succeed if the calling user is a moderator in at least one channel)
///
/// Exclusive of `global`
let rooms: [String]?
/// If true then remove a server-wide global ban
///
/// Exclusive of rooms
let global: Bool?
}
}

View file

@ -40,10 +40,13 @@ public final class OpenGroupAPI: NSObject {
/// - For each room:
/// - Poll Info
/// - Messages (includes additions and deletions)
public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> {
/// - Inbox for the server
public static func poll(_ server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> {
// Store a local copy of the cached state for this server
let hadPerformedInitialPoll: Bool = (hasPerformedInitialPoll[server] == true)
let originalTimeSinceLastPoll: TimeInterval = (timeSinceLastPoll[server] ?? min(lastPollTime, timeSinceLastOpen))
let maybeLastInboxMessageId: Int64? = dependencies.storage.getOpenGroupInboxLatestMessageId(for: server)
let lastInboxMessageId: Int64 = (maybeLastInboxMessageId ?? 0)
// Update the cached state for this server
hasPerformedInitialPoll[server] = true
@ -100,16 +103,32 @@ public final class OpenGroupAPI: NSObject {
]
}
)
.appending(
// Inbox
BatchRequestInfo(
request: Request<NoBody>(
server: server,
endpoint: (maybeLastInboxMessageId == nil ?
.inbox :
.inboxSince(id: lastInboxMessageId)
)
// TODO: Limit?
// queryParameters: [ .limit: 256 ]
),
responseType: [DirectMessage].self
)
)
return batch(server, requests: requestResponseType, using: dependencies)
}
/// This is used, for example, to poll multiple rooms on the same server for updates in a single query rather than needing to make multiple requests for each room.
/// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one
///
/// No guarantee is made as to the order in which sub-requests are processed; use the `/sequence` instead if you need that.
/// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which requests will be
/// carried out (for sequential, related requests invoke via `/sequence` instead)
///
/// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body.
private static func batch(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> {
private static func batch(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> {
let requestBody: BatchRequest = requests.map { $0.toSubRequest() }
let responseTypes = requests.map { $0.responseType }
@ -130,12 +149,16 @@ public final class OpenGroupAPI: NSObject {
}
}
/// The requests are guaranteed to be performed sequentially in the order given in the request and will abort if any request does not return a status-`2xx` response.
/// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests if the previous request
/// returned a non-`2xx` response
///
/// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because
/// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not
/// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)."
private static func sequence(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable)]> {
///
/// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response list (if requests were
/// stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final response value
private static func sequence(_ server: String, requests: [BatchRequestInfoType], using dependencies: Dependencies = Dependencies()) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> {
let requestBody: BatchRequest = requests.map { $0.toSubRequest() }
let responseTypes = requests.map { $0.responseType }
@ -159,6 +182,13 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Capabilities
/// Return the list of server features/capabilities
///
/// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed) response
/// will be returned with missing requested capabilities in the `missing` key
///
/// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch`
/// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}`
public static func capabilities(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Capabilities)> {
let request: Request = Request<NoBody>(
server: server,
@ -173,6 +203,9 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Room
/// Returns a list of available rooms on the server
///
/// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included
public static func rooms(for server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Room])> {
let request: Request = Request<NoBody>(
server: server,
@ -183,6 +216,7 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: [Room].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// Returns the details of a single room
public static func room(for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Room)> {
let request: Request = Request<NoBody>(
server: server,
@ -193,6 +227,15 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: Room.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// Polls a room for metadata updates
///
/// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current
/// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value
///
/// **Note:** This is the direct request to retrieve room updates so should be retrieved automatically from the `poll()` method, in order to call
/// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo`
/// method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead")
public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> {
let request: Request = Request<NoBody>(
server: server,
@ -205,6 +248,7 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Messages
/// Posts a new message to a room
public static func send(
_ plaintext: Data,
to roomToken: String,
@ -236,6 +280,7 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// Returns a single message by ID
public static func message(_ id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Message)> {
let request: Request = Request<NoBody>(
server: server,
@ -246,9 +291,13 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: Message.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// Edits a message, replacing its existing content with new content and a new signature
///
/// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room
public static func messageUpdate(
_ id: Int64,
plaintext: Data,
fileIds: [Int64]?,
in roomToken: String,
on server: String,
using dependencies: Dependencies = Dependencies()
@ -259,7 +308,8 @@ public final class OpenGroupAPI: NSObject {
let requestBody: UpdateMessageRequest = UpdateMessageRequest(
data: plaintext,
signature: Data(signResult.signature)
signature: Data(signResult.signature),
fileIds: fileIds
)
let request: Request = Request(
@ -294,10 +344,10 @@ public final class OpenGroupAPI: NSObject {
return response
}
}
/// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()`
/// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to
/// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly)
/// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call
/// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages`
/// method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead")
public static func recentMessages(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> {
let request: Request = Request<NoBody>(
@ -311,9 +361,9 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()`
/// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to
/// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly)
/// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly
/// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages`
/// method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead")
public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> {
// TODO: Do we need to be able to load old messages?
@ -328,9 +378,9 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: [Message].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// This is the direct request to retrieve recent messages from an Open Group so should be retrieved automatically from the `poll()`
/// method, if the logic should change then remove the `@available` line and make sure to route the response of this method to
/// the `OpenGroupManager` `handleMessages` method (otherwise the logic may not work correctly)
/// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the
/// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the
/// `OpenGroupManager.handleMessages` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead")
public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [Message])> {
let request: Request = Request<NoBody>(
@ -346,6 +396,16 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Pinning
/// Adds a pinned message to this room
///
/// **Note:** Existing pinned messages are not removed: the new message is added to the pinned message list (If you want to remove existing
/// pins then build a sequence request that first calls .../unpin/all)
///
/// The user must have admin (not just moderator) permissions in the room in order to pin messages
///
/// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned
/// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the
/// order in which pinned messages should be displayed
public static func pinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<OnionRequestResponseInfoType> {
let request: Request = Request<NoBody>(
method: .post,
@ -357,6 +417,9 @@ public final class OpenGroupAPI: NSObject {
.map { responseInfo, _ in responseInfo }
}
/// Remove a message from this room's pinned message list
///
/// The user must have `admin` (not just `moderator`) permissions in the room
public static func unpinMessage(id: Int64, in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<OnionRequestResponseInfoType> {
let request: Request = Request<NoBody>(
method: .post,
@ -368,6 +431,9 @@ public final class OpenGroupAPI: NSObject {
.map { responseInfo, _ in responseInfo }
}
/// Removes _all_ pinned messages from this room
///
/// The user must have `admin` (not just `moderator`) permissions in the room
public static func unpinAll(in roomToken: String, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<OnionRequestResponseInfoType> {
let request: Request = Request<NoBody>(
method: .post,
@ -435,7 +501,13 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Inbox (Message Requests)
public static func messageRequests(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> {
/// Retrieves all of the user's current DMs (up to limit)
///
/// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()`
/// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the
/// `OpenGroupManager.handleInbox` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead")
public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> {
let request: Request = Request<NoBody>(
server: server,
endpoint: .inbox
@ -445,7 +517,13 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
public static func messageRequestsSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> {
/// Polls for any DMs received since the given id
///
/// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved
/// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response
/// of this method to the `OpenGroupManager.handleInbox` method to ensure things are processed correctly
@available(*, unavailable, message: "Avoid using this directly, use the pre-build `poll()` method instead")
public static func inboxSince(id: Int64, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> {
let request: Request = Request<NoBody>(
server: server,
endpoint: .inboxSince(id: id)
@ -455,14 +533,12 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
public static func sendMessageRequest(_ plaintext: Data, to blindedSessionId: String, on server: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage])> {
guard let signedMessage: Data = sign(message: plaintext, to: blindedSessionId, on: server, with: serverPublicKey, using: dependencies) else {
return Promise(error: Error.signingFailed)
}
/// Delivers a direct message to a user via their blinded Session ID
///
/// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver
public static func send(_ ciphertext: Data, toInboxFor blindedSessionId: String, on server: String/*, with serverPublicKey: String*/, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> {
let requestBody: SendDirectMessageRequest = SendDirectMessageRequest(
data: signedMessage
// signature: signedMessage.signature // TODO: Confirm whether this needs a signature??
message: ciphertext
)
let request: Request = Request(
@ -478,7 +554,44 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Users
public static func userBan(_ sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> {
/// Applies a ban of a user from specific rooms, or from the server globally
///
/// The invoking user must have `moderator` (or `admin`) permission in all given rooms when specifying rooms, and must be a
/// `globalModerator` (or `globalAdmin`) if using the global parameter
///
/// **Note:** The user's messages are not deleted by this request - In order to ban and delete all messages use the `/sequence` endpoint to
/// bundle a `/user/.../ban` with a `/user/.../deleteMessages` request
///
/// - Parameters:
/// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted
///
/// - timeout: Value specifying a time limit on the ban, in seconds
///
/// The applied ban will expire and be removed after the given interval - If omitted (or `null`) then the ban is permanent
///
/// If this endpoint is called multiple times then the timeout of the last call takes effect (eg. a permanent ban can be replaced
/// with a time-limited ban by calling the endpoint again with a timeout value, and vice versa)
///
/// - roomTokens: List of one or more room tokens from which the user should be banned from
///
/// The invoking user **must** be a moderator of all of the given rooms.
///
/// This may be set to the single-element list `["*"]` to ban the user from all rooms in which the current user has moderator
/// permissions (the call will succeed if the calling user is a moderator in at least one channel)
///
/// **Note:** You can ban from all rooms on a server by providing a `nil` value for this parameter (the invoking user must be a
/// global moderator in order to add a global ban)
///
/// - server: The server to delete messages from
///
/// - dependencies: Injected dependencies (used for unit testing)
public static func userBan(
_ sessionId: String,
for timeout: TimeInterval? = nil,
from roomTokens: [String]? = nil,
on server: String,
using dependencies: Dependencies = Dependencies()
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
let requestBody: UserBanRequest = UserBanRequest(
rooms: roomTokens,
global: (roomTokens == nil ? true : nil),
@ -495,7 +608,36 @@ public final class OpenGroupAPI: NSObject {
return send(request, using: dependencies)
}
public static func userUnban(_ sessionId: String, from roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> {
/// Removes a user ban from specific rooms, or from the server globally
///
/// The invoking user must have `moderator` (or `admin`) permission in all given rooms when specifying rooms, and must be a global server `moderator`
/// (or `admin`) if using the `global` parameter
///
/// **Note:** Room and global bans are independent: if a user is banned globally and has a room-specific ban then removing the global ban does not remove
/// the room specific ban, and removing the room-specific ban does not remove the global ban (to fully unban a user globally and from all rooms, submit a
/// `/sequence` request with a global unban followed by a "rooms": ["*"] unban)
///
/// - Parameters:
/// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted
///
/// - roomTokens: List of one or more room tokens from which the user should be unbanned from
///
/// The invoking user **must** be a moderator of all of the given rooms.
///
/// This may be set to the single-element list `["*"]` to unban the user from all rooms in which the current user has moderator
/// permissions (the call will succeed if the calling user is a moderator in at least one channel)
///
/// **Note:** You can ban from all rooms on a server by providing a `nil` value for this parameter
///
/// - server: The server to delete messages from
///
/// - dependencies: Injected dependencies (used for unit testing)
public static func userUnban(
_ sessionId: String,
from roomTokens: [String]?,
on server: String,
using dependencies: Dependencies = Dependencies()
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
let requestBody: UserUnbanRequest = UserUnbanRequest(
rooms: roomTokens,
global: (roomTokens == nil ? true : nil)
@ -511,26 +653,68 @@ public final class OpenGroupAPI: NSObject {
return send(request, using: dependencies)
}
public static func userPermissionUpdate(_ sessionId: String, read: Bool, write: Bool, upload: Bool, for roomTokens: [String], timeout: TimeInterval, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> {
let requestBody: UserPermissionsRequest = UserPermissionsRequest(
rooms: roomTokens,
timeout: timeout,
read: read,
write: write,
upload: upload
)
/// Appoints or removes a moderator or admin
///
/// This endpoint is used to appoint or remove moderator/admin permissions either for specific rooms or for server-wide global moderator permissions
///
/// Admins/moderators of rooms can only be appointed or removed by a user who has admin permissions in the room (including global admins)
///
/// Global admins/moderators may only be appointed by a global admin
///
/// The admin/moderator paramters interact as follows:
/// - **admin=true, moderator omitted:** This adds admin permissions, which automatically also implies moderator permissions
/// - **admin=true, moderator=true:** Exactly the same as above
/// - **admin=false, moderator=true:** Removes any existing admin permissions from the rooms (or globally), if present, and adds
/// moderator permissions to the rooms/globally (if not already present)
/// - **admin=false, moderator omitted:** This removes admin permissions but leaves moderator permissions, if present (this
/// effectively "downgrades" an admin to a moderator). Unlike the above this does **not** add moderator permissions to matching rooms
/// if not already present
/// - **moderator=true, admin omitted:** Adds moderator permissions to the given rooms (or globally), if not already present. If
/// the user already has admin permissions this does nothing (that is, admin permission is *not* removed, unlike the above)
/// - **moderator=false, admin omitted:** This removes moderator **and** admin permissions from all given rooms (or globally)
/// - **moderator=false, admin=false:** Exactly the same as above
/// - **moderator=false, admin=true:** This combination is **not permitted** (because admin permissions imply moderator
/// permissions) and will result in Bad Request error if given
///
/// - Parameters:
/// - sessionId: The sessionId (either standard or blinded) of the user to modify the permissions of
///
/// - moderator: Value indicating that this user should have moderator permissions added (true), removed (false), or left alone (null)
///
/// - admin: Value indicating that this user should have admin permissions added (true), removed (false), or left alone (null)
///
/// Granting admin permission automatically includes granting moderator permission (and thus it is an error to use admin=true with
/// moderator=false)
///
/// - visible: Value indicating whether the moderator/admin should be made publicly visible as a moderator/admin of the room(s)
/// (if true) or hidden (false)
///
/// Hidden moderators/admins still have all the same permissions as visible moderators/admins, but are visible only to other
/// moderators/admins; regular users in the room will not know their moderator status
///
/// - roomTokens: List of one or more room tokens to which the permission changes should be applied
///
/// The invoking user **must** be an admin of all of the given rooms.
///
/// This may be set to the single-element list `["*"]` to add or remove the moderator from all rooms in which the current user has admin
/// permissions (the call will succeed if the calling user is an admin in at least one channel)
///
/// **Note:** You can specify a change to global permisisons by providing a `nil` value for this parameter
///
/// - server: The server to perform the permission changes on
///
/// - dependencies: Injected dependencies (used for unit testing)
public static func userModeratorUpdate(
_ sessionId: String,
moderator: Bool? = nil,
admin: Bool? = nil,
visible: Bool,
for roomTokens: [String]?,
on server: String,
using dependencies: Dependencies = Dependencies()
) -> Promise<(OnionRequestResponseInfoType, Data?)> {
guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { return Promise(error: Error.generic) }
let request: Request = Request(
method: .post,
server: server,
endpoint: .userPermission(sessionId),
body: requestBody
)
return send(request, using: dependencies)
}
public static func userModeratorUpdate(_ sessionId: String, moderator: Bool, admin: Bool, visible: Bool, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, Data?)> {
let requestBody: UserModeratorRequest = UserModeratorRequest(
rooms: roomTokens,
global: (roomTokens == nil ? true : nil),
@ -549,7 +733,31 @@ public final class OpenGroupAPI: NSObject {
return send(request, using: dependencies)
}
public static func userDeleteMessages(_ sessionId: String, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> {
// TODO: Need to test this once the API has been implemented
// TODO: Update docs to align with the API documentation once implemented
/// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server
///
/// - Parameters:
/// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted
///
/// - roomTokens: List of one or more room tokens from which the messages should be deleted
///
/// The invoking user **must** be an admin of all of the given rooms.
///
/// This may be set to the single-element list `["*"]` to add or remove the moderator from all rooms in which the current user has admin
/// permissions (the call will succeed if the calling user is an admin in at least one channel)
///
/// **Note:** You can delete messages from all rooms on a server by providing a `nil` value for this parameter
///
/// - server: The server to delete messages from
///
/// - dependencies: Injected dependencies (used for unit testing)
public static func userDeleteMessages(
_ sessionId: String,
for roomTokens: [String]?,
on server: String,
using dependencies: Dependencies = Dependencies()
) -> Promise<(OnionRequestResponseInfoType, UserDeleteMessagesResponse)> {
let requestBody: UserDeleteMessagesRequest = UserDeleteMessagesRequest(
rooms: roomTokens,
global: (roomTokens == nil ? true : nil)
@ -566,7 +774,15 @@ public final class OpenGroupAPI: NSObject {
.decoded(as: UserDeleteMessagesResponse.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
public static func userBanAndDeleteAllMessage(_ sessionId: String, for roomTokens: [String]? = nil, on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<[OnionRequestResponseInfoType]> {
// TODO: Need to test this once the API has been implemented
/// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those
/// methods for the documented behaviour of each method
public static func userBanAndDeleteAllMessage(
_ sessionId: String,
for roomTokens: [String]?,
on server: String,
using dependencies: Dependencies = Dependencies()
) -> Promise<[OnionRequestResponseInfoType]> {
let banRequestBody: UserBanRequest = UserBanRequest(
rooms: roomTokens,
global: (roomTokens == nil ? true : nil),
@ -585,8 +801,7 @@ public final class OpenGroupAPI: NSObject {
server: server,
endpoint: .userBan(sessionId),
body: banRequestBody
),
responseType: Data?.self
)
),
BatchRequestInfo(
request: Request(
@ -608,46 +823,8 @@ public final class OpenGroupAPI: NSObject {
// MARK: - Authentication
// TODO: This is going to have to work differently (ie. `MessageSender+Encryption`)
/// Sign a blinded message request to be sent to a users inbox via SOGS v4
private static func sign(message: Data, to blindedSessionId: String, on serverName: String, with serverPublicKey: String, using dependencies: Dependencies = Dependencies()) -> Data? {
guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil }
// TODO: Re-do this
return nil
// guard let blindedKeyPair: BlindedECKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else {
// return nil
// }
// guard let blindedRecipientPublicKey: Data = String(blindedSessionId.suffix(from: blindedSessionId.index(blindedSessionId.startIndex, offsetBy: IdPrefix.blinded.rawValue.count))).dataFromHex() else {
// return nil
// }
//
// /// Generate the sharedSecret by "a kB || kA || kB" where
// /// a, A are the users private and public keys respectively,
// /// kA is the users blinded public key
// /// kB is the recipients blinded public key
// let maybeSharedSecret: Data? = dependencies.sodium
// .sharedEdSecret(userEdKeyPair.secretKey, blindedRecipientPublicKey.bytes)?
// .appending(blindedKeyPair.publicKey.bytes)
// .appending(blindedRecipientPublicKey.bytes)
//
// guard let sharedSecret: Data = maybeSharedSecret else { return nil }
// guard let intermediateHash: Bytes = dependencies.genericHash.hash(message: sharedSecret.bytes) else { return nil }
//
// /// Generate the inner message by "message || A" where
// /// A is the sender's ed25519 master pubkey (**not** kA blinded pubkey)
// let innerMessage: Bytes = (message.bytes + userEdKeyPair.publicKey)
// guard let (ciphertext, nonce) = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerMessage, secretKey: intermediateHash) else {
// return nil
// }
//
// /// Generate the final data by "b'\x00' + ciphertext + nonce"
// let finalData: Bytes = [0] + ciphertext + nonce
//
// return Data(finalData)
}
/// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities)
public static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? {
private static func sign(_ messageBytes: Bytes, for serverName: String, using dependencies: Dependencies = Dependencies()) -> (publicKey: String, signature: Bytes)? {
guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { return nil }
guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: serverName) else {
return nil
@ -666,7 +843,7 @@ public final class OpenGroupAPI: NSObject {
}
return (
publicKey: IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey),
publicKey: SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString,
signature: signatureResult
)
}
@ -677,7 +854,7 @@ public final class OpenGroupAPI: NSObject {
}
return (
publicKey: IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey),
publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString,
signature: signatureResult
)
}
@ -691,7 +868,7 @@ public final class OpenGroupAPI: NSObject {
.appending(url.query.map { value in "?\(value)" })
let method: String = (request.httpMethod ?? "GET")
let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970))
let nonce: Data = Data(dependencies.nonceGenerator.nonce())
let nonce: Data = Data(dependencies.nonceGenerator16.nonce())
guard let serverPublicKeyData: Data = serverPublicKey.dataFromHex() else { return nil }
guard let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil }

View file

@ -130,11 +130,12 @@ public final class OpenGroupManager: NSObject {
isBackgroundPoll: Bool,
using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()
) {
// Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages
// Sorting the messages by server ID before importing them fixes an issue where messages
// that quote older messages can't find those older messages
let openGroupID = "\(server).\(roomToken)"
let sortedMessages: [OpenGroupAPI.Message] = messages
.sorted { lhs, rhs in lhs.seqNo < rhs.seqNo }
let seqNo: Int64 = (sortedMessages.last?.seqNo ?? 0)
.sorted { lhs, rhs in lhs.id < rhs.id }
let seqNo: Int64 = (sortedMessages.map { $0.seqNo }.max() ?? 0)
dependencies.storage.write { transaction in
var messageServerIDsToRemove: [UInt64] = []
@ -256,7 +257,6 @@ public final class OpenGroupManager: NSObject {
imageID: (pollInfo.details?.imageId.map { "\($0)" } ?? existingOpenGroup?.imageID),
infoUpdates: ((pollInfo.details?.infoUpdates ?? existingOpenGroup?.infoUpdates) ?? 0)
)
let existingUserCount: UInt64? = dependencies.storage.getUserCount(forOpenGroupWithID: updatedOpenGroup.id)
// - Thread changes
thread.shouldBeVisible = true
@ -268,7 +268,7 @@ public final class OpenGroupManager: NSObject {
// - User Count
dependencies.storage.setUserCount(
to: ((pollInfo.activeUsers.map { UInt64($0) } ?? existingUserCount) ?? 0),
to: UInt64(pollInfo.activeUsers),
forOpenGroupWithID: updatedOpenGroup.id,
using: transaction
)
@ -313,6 +313,51 @@ public final class OpenGroupManager: NSObject {
)
}
internal static func handleInbox(
_ messages: [OpenGroupAPI.DirectMessage],
on server: String,
isBackgroundPoll: Bool,
using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()
) {
guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else {
SNLog("Couldn't receive inbox message.")
return
}
// Sorting the messages by server ID before importing them fixes an issue where messages
// that quote older messages can't find those older messages
let sortedMessages: [OpenGroupAPI.DirectMessage] = messages
.sorted { lhs, rhs in lhs.id < rhs.id }
let latestMessageId: Int64 = (sortedMessages.last?.id ?? 0)
dependencies.storage.write { transaction in
// Update the 'latestMessageId' value
dependencies.storage.setOpenGroupInboxLatestMessageId(for: server, to: latestMessageId, using: transaction)
// Process the messages
sortedMessages.forEach { message in
guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else {
SNLog("Couldn't receive inbox message.")
return
}
// Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps
let envelope = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000)))
envelope.setContent(messageData)
envelope.setSource(message.sender)
do {
let data = try envelope.buildSerializedData()
let (message, proto) = try MessageReceiver.parse(data, openGroupMessageServerID: nil, openGroupServerPublicKey: serverPublicKey, isRetry: false, using: transaction)
try MessageReceiver.handle(message, associatedWithProto: proto, openGroupID: nil, isBackgroundPoll: isBackgroundPoll, using: transaction)
}
catch let error {
SNLog("Couldn't receive inbox message due to error: \(error).")
}
}
}
}
// MARK: - Convenience
/// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group
@ -332,7 +377,7 @@ public final class OpenGroupManager: NSObject {
}
// Add the unblinded key as an option
targetKeys.append(IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey))
targetKeys.append(SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString)
let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: server)
@ -343,7 +388,7 @@ public final class OpenGroupManager: NSObject {
}
// Add the blinded key as an option
targetKeys.append(IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey))
targetKeys.append(SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString)
}
}

View file

@ -13,7 +13,8 @@ extension OpenGroupAPI {
let sign: SignType
let genericHash: GenericHashType
let ed25519: Ed25519Type.Type
let nonceGenerator: NonceGenerator16ByteType
let nonceGenerator16: NonceGenerator16ByteType
let nonceGenerator24: NonceGenerator24ByteType
let date: Date
public init(
@ -25,7 +26,8 @@ extension OpenGroupAPI {
sign: SignType? = nil,
genericHash: GenericHashType? = nil,
ed25519: Ed25519Type.Type = Ed25519.self,
nonceGenerator: NonceGenerator16ByteType = NonceGenerator16Byte(),
nonceGenerator16: NonceGenerator16ByteType = NonceGenerator16Byte(),
nonceGenerator24: NonceGenerator24ByteType = NonceGenerator24Byte(),
date: Date = Date()
) {
self.api = api
@ -35,7 +37,8 @@ extension OpenGroupAPI {
self.sign = (sign ?? sodium.getSign())
self.genericHash = (genericHash ?? sodium.getGenericHash())
self.ed25519 = ed25519
self.nonceGenerator = nonceGenerator
self.nonceGenerator16 = nonceGenerator16
self.nonceGenerator24 = nonceGenerator24
self.date = date
}
@ -49,7 +52,8 @@ extension OpenGroupAPI {
sign: SignType? = nil,
genericHash: GenericHashType? = nil,
ed25519: Ed25519Type.Type? = nil,
nonceGenerator: NonceGenerator16ByteType? = nil,
nonceGenerator16: NonceGenerator16ByteType? = nil,
nonceGenerator24: NonceGenerator24ByteType? = nil,
date: Date? = nil
) -> Dependencies {
return Dependencies(
@ -60,7 +64,8 @@ extension OpenGroupAPI {
sign: (sign ?? self.sign),
genericHash: (genericHash ?? self.genericHash),
ed25519: (ed25519 ?? self.ed25519),
nonceGenerator: (nonceGenerator ?? self.nonceGenerator),
nonceGenerator16: (nonceGenerator16 ?? self.nonceGenerator16),
nonceGenerator24: (nonceGenerator24 ?? self.nonceGenerator24),
date: (date ?? self.date)
)
}

View file

@ -136,7 +136,7 @@ extension OpenGroupAPI {
// Inbox (Message Requests)
case .inbox: return "inbox"
case .inboxSince(let id): return "inbox/\(id)"
case .inboxSince(let id): return "inbox/since/\(id)"
case .inboxFor(let sessionId): return "inbox/\(sessionId)"
// Users

View file

@ -3,11 +3,15 @@
import Sodium
public protocol NonceGenerator16ByteType {
var NonceBytes: Int { get }
func nonce() -> Array<UInt8>
}
extension NonceGenerator16ByteType {
public protocol NonceGenerator24ByteType {
var NonceBytes: Int { get }
func nonce() -> Array<UInt8>
}
extension OpenGroupAPI {
@ -16,4 +20,10 @@ extension OpenGroupAPI {
public init() {}
}
public class NonceGenerator24Byte: NonceGenerator, NonceGenerator24ByteType {
public var NonceBytes: Int { 24 }
public init() {}
}
}

View file

@ -4,7 +4,10 @@ import Foundation
import SessionUtilitiesKit
extension OpenGroupAPI {
struct NoBody: Encodable {}
struct Empty: Codable {}
typealias NoBody = Empty
typealias NoResponse = Empty
struct Request<T: Encodable> {
let method: HTTP.Verb

View file

@ -9,14 +9,20 @@ public protocol SodiumType {
func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType
func getSign() -> SignType
func generateBlindingFactor(serverPublicKey: String) -> Bytes?
func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair?
func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes?
func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes?
func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes?
func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes?
}
public protocol AeadXChaCha20Poly1305IetfType {
func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key, additionalData: Bytes?) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)?
var KeyBytes: Int { get }
var ABytes: Int { get }
func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes?
func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes?
}
public protocol Ed25519Type {
@ -24,6 +30,9 @@ public protocol Ed25519Type {
}
public protocol SignType {
var PublicKeyBytes: Int { get }
func toX25519(ed25519PublicKey: Bytes) -> Bytes?
func signature(message: Bytes, secretKey: Bytes) -> Bytes?
func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool
}
@ -37,8 +46,12 @@ public protocol GenericHashType {
// MARK: - Default Values
extension AeadXChaCha20Poly1305IetfType {
func encrypt(message: Bytes, secretKey: Aead.XChaCha20Poly1305Ietf.Key) -> (authenticatedCipherText: Bytes, nonce: Aead.XChaCha20Poly1305Ietf.Nonce)? {
return encrypt(message: message, secretKey: secretKey, additionalData: nil)
func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? {
return encrypt(message: message, secretKey: secretKey, nonce: nonce, additionalData: nil)
}
func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? {
return decrypt(authenticatedCipherText: authenticatedCipherText, secretKey: secretKey, nonce: nonce, additionalData: nil)
}
}

View file

@ -24,7 +24,65 @@ extension MessageReceiver {
guard isValid else { throw Error.invalidSignature }
// 4. ) Get the sender's X25519 public key
guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw Error.decryptionFailed }
// TODO: Need to rework this as it'll be based on the blinded id
return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString)
}
internal static func decryptWithSessionBlindingProtocol(data: Data, fromBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) {
guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else {
throw Error.decryptionFailed
}
/// Step one: calculate the shared encryption key, receiving from A to B
let kA: Bytes = Data(hex: fromBlindedPublicKey.removingIdPrefixIfNeeded()).bytes
guard let dec_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey(
secretKey: userEd25519KeyPair.secretKey,
otherBlindedPublicKey: kA,
fromBlindedPublicKey: kA,
toBlindedPublicKey: blindedKeyPair.publicKey,
genericHash: dependencies.genericHash
) else {
throw Error.decryptionFailed
}
return (Data(plaintext), IdPrefix.standard.rawValue + senderX25519PublicKey.toHexString())
/// v, ct, nc = data[0], data[1:-24], data[-24:]
let version: UInt8 = data.bytes[0]
let ciphertext: Bytes = Bytes(data.bytes[1..<(data.count - dependencies.nonceGenerator24.NonceBytes)])
let nonce: Bytes = Bytes(data.bytes[(data.count - dependencies.nonceGenerator24.NonceBytes)..<data.count])
/// Make sure our encryption version is okay
guard version == 0 else { throw Error.decryptionFailed }
/// Decrypt
guard let innerBytes: Bytes = dependencies.aeadXChaCha20Poly1305Ietf.decrypt(authenticatedCipherText: ciphertext, secretKey: dec_key, nonce: nonce) else {
throw Error.decryptionFailed
}
/// Ensure the length is correct
guard innerBytes.count > dependencies.sign.PublicKeyBytes else { throw Error.decryptionFailed }
/// Split up: the last 32 bytes are the sender's *unblinded* ed25519 key
let plaintext: Bytes = Bytes(innerBytes[
0...(innerBytes.count - 1 - dependencies.sign.PublicKeyBytes)
])
let sender_edpk: Bytes = Bytes(innerBytes[
(innerBytes.count - dependencies.sign.PublicKeyBytes)...(innerBytes.count - 1)
])
/// Verify that the inner sender_edpk (A) yields the same outer kA we got with the message
guard let blindingFactor: Bytes = dependencies.sodium.generateBlindingFactor(serverPublicKey: openGroupPublicKey) else {
throw Error.invalidSignature
}
guard let sharedSecret: Bytes = dependencies.sodium.combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) else {
throw Error.invalidSignature
}
guard kA == sharedSecret else { throw Error.invalidSignature }
/// Get the sender's X25519 public key
guard let senderSessionIdBytes: Bytes = dependencies.sign.toX25519(ed25519PublicKey: sender_edpk) else {
throw Error.decryptionFailed
}
return (Data(plaintext), SessionId(.standard, publicKey: senderSessionIdBytes).hexString)
}
}

View file

@ -48,66 +48,90 @@ public enum MessageReceiver {
}
}
public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) {
public static func parse(_ data: Data, openGroupMessageServerID: UInt64?, openGroupServerPublicKey: String? = nil, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) {
let userPublicKey = SNMessagingKitConfiguration.shared.storage.getUserPublicKey()
let isOpenGroupMessage = (openGroupMessageServerID != nil)
// Parse the envelope
let envelope = try SNProtoEnvelope.parseData(data)
let storage = SNMessagingKitConfiguration.shared.storage
// Decrypt the contents
guard let ciphertext = envelope.content else { throw Error.noData }
var plaintext: Data!
var sender: String!
var groupPublicKey: String? = nil
if isOpenGroupMessage {
(plaintext, sender) = (envelope.content!, envelope.source!)
} else {
}
else {
switch envelope.type {
case .sessionMessage:
guard let userX25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { throw Error.noUserX25519KeyPair }
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair)
case .closedGroupMessage:
guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey }
var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey)
guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair }
// Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than
// likely be the one we want) but try older ones in case that didn't work)
var encryptionKeyPair = encryptionKeyPairs.removeLast()
func decrypt() throws {
do {
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: encryptionKeyPair)
} catch {
if !encryptionKeyPairs.isEmpty {
encryptionKeyPair = encryptionKeyPairs.removeLast()
try decrypt()
} else {
throw error
case .sessionMessage:
// Default to 'standard' as the old code didn't seem to require an `envelope.source`
switch (SessionId.Prefix(from: envelope.source) ?? .standard) {
case .standard, .unblinded:
guard let userX25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else {
throw Error.noUserX25519KeyPair
}
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair)
case .blinded:
guard let senderSessionId: String = envelope.source else { throw Error.noData }
guard let openGroupServerPublicKey: String = openGroupServerPublicKey else {
throw Error.invalidGroupPublicKey
}
guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else {
throw Error.noUserED25519KeyPair
}
(plaintext, sender) = try decryptWithSessionBlindingProtocol(
data: ciphertext,
fromBlindedPublicKey: senderSessionId,
with: openGroupServerPublicKey,
userEd25519KeyPair: userEd25519KeyPair
)
}
case .closedGroupMessage:
guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else {
throw Error.invalidGroupPublicKey
}
var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey)
guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair }
// Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than
// likely be the one we want) but try older ones in case that didn't work)
var encryptionKeyPair = encryptionKeyPairs.removeLast()
func decrypt() throws {
do {
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: encryptionKeyPair)
}
catch {
if !encryptionKeyPairs.isEmpty {
encryptionKeyPair = encryptionKeyPairs.removeLast()
try decrypt()
}
else {
throw error
}
}
}
}
groupPublicKey = envelope.source
try decrypt()
/*
do {
groupPublicKey = envelope.source
try decrypt()
} catch {
do {
let now = Date()
// Don't spam encryption key pair requests
let shouldRequestEncryptionKeyPair = given(lastEncryptionKeyPairRequest[groupPublicKey!]) { now.timeIntervalSince($0) > 30 } ?? true
if shouldRequestEncryptionKeyPair {
try MessageSender.requestEncryptionKeyPair(for: groupPublicKey!, using: transaction as! YapDatabaseReadWriteTransaction)
lastEncryptionKeyPairRequest[groupPublicKey!] = now
}
}
throw error // Throw the * decryption * error and not the error generated by requestEncryptionKeyPair (if it generated one)
}
*/
default: throw Error.unknownEnvelopeType
default: throw Error.unknownEnvelopeType
}
}
// Don't process the envelope any further if the sender is blocked
guard !isBlocked(sender) else { throw Error.senderBlocked }
// Parse the proto
let proto: SNProtoContent
do {

View file

@ -8,7 +8,7 @@ extension MessageSender {
var members = members
let userPublicKey = getUserHexEncodedPublicKey()
// Generate the group's public key
let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the 'IdPrefix.standard' prefix
let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix
// Generate the key pair that'll be used for encryption and decryption
let encryptionKeyPair = Curve25519.generateKeyPair()
// Ensure the current user is included in the member list

View file

@ -4,15 +4,59 @@ import Sodium
extension MessageSender {
internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String) throws -> Data {
guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else { throw Error.noUserED25519KeyPair }
guard let userED25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else {
throw Error.noUserED25519KeyPair
}
let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded())
let sodium = Sodium()
let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey
guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed }
guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else {
throw Error.signingFailed
}
let plaintextWithMetadata = plaintext + Data(userED25519KeyPair.publicKey) + Data(signature)
guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { throw Error.encryptionFailed }
guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else {
throw Error.encryptionFailed
}
return Data(ciphertext)
}
internal static func encryptWithSessionBlindingProtocol(_ plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) throws -> Data {
guard SessionId.Prefix(from: recipientBlindedId) == .blinded else { throw Error.signingFailed }
guard let userEd25519KeyPair = SNMessagingKitConfiguration.shared.storage.getUserED25519KeyPair() else {
throw Error.noUserED25519KeyPair
}
guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else {
throw Error.signingFailed
}
let recipientBlindedPublicKey = Data(hex: recipientBlindedId.removingIdPrefixIfNeeded())
/// Step one: calculate the shared encryption key, sending from A to B
guard let enc_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey(
secretKey: userEd25519KeyPair.secretKey,
otherBlindedPublicKey: recipientBlindedPublicKey.bytes,
fromBlindedPublicKey: blindedKeyPair.publicKey,
toBlindedPublicKey: recipientBlindedPublicKey.bytes,
genericHash: dependencies.genericHash
) else {
throw Error.signingFailed
}
/// Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey)
let innerBytes: Bytes = (plaintext.bytes + userEd25519KeyPair.publicKey)
/// Encrypt using xchacha20-poly1305
let nonce: Bytes = dependencies.nonceGenerator24.nonce()
guard let ciphertext = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerBytes, secretKey: enc_key, nonce: nonce) else {
throw Error.encryptionFailed
}
/// data = b'\x00' + ciphertext + nonce
return Data(Bytes(arrayLiteral: 0) + ciphertext + nonce)
}
}

View file

@ -103,6 +103,9 @@ public final class MessageSender : NSObject {
case .legacyOpenGroup, .openGroup:
return sendToOpenGroupDestination(destination, message: message, using: transaction)
case .openGroupInbox:
return sendToOpenGroupInboxDestination(destination, message: message, using: transaction)
}
}
@ -125,7 +128,7 @@ public final class MessageSender : NSObject {
switch destination {
case .contact(let publicKey): message.recipient = publicKey
case .closedGroup(let groupPublicKey): message.recipient = groupPublicKey
case .legacyOpenGroup, .openGroup: preconditionFailure()
case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure()
}
let isSelfSend = (message.recipient == userPublicKey)
@ -183,7 +186,7 @@ public final class MessageSender : NSObject {
ciphertext = try encryptWithSessionProtocol(plaintext, for: encryptionKeyPair.hexEncodedPublicKey)
case .legacyOpenGroup, .openGroup: preconditionFailure()
case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure()
}
}
catch {
@ -205,7 +208,7 @@ public final class MessageSender : NSObject {
kind = .closedGroupMessage
senderPublicKey = groupPublicKey
case .legacyOpenGroup, .openGroup: preconditionFailure()
case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure()
}
let wrappedMessage: Data
@ -302,15 +305,14 @@ public final class MessageSender : NSObject {
preconditionFailure()
}
message.sender = IdPrefix.blinded.hexEncodedPublicKey(for: blindedKeyPair.publicKey)
message.sender = SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString
}
else {
message.sender = IdPrefix.unblinded.hexEncodedPublicKey(for: userEdKeyPair.publicKey)
message.sender = SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString
}
switch destination {
case .contact(_): preconditionFailure()
case .closedGroup(_): preconditionFailure()
case .contact, .closedGroup, .openGroupInbox: preconditionFailure()
case .legacyOpenGroup(let channel, let server): message.recipient = "\(server).\(channel)"
case .openGroup(let room, let server, let whisperTo, let whisperMods, _):
@ -405,6 +407,99 @@ public final class MessageSender : NSObject {
return promise
}
internal static func sendToOpenGroupInboxDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
let storage = SNMessagingKitConfiguration.shared.storage
let transaction = transaction as! YapDatabaseReadWriteTransaction
let userPublicKey = storage.getUserPublicKey()
guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else {
preconditionFailure()
}
// Set the timestamp, sender and recipient
if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set
message.sentTimestamp = NSDate.millisecondTimestamp()
}
message.sender = userPublicKey
message.recipient = recipientBlindedPublicKey
// Set the failure handler (need it here already for precondition failure handling)
func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) {
MessageSender.handleFailedMessageSend(message, with: error, using: transaction)
seal.reject(error)
}
// Attach the user's profile if needed
if let message = message as? VisibleMessage {
guard let name = storage.getUser()?.name else {
handleFailure(with: Error.noUsername, using: transaction)
return promise
}
if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL {
message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL)
}
else {
message.profile = VisibleMessage.Profile(displayName: name)
}
}
// Convert it to protobuf
guard let proto = message.toProto(using: transaction) else {
handleFailure(with: Error.protoConversionFailed, using: transaction)
return promise
}
// Serialize the protobuf
let plaintext: Data
do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
}
catch {
SNLog("Couldn't serialize proto due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Encrypt the serialized protobuf
let ciphertext: Data
do {
ciphertext = try encryptWithSessionBlindingProtocol(plaintext, for: recipientBlindedPublicKey, openGroupPublicKey: openGroupPublicKey, using: dependencies)
}
catch {
SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Send the result
OpenGroupAPI
.send(
ciphertext,
toInboxFor: recipientBlindedPublicKey,
on: server,
using: dependencies
)
.done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in
dependencies.storage.write { transaction in
MessageSender.handleSuccessfulMessageSend(message, to: destination, using: transaction)
seal.fulfill(())
}
}
.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
dependencies.storage.write { transaction in
handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction)
}
}
return promise
}
// MARK: Success & Failure Handling
public static func handleSuccessfulMessageSend(_ message: Message, to destination: Message.Destination, serverTimestamp: UInt64? = nil, isSyncMessage: Bool = false, using transaction: Any) {

View file

@ -83,46 +83,58 @@ extension OpenGroupAPI {
return promise
}
private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable)], isBackgroundPoll: Bool) {
response.forEach { endpoint, response in
private func handlePollResponse(_ response: [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)], isBackgroundPoll: Bool) {
response.forEach { endpoint, endpointResponse in
switch endpoint {
case .capabilities:
guard let responseData: BatchSubResponse<Capabilities> = response.data as? BatchSubResponse<Capabilities> else {
guard let responseData: BatchSubResponse<Capabilities> = endpointResponse.data as? BatchSubResponse<Capabilities>, let responseBody: Capabilities = responseData.body else {
SNLog("Open group polling failed due to invalid data.")
return
}
OpenGroupManager.handleCapabilities(
responseData.body,
responseBody,
on: server
)
case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _):
guard let responseData: BatchSubResponse<[Message]> = response.data as? BatchSubResponse<[Message]> else {
guard let responseData: BatchSubResponse<[Message]> = endpointResponse.data as? BatchSubResponse<[Message]>, let responseBody: [Message] = responseData.body else {
SNLog("Open group polling failed due to invalid data.")
return
}
OpenGroupManager.handleMessages(
responseData.body,
responseBody,
for: roomToken,
on: server,
isBackgroundPoll: isBackgroundPoll
)
case .roomPollInfo(let roomToken, _):
guard let responseData: BatchSubResponse<RoomPollInfo> = response.data as? BatchSubResponse<RoomPollInfo> else {
guard let responseData: BatchSubResponse<RoomPollInfo> = endpointResponse.data as? BatchSubResponse<RoomPollInfo>, let responseBody: RoomPollInfo = responseData.body else {
SNLog("Open group polling failed due to invalid data.")
return
}
OpenGroupManager.handlePollInfo(
responseData.body,
responseBody,
publicKey: nil,
for: roomToken,
on: server
)
case .inbox, .inboxSince:
guard let responseData: BatchSubResponse<[DirectMessage]> = endpointResponse.data as? BatchSubResponse<[DirectMessage]>, let responseBody: [DirectMessage] = responseData.body else {
SNLog("Open group polling failed due to invalid data.")
return
}
OpenGroupManager.handleInbox(
responseBody,
on: server,
isBackgroundPoll: isBackgroundPoll
)
default: break // No custom handling needed
}
}

View file

@ -71,6 +71,12 @@ public protocol SessionMessagingKitStorageProtocol {
func getOpenGroupSequenceNumber(for room: String, on server: String) -> Int64?
func setOpenGroupSequenceNumber(for room: String, on server: String, to newValue: Int64, using transaction: Any)
func removeOpenGroupSequenceNumber(for room: String, on server: String, using transaction: Any)
// MARK: - -- Open Group Inbox Latest Message Id
func getOpenGroupInboxLatestMessageId(for server: String) -> Int64?
func setOpenGroupInboxLatestMessageId(for server: String, to newValue: Int64, using transaction: Any)
func removeOpenGroupInboxLatestMessageId(for server: String, using transaction: Any)
// MARK: - Message Handling

View file

@ -47,14 +47,9 @@ extension Sodium {
private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32
private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64
/// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair`
public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? {
guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else {
return nil
}
/// 64-byte blake2b hash then reduce to get the blinding factor:
/// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
/// 64-byte blake2b hash then reduce to get the blinding factor
public func generateBlindingFactor(serverPublicKey: String) -> Bytes? {
/// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil }
guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else {
return nil
@ -75,15 +70,18 @@ extension Sodium {
/// Ensure the above worked
guard kResult == 0 else { return nil }
/// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to
/// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the
/// same secret scalar secret. (And so this is just the most convenient way to get 'a' out of
/// a sodium Ed25519 secret key).
/// a = s.to_curve25519_private_key().encode()
let secretKeyBytes: Bytes = [UInt8](edKeyPair.secretKey)
return Data(bytes: kPtr, count: Sodium.scalarLength).bytes
}
/// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to
/// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the
/// same secret scalar secret (and so this is just the most convenient way to get 'a' out of
/// a sodium Ed25519 secret key)
private func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes? {
/// a = s.to_curve25519_private_key().encode()
let aPtr: UnsafeMutablePointer<UInt8> = UnsafeMutablePointer<UInt8>.allocate(capacity: Sodium.scalarMultLength)
let aResult = secretKeyBytes.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in
let aResult = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in
guard let secretKeyBaseAddress: UnsafePointer<UInt8> = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return -1
}
@ -94,10 +92,38 @@ extension Sodium {
/// Ensure the above worked
guard aResult == 0 else { return nil }
return Data(bytes: aPtr, count: Sodium.scalarMultLength).bytes
}
/// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair`
public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? {
guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else {
return nil
}
guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey) else { return nil }
guard let aBytes: Bytes = generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) else { return nil }
/// Generate the blinded key pair `ka`, `kA`
let kaPtr: UnsafeMutablePointer<UInt8> = UnsafeMutablePointer<UInt8>.allocate(capacity: Sodium.secretKeyLength)
let kAPtr: UnsafeMutablePointer<UInt8> = UnsafeMutablePointer<UInt8>.allocate(capacity: Sodium.publicKeyLength)
crypto_core_ed25519_scalar_mul(kaPtr, kPtr, aPtr)
let kaResult = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in
return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in
guard let kBaseAddress: UnsafePointer<UInt8> = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return -1
}
guard let aBaseAddress: UnsafePointer<UInt8> = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return -1
}
crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress)
return 0
}
}
/// Ensure the above worked
guard kaResult == 0 else { return nil }
guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil }
return Box.KeyPair(
@ -108,7 +134,7 @@ extension Sodium {
/// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the
/// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded
/// pubkeys (This doesn't affect verification at all).
/// pubkeys (this doesn't affect verification at all)
public func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? {
/// H_rh = sha512(s.encode()).digest()[32:]
let H_rh: Bytes = Bytes(secretKey.sha512().suffix(32))
@ -170,25 +196,41 @@ extension Sodium {
return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes)
}
// TODO: Determine if we still need this? (To generate the `kB` value for the `/inbox` API????)
public func sharedEdSecret(_ firstKeyBytes: [UInt8], _ secondKeyBytes: [UInt8]) -> Bytes? {
let sharedSecretPtr: UnsafeMutablePointer<UInt8> = UnsafeMutablePointer<UInt8>.allocate(capacity: Sodium.noClampLength)
let result = secondKeyBytes.withUnsafeBytes { (secondKeyPtr: UnsafeRawBufferPointer) -> Int32 in
return firstKeyBytes.withUnsafeBytes { (firstKeyPtr: UnsafeRawBufferPointer) -> Int32 in
guard let firstKeyBaseAddress: UnsafePointer<UInt8> = firstKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
/// Combines two keys (`kA`)
public func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? {
let combinedPtr: UnsafeMutablePointer<UInt8> = UnsafeMutablePointer<UInt8>.allocate(capacity: Sodium.noClampLength)
let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in
return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in
guard let lhsKeyBytesBaseAddress: UnsafePointer<UInt8> = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return -1
}
guard let secondKeyBaseAddress: UnsafePointer<UInt8> = secondKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
guard let rhsKeyBytesBaseAddress: UnsafePointer<UInt8> = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return -1
}
return crypto_scalarmult_ed25519_noclamp(sharedSecretPtr, firstKeyBaseAddress, secondKeyBaseAddress)
return crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress)
}
}
/// Ensure the above worked
guard result == 0 else { return nil }
return Data(bytes: sharedSecretPtr, count: Sodium.scalarMultLength).bytes
return Data(bytes: combinedPtr, count: Sodium.noClampLength).bytes
}
/// Calculate a shared secret for a message from A to B:
///
/// BLAKE2b(a kB || kA || kB)
///
/// The receiver can calulate the same value via:
///
/// BLAKE2b(b kA || kA || kB)
public func sharedBlindedEncryptionKey(secretKey: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? {
guard let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) else { return nil }
guard let combinedKeyBytes: Bytes = combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) else { return nil }
return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32)
}
}
@ -218,3 +260,25 @@ extension GenericHash {
return output
}
}
extension AeadXChaCha20Poly1305IetfType {
/// This method is the same as the standard AeadXChaCha20Poly1305IetfType `encrypt` method except it allows the
/// specification of a nonce which allows for deterministic behaviour with unit testing
public func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes? = nil) -> Bytes? {
guard secretKey.count == KeyBytes else { return nil }
var authenticatedCipherText = Bytes(repeating: 0, count: message.count + ABytes)
var authenticatedCipherTextLen: UInt64 = 0
let result = crypto_aead_xchacha20poly1305_ietf_encrypt(
&authenticatedCipherText, &authenticatedCipherTextLen,
message, UInt64(message.count),
additionalData, UInt64(additionalData?.count ?? 0),
nil, nonce, secretKey
)
guard result == 0 else { return nil }
return authenticatedCipherText
}
}

View file

@ -7,15 +7,19 @@ public extension ECKeyPair {
}
@objc var hexEncodedPublicKey: String {
// Prefixing with 'IdPrefix.standard' is necessary for what seems to be a sort of Signal public key versioning system
return IdPrefix.standard.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined()
// Prefixing with 'SessionId.Prefix.standard' is necessary for what seems to be a sort of Signal public key versioning system
return SessionId(.standard, publicKey: publicKey.bytes).hexString
}
@objc static func isValidHexEncodedPublicKey(candidate: String) -> Bool {
// Note: If the logic in here changes ensure it doesn't break `SessionId.Prefix(from:)`
// Check that it's a valid hexadecimal encoding
guard Hex.isValid(candidate) else { return false }
// Check that it has length 66 and a valid prefix
guard candidate.count == 66 && IdPrefix.allCases.first(where: { candidate.hasPrefix($0.rawValue) }) != nil else { return false }
guard candidate.count == 66 && SessionId.Prefix.allCases.first(where: { candidate.hasPrefix($0.rawValue) }) != nil else {
return false
}
// It appears to be a valid public key
return true
}

View file

@ -4,7 +4,7 @@ public extension Data {
func removingIdPrefixIfNeeded() -> Data {
var result = self
if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() }
if result.count == 33 && SessionId.Prefix(from: result.toHexString()) != nil { result.removeFirst() }
return result
}
@ -29,7 +29,7 @@ public extension Data {
@objc func removingIdPrefixIfNeeded() -> NSData {
var result = self as Data
if result.count == 33 && IdPrefix(with: result.toHexString()) != nil { result.removeFirst() }
if result.count == 33 && SessionId.Prefix(from: result.toHexString()) != nil { result.removeFirst() }
return result as NSData
}
}

View file

@ -1,24 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Sodium
import Curve25519Kit
public enum IdPrefix: String, CaseIterable {
case standard = "05" // Used for identified users, open groups, etc.
case blinded = "15" // Used for participants in open groups with blinding enabled
case unblinded = "00" // Used for participants in open groups with blinding disabled
public init?(with sessionId: String) {
// TODO: Determine if we want this 'idPrefix' method (would need to validate both `ECKeyPair` and `Box.KeyPair` types)
guard ECKeyPair.isValidHexEncodedPublicKey(candidate: sessionId) else { return nil }
guard let targetPrefix: IdPrefix = IdPrefix(rawValue: String(sessionId.prefix(2))) else { return nil }
self = targetPrefix
}
public func hexEncodedPublicKey(for publicKey: Bytes) -> String {
return self.rawValue + publicKey.map { String(format: "%02hhx", $0) }.joined()
}
}

View file

@ -0,0 +1,57 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Sodium
import Curve25519Kit
public struct SessionId {
public enum Prefix: String, CaseIterable {
case standard = "05" // Used for identified users, open groups, etc.
case blinded = "15" // Used for participants in open groups with blinding enabled
case unblinded = "00" // Used for participants in open groups with blinding disabled
public init?(from stringValue: String?) {
guard let stringValue: String = stringValue else { return nil }
guard stringValue.count > 2 else {
guard let targetPrefix: Prefix = Prefix(rawValue: stringValue) else { return nil }
self = targetPrefix
return
}
guard ECKeyPair.isValidHexEncodedPublicKey(candidate: stringValue) else { return nil }
guard let targetPrefix: Prefix = Prefix(rawValue: String(stringValue.prefix(2))) else { return nil }
self = targetPrefix
}
}
public let prefix: Prefix
public let publicKey: String
public var hexString: String {
return prefix.rawValue + publicKey
}
// MARK: - Initialization
public init?(from idString: String?) {
guard let idString: String = idString, idString.count > 2 else { return nil }
guard let targetPrefix: Prefix = Prefix(from: idString) else { return nil }
self.prefix = targetPrefix
self.publicKey = idString.substring(from: 2)
}
public init?(_ type: Prefix, publicKey: String) {
guard ECKeyPair.isValidHexEncodedPublicKey(candidate: publicKey) else { return nil }
self.prefix = type
self.publicKey = publicKey
}
public init(_ type: Prefix, publicKey: Bytes) {
self.prefix = type
self.publicKey = publicKey.map { String(format: "%02hhx", $0) }.joined()
}
}

View file

@ -3,7 +3,7 @@ public extension String {
func removingIdPrefixIfNeeded() -> String {
var result = self
if result.count == 66 && IdPrefix(with: result) != nil { result.removeFirst(2) }
if result.count == 66 && SessionId.Prefix(from: result) != nil { result.removeFirst(2) }
return result
}
}
@ -12,7 +12,7 @@ public extension String {
@objc func removingIdPrefixIfNeeded() -> NSString {
var result = self as String
if result.count == 66 && IdPrefix(with: result) != nil { result.removeFirst(2) }
if result.count == 66 && SessionId.Prefix(from: result) != nil { result.removeFirst(2) }
return result as NSString
}
}

View file

@ -5,7 +5,7 @@ public final class Identicon : NSObject {
@objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage {
let icon = PlaceholderIcon(seed: seed)
var content = text
if content.count > 2 && IdPrefix(with: content) != nil {
if content.count > 2 && SessionId.Prefix(from: content) != nil {
content.removeFirst(2)
}
let layer = icon.generateLayer(with: size, text: content.substring(to: 1))