Started working on `MessageRequestResponse` handling for SOGS message requests

Pointing Curve25519 to use a fork that exposes an XEd25519 conversion method
Fixed an issue where I had broken all message sending due to the SnodeAPI casting Onion responses to `Any`
This commit is contained in:
Morgan Pretty 2022-02-25 17:48:09 +11:00
parent dbead5e3c8
commit cc2a077a6c
12 changed files with 106 additions and 50 deletions

View File

@ -24,7 +24,8 @@ abstract_target 'GlobalDependencies' do
# Dependencies to be included only in all extensions/frameworks
abstract_target 'FrameworkAndExtensionDependencies' do
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git'
# TODO: Swap this to use an oxen-io fork
pod 'Curve25519Kit', git: 'https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git', branch: 'session'
pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version'
target 'SessionNotificationServiceExtension'

View File

@ -123,7 +123,7 @@ PODS:
DEPENDENCIES:
- AFNetworking
- CryptoSwift
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
- Curve25519Kit (from `https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git`, branch `session`)
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
- Nimble
- NVActivityIndicatorView
@ -156,7 +156,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
Curve25519Kit:
:git: https://github.com/signalapp/Curve25519Kit.git
:branch: session
:git: https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git
Mantle:
:branch: signal-master
:git: https://github.com/signalapp/Mantle
@ -174,8 +175,8 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS:
Curve25519Kit:
:commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577
:git: https://github.com/signalapp/Curve25519Kit.git
:commit: a23049232dc6c18928cdacfbcef287dad954c5c6
:git: https://github.com/mpretty-cyro/session-ios-curve-25519-kit.git
Mantle:
:commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4
:git: https://github.com/signalapp/Mantle
@ -213,6 +214,6 @@ SPEC CHECKSUMS:
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: 42874150fd08761ee6907c5bacf22b95ae849d8c
PODFILE CHECKSUM: b3b9b5446a109dbcdb5381176ebe431f7762558d
COCOAPODS: 1.11.2

View File

@ -2,6 +2,34 @@ import PromiseKit
import Sodium
extension Storage {
public func getAllMessageRequestThreads() -> [String: TSContactThread] {
var result: [String: TSContactThread] = [:]
Storage.read { transaction in
result = self.getAllMessageRequestThreads(using: transaction)
}
return result
}
public func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread] {
var result = [String: TSContactThread]()
// FIXME: We might be able to optimise this further by filtering the SQL query `WHERE uniqueId LIKE '_c15'
let blindedThreadPrefix: String = TSContactThread.threadID(fromContactSessionID: SessionId.Prefix.blinded.rawValue)
transaction.enumerateKeysAndObjects(
inCollection: TSContactThread.collection(),
using: { threadID, object, _ in
guard let contactThread = object as? TSContactThread else { return }
result[threadID] = contactThread
},
withFilter: { key -> Bool in key.starts(with: blindedThreadPrefix) }
)
return result
}
/// Returns the ID of the thread.
public func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? {

View File

@ -115,7 +115,7 @@ public final class OpenGroupAPI: NSObject {
// TODO: Limit?
// queryParameters: [ .limit: 256 ]
),
responseType: [DirectMessage].self
responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages
)
)
@ -507,30 +507,30 @@ public final class OpenGroupAPI: NSObject {
/// 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])> {
public static func inbox(on server: String, using dependencies: Dependencies = Dependencies()) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> {
let request: Request = Request<NoBody>(
server: server,
endpoint: .inbox
)
return send(request, using: dependencies)
.decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
.decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// Polls for any DMs received since the given id
/// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages
///
/// **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])> {
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)
)
return send(request, using: dependencies)
.decoded(as: [DirectMessage].self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
.decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, error: Error.parsingFailed, using: dependencies)
}
/// Delivers a direct message to a user via their blinded Session ID

View File

