Implement OpenGroupAPIV2

This commit is contained in:
Niels Andriesse 2021-03-19 16:47:00 +11:00 committed by nielsandriesse
parent d11db4cb03
commit 34bbff1ab4
9 changed files with 291 additions and 10 deletions

View File

@ -251,6 +251,7 @@
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; };
B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; };
B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; };
B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; };
B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; };
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; };
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; };
@ -310,6 +311,7 @@
C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; };
C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; };
C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */; };
C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */; };
C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; };
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328250E25CA06020062D0A7 /* VoiceMessageView.swift */; };
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328251E25CA3A900062D0A7 /* QuoteView.swift */; };
@ -1234,6 +1236,7 @@
B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = "<group>"; };
B886B4A62398B23E00211ABE /* QRCodeVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeVC.swift; sourceTree = "<group>"; };
B886B4A82398BA1500211ABE /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = "<group>"; };
B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIV2.swift; sourceTree = "<group>"; };
B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = "<group>"; };
B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = "<group>"; };
B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = "<group>"; };
@ -1306,6 +1309,7 @@
C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionVC.swift; sourceTree = "<group>"; };
C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = "<group>"; };
C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairUtilities.swift; sourceTree = "<group>"; };
C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupMessageV2.swift; sourceTree = "<group>"; };
C328250E25CA06020062D0A7 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = "<group>"; };
C328251E25CA3A900062D0A7 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = "<group>"; };
C328252F25CA55360062D0A7 /* ContextMenuWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuWindow.swift; sourceTree = "<group>"; };
@ -2495,6 +2499,26 @@
path = Meta;
sourceTree = "<group>";
};
C3227FF4260AAD58006EA627 /* V2 */ = {
isa = PBXGroup;
children = (
B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */,
C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */,
);
path = V2;
sourceTree = "<group>";
};
C3228005260AAD7E006EA627 /* V1 */ = {
isa = PBXGroup;
children = (
C3A721352558BDF90043A11F /* OpenGroupAPI.swift */,
C3A721362558BDFA0043A11F /* OpenGroupInfo.swift */,
C3A721342558BDF90043A11F /* OpenGroupMessage.swift */,
C3A7229B2558E4310043A11F /* OpenGroupMessage+Conversion.swift */,
);
path = V1;
sourceTree = "<group>";
};
C328252E25CA54F70062D0A7 /* Context Menu */ = {
isa = PBXGroup;
children = (
@ -3158,11 +3182,9 @@
C3A721332558BDDF0043A11F /* Open Groups */ = {
isa = PBXGroup;
children = (
C3228005260AAD7E006EA627 /* V1 */,
C3227FF4260AAD58006EA627 /* V2 */,
C3A721372558BDFA0043A11F /* OpenGroup.swift */,
C3A721352558BDF90043A11F /* OpenGroupAPI.swift */,
C3A721362558BDFA0043A11F /* OpenGroupInfo.swift */,
C3A721342558BDF90043A11F /* OpenGroupMessage.swift */,
C3A7229B2558E4310043A11F /* OpenGroupMessage+Conversion.swift */,
C3AAFFCB25AE92150089E6DD /* OpenGroupManager.swift */,
);
path = "Open Groups";
@ -4733,6 +4755,7 @@
C32C5BDD256DC88D003C73A2 /* OWSReadReceiptManager.m in Sources */,
C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */,
C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */,
C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */,
B8B32021258B1A650020074B /* Contact.swift in Sources */,
C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */,
C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */,
@ -4801,6 +4824,7 @@
C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */,
C3A7222A2558C1E40043A11F /* DotNetAPI.swift in Sources */,
C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */,
B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */,
C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */,
C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */,
C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */,

View File

@ -0,0 +1,210 @@
import PromiseKit
import SessionSnodeKit
// TODO: Auth token & public key storage
public enum OpenGroupAPIV2 {
// MARK: Error
public enum Error : LocalizedError {
case generic
case parsingFailed
case decryptionFailed
case signingFailed
case invalidURL
case noPublicKey
public var errorDescription: String? {
switch self {
case .generic: return "An error occurred."
case .parsingFailed: return "Invalid response."
case .decryptionFailed: return "Couldn't decrypt response."
case .signingFailed: return "Couldn't sign message."
case .invalidURL: return "Invalid URL."
case .noPublicKey: return "Couldn't find server public key."
}
}
}
// MARK: Request
private struct Request {
let verb: HTTP.Verb
let room: String
let server: String
let endpoint: String
let queryParameters: [String:String]
let parameters: JSON
let isAuthRequired: Bool
/// Always `true` under normal circumstances. You might want to disable
/// this when running over Lokinet.
let useOnionRouting: Bool
init(verb: HTTP.Verb, room: String, server: String, endpoint: String, queryParameters: [String:String] = [:],
parameters: JSON = [:], isAuthRequired: Bool = true, useOnionRouting: Bool = true) {
self.verb = verb
self.room = room
self.server = server
self.endpoint = endpoint
self.queryParameters = queryParameters
self.parameters = parameters
self.isAuthRequired = isAuthRequired
self.useOnionRouting = useOnionRouting
}
}
// MARK: Convenience
private static func send(_ request: Request) -> Promise<JSON> {
let tsRequest: TSRequest
switch request.verb {
case .get:
var rawURL = "\(request.server)/\(request.endpoint)"
if !request.queryParameters.isEmpty {
let queryString = request.queryParameters.map { key, value in "\(key)=\(value)" }.joined(separator: "&")
rawURL += "?\(queryString)"
}
guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) }
tsRequest = TSRequest(url: url)
case .post, .put, .delete:
let rawURL = "\(request.server)/\(request.endpoint)"
guard let url = URL(string: rawURL) else { return Promise(error: Error.invalidURL) }
tsRequest = TSRequest(url: url, method: request.verb.rawValue, parameters: request.parameters)
}
tsRequest.setValue(request.room, forKey: "Room")
if request.useOnionRouting {
guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { return Promise(error: Error.noPublicKey) }
return getAuthToken(for: request.server).then(on: DispatchQueue.global(qos: .default)) { authToken -> Promise<JSON> in
tsRequest.setValue(authToken, forKey: "Authorization")
return OnionRequestAPI.sendOnionRequest(tsRequest, to: request.server, using: publicKey)
}
} else {
preconditionFailure("It's currently not allowed to send non onion routed requests.")
}
}
// MARK: Authorization
private static func getAuthToken(for server: String) -> Promise<String> {
return Promise.value("") // TODO: Implement
}
public static func requestNewAuthToken(for room: String, on server: String) -> Promise<String> {
SNLog("Requesting auth token for server: \(server).")
guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return Promise(error: Error.generic) }
let queryParameters = [ "public_key" : getUserHexEncodedPublicKey() ]
let request = Request(verb: .get, room: room, server: server, endpoint: "auth_token_challenge", queryParameters: queryParameters, isAuthRequired: false)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let base64EncodedCiphertext = json["ciphertext"] as? String, let base64EncodedEphemeralPublicKey = json["ephemeral_public_key"] as? String,
let ciphertext = Data(base64Encoded: base64EncodedCiphertext), let ephemeralPublicKey = Data(base64Encoded: base64EncodedEphemeralPublicKey) else {
throw Error.parsingFailed
}
let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey)
guard let tokenAsData = try? AESGCM.decrypt(ciphertext, with: symmetricKey) else { throw Error.decryptionFailed }
return tokenAsData.toHexString()
}
}
public static func claimAuthToken(for room: String, on server: String) -> Promise<Void> {
guard let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() else { return Promise(error: Error.generic) }
let parameters = [ "public_key" : userKeyPair.publicKey.toHexString() ]
let request = Request(verb: .post, room: room, server: server, endpoint: "claim_auth_token", parameters: parameters)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { _ in }
}
/// Should be called when leaving a group.
public static func deleteAuthToken(for room: String, on server: String) -> Promise<Void> {
let request = Request(verb: .delete, room: room, server: server, endpoint: "auth_token")
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { _ in }
}
// MARK: File Storage
public static func upload(_ file: Data, to room: String, on server: String) -> Promise<String> {
let base64EncodedFile = file.base64EncodedString()
let parameters = [ "file" : base64EncodedFile ]
let request = Request(verb: .post, room: room, server: server, endpoint: "files", parameters: parameters)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let fileID = json["result"] as? String else { throw Error.parsingFailed }
return fileID
}
}
public static func download(_ file: String, from room: String, on server: String) -> Promise<Data> {
let request = Request(verb: .get, room: room, server: server, endpoint: "files/\(file)")
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let base64EncodedFile = json["result"] as? String, let file = Data(base64Encoded: base64EncodedFile) else { throw Error.parsingFailed }
return file
}
}
// MARK: Message Sending & Receiving
public static func send(_ message: OpenGroupMessageV2, to room: String, on server: String) -> Promise<OpenGroupMessageV2> {
guard let signedMessage = message.sign() else { return Promise(error: Error.signingFailed) }
guard let json = signedMessage.toJSON() else { return Promise(error: Error.parsingFailed) }
let request = Request(verb: .post, room: room, server: server, endpoint: "messages", parameters: json)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let message = OpenGroupMessageV2.fromJSON(json) else { throw Error.parsingFailed }
return message
}
}
public static func getMessages(for room: String, on server: String) -> Promise<[OpenGroupMessageV2]> {
// TODO: From server ID & limit
let queryParameters: [String:String] = [:]
let request = Request(verb: .get, room: room, server: server, endpoint: "messages", queryParameters: queryParameters)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let rawMessages = json["messages"] as? [[String:Any]] else { throw Error.parsingFailed }
let messages: [OpenGroupMessageV2] = rawMessages.compactMap { json in
// TODO: Signature validation
guard let message = OpenGroupMessageV2.fromJSON(json) else {
SNLog("Couldn't parse open group message from JSON: \(json).")
return nil
}
return message
}
return messages
}
}
// MARK: Message Deletion
public static func deleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise<Void> {
let request = Request(verb: .delete, room: room, server: server, endpoint: "messages/\(serverID)")
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { _ in }
}
public static func getDeletedMessages(for room: String, on server: String) -> Promise<[Int64]> {
// TODO: From server ID & limit
let queryParameters: [String:String] = [:]
let request = Request(verb: .get, room: room, server: server, endpoint: "deleted_messages", queryParameters: queryParameters)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let ids = json["ids"] as? [Int64] else { throw Error.parsingFailed }
return ids
}
}
// MARK: Moderation
public static func getModerators(for room: String, on server: String) -> Promise<[String]> {
let request = Request(verb: .get, room: room, server: server, endpoint: "moderators")
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let moderators = json["moderators"] as? [String] else { throw Error.parsingFailed }
return moderators
}
}
public static func ban(_ publicKey: String, from room: String, on server: String) -> Promise<Void> {
let parameters = [ "public_key" : publicKey ]
let request = Request(verb: .post, room: room, server: server, endpoint: "block_list", parameters: parameters)
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { _ in }
}
public static func unban(_ publicKey: String, from room: String, on server: String) -> Promise<Void> {
let request = Request(verb: .delete, room: room, server: server, endpoint: "block_list/\(publicKey)")
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { _ in }
}
// MARK: General
public static func getMemberCount(for room: String, on server: String) -> Promise<UInt> {
let request = Request(verb: .get, room: room, server: server, endpoint: "member_count")
return send(request).map(on: DispatchQueue.global(qos: .userInitiated)) { json in
guard let memberCount = json["member_count"] as? UInt else { throw Error.parsingFailed }
return memberCount
}
}
}

View File

@ -0,0 +1,33 @@
public struct OpenGroupMessageV2 {
public let serverID: Int64?
/// The serialized protobuf in base64 encoding.
public let base64EncodedData: String
/// When sending a message, the sender signs the serialized protobuf with their private key so that
/// a receiving user can verify that the message wasn't tampered with.
public let base64EncodedSignature: String?
public func sign() -> OpenGroupMessageV2? {
let userKeyPair = SNMessagingKitConfiguration.shared.storage.getUserKeyPair()!
let data = Data(base64Encoded: base64EncodedData)!
guard let signature = try? Ed25519.sign(data, with: userKeyPair) else {
SNLog("Failed to sign open group message.")
return nil
}
return OpenGroupMessageV2(serverID: serverID, base64EncodedData: base64EncodedData, base64EncodedSignature: signature.base64EncodedString())
}
public func toJSON() -> JSON? {
var result: JSON = [ "data" : base64EncodedData ]
if let serverID = serverID { result["server_id"] = serverID }
if let base64EncodedSignature = base64EncodedSignature { result["signature"] = base64EncodedSignature }
return result
}
public static func fromJSON(_ json: JSON) -> OpenGroupMessageV2? {
guard let base64EncodedData = json["data"] as? String else { return nil }
let serverID = json["server_id"] as? Int64
let base64EncodedSignature = json["signature"] as? String
return OpenGroupMessageV2(serverID: serverID, base64EncodedData: base64EncodedData, base64EncodedSignature: base64EncodedSignature)
}
}