@ -319,6 +319,8 @@ public final class OpenGroupManager: NSObject {
isBackgroundPoll: Bool,
using dependencies: OpenGroupAPI.Dependencies = OpenGroupAPI.Dependencies()
) {
// Don't need to do anything if we have no messages (it's a valid case)
guard !messages.isEmpty else { return }
guard let serverPublicKey: String = dependencies.storage.getOpenGroupPublicKey(for: server) else {
SNLog("Couldn't receive inbox message.")
return

View File

@ -15,6 +15,8 @@ public protocol SodiumType {
func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes?
func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes?
func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool
}
public protocol AeadXChaCha20Poly1305IetfType {

View File

@ -124,13 +124,13 @@ extension OpenGroupAPI {
)
case .inbox, .inboxSince:
guard let responseData: BatchSubResponse<[DirectMessage]> = endpointResponse.data as? BatchSubResponse<[DirectMessage]>, let responseBody: [DirectMessage] = responseData.body else {
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,
(responseBody ?? []),
on: server,
isBackgroundPoll: isBackgroundPoll
)

View File

@ -80,6 +80,9 @@ public protocol SessionMessagingKitStorageProtocol {
// MARK: - Message Handling
func getAllMessageRequestThreads() -> [String: TSContactThread]
func getAllMessageRequestThreads(using transaction: YapDatabaseReadTransaction) -> [String: TSContactThread]
func getReceivedMessageTimestamps(using transaction: Any) -> [UInt64]
func addReceivedMessageTimestamp(_ timestamp: UInt64, using transaction: Any)
/// Returns the ID of the thread.

View File

@ -1,5 +1,6 @@
import Clibsodium
import Sodium
import Curve25519Kit
extension Sign {
@ -232,6 +233,33 @@ extension Sodium {
return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32)
}
/// This method should be used to check if a users standard sessionId matches a blinded one
public func sessionId(_ standardSessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String) -> Bool {
// Only support generating blinded keys for standard session ids
guard let sessionId: SessionId = SessionId(from: standardSessionId), sessionId.prefix == .standard else { return false }
guard let blindedId: SessionId = SessionId(from: blindedSessionId), blindedId.prefix == .blinded else { return false }
guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey) else { return false }
/// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what
/// Signal's XEd25519 conversion always uses)
///
/// Note: The below method is code we have exposed from the `curve25519_verify` method within the Curve25519 library
/// rather than custom code we have written
guard let xEd25519Key: Data = try? Ed25519.publicKey(from: Data(hex: sessionId.publicKey)) else { return false }
/// Blind the positive public key
guard let pk1: Bytes = combineKeys(lhsKeyBytes: kBytes, rhsKeyBytes: xEd25519Key.bytes) else { return false }
/// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2
/// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000])
let pk2: Bytes = (pk1[0..<31] + [(pk1[31] ^ 0b1000_0000)])
return (
SessionId(.blinded, publicKey: pk1).publicKey == blindedId.publicKey ||
SessionId(.blinded, publicKey: pk2).publicKey == blindedId.publicKey
)
}
}
extension GenericHash {

View File

@ -14,12 +14,24 @@ internal extension OnionRequestAPI {
}
/// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request.
static func encrypt(_ payload: String, for destination: Destination) -> Promise<AESGCM.EncryptionResult> {
static func encrypt(_ payload: String, for destination: Destination, with version: Version) -> Promise<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.pending()
DispatchQueue.global(qos: .userInitiated).async {
do {
guard let data = payload.data(using: .utf8) else {
throw Error.invalidRequestInfo
guard let payloadAsData: Data = payload.data(using: .utf8) else { throw Error.invalidRequestInfo }
let data: Data
switch version {
case .v2, .v3:
// Wrapping is only needed for snode requests
switch destination {
case .snode: data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ])
case .server: data = payloadAsData
}
case .v4:
data = payloadAsData
}
let result = try encrypt(data, for: destination)
@ -33,34 +45,6 @@ internal extension OnionRequestAPI {
return promise
}
static func encrypt(_ payload: JSON, for destination: Destination) -> Promise<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.pending()
DispatchQueue.global(qos: .userInitiated).async {
do {
guard JSONSerialization.isValidJSONObject(payload) else { return seal.reject(HTTP.Error.invalidJSON) }
// Wrapping isn't needed for file server or open group onion requests
switch destination {
case .snode:
let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ])
let data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ])
let result = try encrypt(data, for: destination)
seal.fulfill(result)
case .server:
let data = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ])
let result = try encrypt(data, for: destination)
seal.fulfill(result)
}
}
catch (let error) {
seal.reject(error)
}
}
return promise
}
private static func encrypt(_ payload: Data, for destination: Destination) throws -> AESGCM.EncryptionResult {
switch destination {
case .snode(let snode):

View File

@ -245,7 +245,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
}
/// Builds an onion around `payload` and returns the result.
private static func buildOnion(around payload: String, targetedAt destination: Destination) -> Promise<OnionBuildingResult> {
private static func buildOnion(around payload: String, targetedAt destination: Destination, version: Version) -> Promise<OnionBuildingResult> {
var guardSnode: Snode!
var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination
var encryptionResult: AESGCM.EncryptionResult!
@ -254,7 +254,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
return getPath(excluding: snodeToExclude).then2 { path -> Promise<AESGCM.EncryptionResult> in
guardSnode = path.first!
// Encrypt in reverse order, i.e. the destination first
return encrypt(payload, for: destination).then2 { r -> Promise<AESGCM.EncryptionResult> in
return encrypt(payload, for: destination, with: version).then2 { r -> Promise<AESGCM.EncryptionResult> in
targetSnodeSymmetricKey = r.symmetricKey
// Recursively encrypt the layers of the onion (again in reverse order)
encryptionResult = r
@ -328,7 +328,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
let (promise, seal) = Promise<(OnionRequestResponseInfoType, Data?)>.pending()
var guardSnode: Snode?
Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths`
buildOnion(around: payload, targetedAt: destination).done2 { intermediate in
buildOnion(around: payload, targetedAt: destination, version: version).done2 { intermediate in
guardSnode = intermediate.guardSnode
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"
let finalEncryptionResult = intermediate.finalEncryptionResult

View File

@ -131,8 +131,15 @@ public final class SnodeAPI : NSObject {
// MARK: Internal API
internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> RawResponsePromise {
if Features.useOnionRequests {
// TODO: Ensure this should use the v3 request?
return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, using: .v3, associatedWith: publicKey).map2 { $0 as Any }
return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, using: .v3, associatedWith: publicKey)
.map2 { responseData in
guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else {
throw Error.generic
}
// FIXME: Would be nice to change this to not send 'Any'
return responseJson as Any
}
} else {
let url = "\(snode.address):\(snode.port)/storage_rpc/v1"
return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise<Any> in