View File

@ -374,12 +374,16 @@ public enum OnionRequestAPI {
let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidJSON) }
do {
let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey)
// The old open group server and file server implementations put the status code in the JSON under the "status"
// key, whereas the new implementations put it under the "status_code" key
guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON,
let statusCode = json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) }
let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) }
if statusCode == 406 { // Clock out of sync
SNLog("The user's clock is out of sync with the service node network.")
seal.reject(SnodeAPI.Error.clockOutOfSync)
} else if let bodyAsString = json["body"] as? String {
// This clause is only used by the old open group and file server implementations. The new implementations will
// always go to the next clause.
let body: JSON
if !isJSONRequired {
body = [ "result" : bodyAsString ]

View File

@ -19,6 +19,20 @@ public enum AESGCM {
}
}
/// - Note: Sync. Don't call from the main thread.
public static func generateSymmetricKey(x25519PublicKey: Data, x25519PrivateKey: Data) throws -> Data {
if Thread.isMainThread {
#if DEBUG
preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.")
#endif
}
guard let sharedSecret = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: x25519PrivateKey) else {
throw Error.sharedSecretGenerationFailed
}
let salt = "LOKI"
return try Data(HMAC(key: salt.bytes, variant: .sha256).authenticate(sharedSecret.bytes))
}
/// - Note: Sync. Don't call from the main thread.
public static func decrypt(_ ivAndCiphertext: Data, with symmetricKey: Data) throws -> Data {
if Thread.isMainThread {
@ -56,11 +70,7 @@ public enum AESGCM {
}
let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey)
let ephemeralKeyPair = Curve25519.generateKeyPair()
guard let ephemeralSharedSecret = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: ephemeralKeyPair.privateKey) else {
throw Error.sharedSecretGenerationFailed
}
let salt = "LOKI"
let symmetricKey = try HMAC(key: salt.bytes, variant: .sha256).authenticate(ephemeralSharedSecret.bytes)
let symmetricKey = try generateSymmetricKey(x25519PublicKey: x25519PublicKey, x25519PrivateKey: ephemeralKeyPair.privateKey)
let ciphertext = try encrypt(plaintext, with: Data(symmetricKey))
return EncryptionResult(ciphertext: ciphertext, symmetricKey: Data(symmetricKey), ephemeralPublicKey: ephemeralKeyPair.publicKey)
}