Create SignalUtilitiesKit
This commit is contained in:
parent
82127bfe4d
commit
c475f895e8
20
Podfile
20
Podfile
|
@ -98,6 +98,26 @@ target 'SignalMessaging' do
|
|||
shared_pods
|
||||
end
|
||||
|
||||
target 'SignalUtilitiesKit' do
|
||||
pod 'AFNetworking', inhibit_warnings: true
|
||||
pod 'CryptoSwift', :inhibit_warnings => true
|
||||
pod 'Curve25519Kit', :inhibit_warnings => true
|
||||
pod 'GRKOpenSSLFramework', :inhibit_warnings => true
|
||||
pod 'HKDFKit', :inhibit_warnings => true
|
||||
pod 'libPhoneNumber-iOS', :inhibit_warnings => true
|
||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master', :inhibit_warnings => true
|
||||
pod 'PromiseKit', :inhibit_warnings => true
|
||||
pod 'Reachability', :inhibit_warnings => true
|
||||
pod 'SAMKeychain', :inhibit_warnings => true
|
||||
pod 'Starscream', git: 'https://github.com/signalapp/Starscream.git', branch: 'signal-release', :inhibit_warnings => true
|
||||
pod 'SwiftProtobuf', '~> 1.5.0', :inhibit_warnings => true
|
||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/signalapp/YapDatabase.git', branch: 'signal-release', :inhibit_warnings => true
|
||||
end
|
||||
|
||||
target 'SessionUIKit' do
|
||||
|
||||
end
|
||||
|
||||
target 'SessionMessagingKit' do
|
||||
pod 'AFNetworking', inhibit_warnings: true
|
||||
pod 'CryptoSwift', :inhibit_warnings => true
|
||||
|
|
|
@ -206,12 +206,14 @@ DEPENDENCIES:
|
|||
- FeedKit (~> 8.1)
|
||||
- GRKOpenSSLFramework
|
||||
- HKDFKit
|
||||
- libPhoneNumber-iOS
|
||||
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
|
||||
- NVActivityIndicatorView (~> 4.7)
|
||||
- PromiseKit
|
||||
- PromiseKit (= 6.5.3)
|
||||
- PureLayout (~> 3.1.4)
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SessionAxolotlKit (from `https://github.com/loki-project/session-ios-protocol-kit.git`, branch `master`)
|
||||
- SessionAxolotlKit/Tests (from `https://github.com/loki-project/session-ios-protocol-kit.git`, branch `master`)
|
||||
- SessionCoreKit (from `https://github.com/loki-project/session-ios-core-kit.git`)
|
||||
|
@ -333,6 +335,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: 6db68da66f20d9f169ceb94dfb9947c3867b9665
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 8fc5917e97576b902a46b328af80664381ede889
|
||||
PODFILE CHECKSUM: d78dc9a752cd3ce8f01fa327b8518dff3f5236d5
|
||||
|
||||
COCOAPODS: 1.10.0.rc.1
|
||||
|
|
2
Pods
2
Pods
|
@ -1 +1 @@
|
|||
Subproject commit 0c79ca436b633fdf1b0daf90e86fd323dcc60c55
|
||||
Subproject commit e28da414f77b9cba508c92e90b16b815847cde7e
|
|
@ -13,7 +13,7 @@ public struct Configuration {
|
|||
internal static var shared: Configuration!
|
||||
}
|
||||
|
||||
public enum SessionMessagingKit { // Just to make the external API nice
|
||||
public enum SessionMessagingKitX { // Just to make the external API nice
|
||||
|
||||
public static func configure(
|
||||
storage: SessionMessagingKitStorageProtocol,
|
||||
|
|
|
@ -2,15 +2,15 @@ import PromiseKit
|
|||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
internal enum MessageSender {
|
||||
public enum MessageSender {
|
||||
|
||||
internal enum Error : LocalizedError {
|
||||
public enum Error : LocalizedError {
|
||||
case invalidMessage
|
||||
case protoConversionFailed
|
||||
case proofOfWorkCalculationFailed
|
||||
case noUserPublicKey
|
||||
|
||||
internal var errorDescription: String? {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidMessage: return "Invalid message."
|
||||
case .protoConversionFailed: return "Couldn't convert message to proto."
|
||||
|
|
|
@ -4,14 +4,20 @@ FOUNDATION_EXPORT double SessionProtocolKitVersionNumber;
|
|||
FOUNDATION_EXPORT const unsigned char SessionProtocolKitVersionString[];
|
||||
|
||||
#import <SessionProtocolKit/AxolotlStore.h>
|
||||
#import <SessionProtocolKit/AxolotlExceptions.h>
|
||||
#import <SessionProtocolKit/ClosedGroupCiphertextMessage.h>
|
||||
#import <SessionProtocolKit/Cryptography.h>
|
||||
#import <SessionProtocolKit/FallbackMessage.h>
|
||||
#import <SessionProtocolKit/NSData+keyVersionByte.h>
|
||||
#import <SessionProtocolKit/NSData+messagePadding.h>
|
||||
#import <SessionProtocolKit/NSData+OWS.h>
|
||||
#import <SessionProtocolKit/NSDate+OWS.h>
|
||||
#import <SessionProtocolKit/NSObject+OWS.h>
|
||||
#import <SessionProtocolKit/NSString+OWS.h>
|
||||
#import <SessionProtocolKit/OWSAsserts.h>
|
||||
#import <SessionProtocolKit/OWSLogs.h>
|
||||
#import <SessionProtocolKit/PreKeyBundle.h>
|
||||
#import <SessionProtocolKit/SerializationUtilities.h>
|
||||
#import <SessionProtocolKit/SessionBuilder.h>
|
||||
#import <SessionProtocolKit/SessionCipher.h>
|
||||
#import <SessionProtocolKit/Threading.h>
|
||||
|
|
|
@ -5,7 +5,7 @@ public final class ClosedGroupSenderKey : NSObject, NSCoding { // NSObject/NSCod
|
|||
public let publicKey: Data
|
||||
|
||||
// MARK: Initialization
|
||||
init(chainKey: Data, keyIndex: UInt, publicKey: Data) {
|
||||
public init(chainKey: Data, keyIndex: UInt, publicKey: Data) {
|
||||
self.chainKey = chainKey
|
||||
self.keyIndex = keyIndex
|
||||
self.publicKey = publicKey
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
#import "NSData+OWS.h"
|
||||
#import <CommonCrypto/CommonCryptor.h>
|
||||
#import <CommonCrypto/CommonHMAC.h>
|
||||
#import <SessionProtocolKit/Randomness.h>
|
||||
#import <Curve25519Kit/Randomness.h>
|
||||
#import <openssl/evp.h>
|
||||
#import <SessionProtocolKit/OWSAsserts.h>
|
||||
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface Randomness : NSObject
|
||||
|
||||
/**
|
||||
* Generates a given number of cryptographically secure bytes using SecRandomCopyBytes.
|
||||
*
|
||||
* @param numberBytes The number of bytes to be generated.
|
||||
*
|
||||
* @return Random Bytes.
|
||||
*/
|
||||
|
||||
+ (NSData *)generateRandomBytes:(int)numberBytes;
|
||||
|
||||
|
||||
@end
|
|
@ -1,24 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "Randomness.h"
|
||||
#import <SessionProtocolKit/OWSAsserts.h>
|
||||
|
||||
@implementation Randomness
|
||||
|
||||
+ (NSData *)generateRandomBytes:(int)numberBytes
|
||||
{
|
||||
NSMutableData *_Nullable randomBytes = [NSMutableData dataWithLength:numberBytes];
|
||||
if (!randomBytes) {
|
||||
OWSFail(@"Could not allocate buffer for random bytes.");
|
||||
}
|
||||
int err = 0;
|
||||
err = SecRandomCopyBytes(kSecRandomDefault, numberBytes, [randomBytes mutableBytes]);
|
||||
if (err != noErr || randomBytes.length != numberBytes) {
|
||||
OWSFail(@"Could not generate random bytes.");
|
||||
}
|
||||
return [randomBytes copy];
|
||||
}
|
||||
|
||||
@end
|
|
@ -3,7 +3,7 @@ import Foundation
|
|||
public final class Snode : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
internal let publicKeySet: KeySet
|
||||
public let publicKeySet: KeySet
|
||||
|
||||
public var ip: String {
|
||||
address.removingPrefix("https://")
|
||||
|
@ -16,9 +16,9 @@ public final class Snode : NSObject, NSCoding { // NSObject/NSCoding conformance
|
|||
case sendMessage = "store"
|
||||
}
|
||||
|
||||
internal struct KeySet {
|
||||
let ed25519Key: String
|
||||
let x25519Key: String
|
||||
public struct KeySet {
|
||||
public let ed25519Key: String
|
||||
public let x25519Key: String
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import PromiseKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public enum SnodeAPI {
|
||||
@objc(SNSnodeAPI)
|
||||
public final class SnodeAPI : NSObject {
|
||||
|
||||
/// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions.
|
||||
internal static var snodeFailureCount: [Snode:UInt] = [:]
|
||||
/// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions.
|
||||
internal static var snodePool: Set<Snode> = []
|
||||
|
||||
/// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions.
|
||||
internal static var swarmCache: [String:Set<Snode>] = [:]
|
||||
|
||||
public static var swarmCache: [String:Set<Snode>] = [:]
|
||||
public static var workQueue: DispatchQueue { Threading.workQueue }
|
||||
|
||||
// MARK: Settings
|
||||
private static let maxRetryCount: UInt = 4
|
||||
private static let minimumSnodePoolCount = 64
|
||||
|
@ -25,11 +28,13 @@ public enum SnodeAPI {
|
|||
|
||||
// MARK: Error
|
||||
public enum Error : LocalizedError {
|
||||
case generic
|
||||
case clockOutOfSync
|
||||
case randomSnodePoolUpdatingFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .generic: return "An error occurred."
|
||||
case .clockOutOfSync: return "Your clock is out of sync with the service node network."
|
||||
case .randomSnodePoolUpdatingFailed: return "Failed to update random service node pool."
|
||||
}
|
||||
|
@ -41,8 +46,8 @@ public enum SnodeAPI {
|
|||
public typealias RawResponse = Any
|
||||
public typealias RawResponsePromise = Promise<RawResponse>
|
||||
|
||||
// MARK: Core
|
||||
internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String, parameters: JSON) -> RawResponsePromise {
|
||||
// MARK: Internal API
|
||||
public static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String, parameters: JSON) -> RawResponsePromise {
|
||||
if useOnionRequests {
|
||||
return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any }
|
||||
} else {
|
||||
|
@ -109,7 +114,40 @@ public enum SnodeAPI {
|
|||
}
|
||||
}
|
||||
|
||||
internal static func getSwarm(for publicKey: String, isForcedReload: Bool = false) -> Promise<Set<Snode>> {
|
||||
internal static func dropSnodeFromSnodePool(_ snode: Snode) {
|
||||
var snodePool = SnodeAPI.snodePool
|
||||
snodePool.remove(snode)
|
||||
SnodeAPI.snodePool = snodePool
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.setSnodePool(to: snodePool, using: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
public static func clearSnodePool() {
|
||||
snodePool.removeAll()
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.setSnodePool(to: [], using: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
public static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) {
|
||||
let swarm = SnodeAPI.swarmCache[publicKey]
|
||||
if var swarm = swarm, let index = swarm.firstIndex(of: snode) {
|
||||
swarm.remove(at: index)
|
||||
SnodeAPI.swarmCache[publicKey] = swarm
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.setSwarm(to: swarm, for: publicKey, using: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func getTargetSnodes(for publicKey: String) -> Promise<[Snode]> {
|
||||
// shuffled() uses the system's default random generator, which is cryptographically secure
|
||||
return getSwarm(for: publicKey).map2 { Array($0.shuffled().prefix(targetSwarmSnodeCount)) }
|
||||
}
|
||||
|
||||
public static func getSwarm(for publicKey: String, isForcedReload: Bool = false) -> Promise<Set<Snode>> {
|
||||
if swarmCache[publicKey] == nil {
|
||||
swarmCache[publicKey] = Configuration.shared.storage.getSwarm(for: publicKey)
|
||||
}
|
||||
|
@ -133,48 +171,26 @@ public enum SnodeAPI {
|
|||
}
|
||||
}
|
||||
|
||||
internal static func getTargetSnodes(for publicKey: String) -> Promise<[Snode]> {
|
||||
// shuffled() uses the system's default random generator, which is cryptographically secure
|
||||
return getSwarm(for: publicKey).map2 { Array($0.shuffled().prefix(targetSwarmSnodeCount)) }
|
||||
}
|
||||
|
||||
internal static func dropSnodeFromSnodePool(_ snode: Snode) {
|
||||
var snodePool = SnodeAPI.snodePool
|
||||
snodePool.remove(snode)
|
||||
SnodeAPI.snodePool = snodePool
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.setSnodePool(to: snodePool, using: transaction)
|
||||
public static func getRawMessages(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise {
|
||||
let storage = Configuration.shared.storage
|
||||
storage.with { transaction in
|
||||
storage.pruneLastMessageHashInfoIfExpired(for: snode, associatedWith: publicKey, using: transaction)
|
||||
}
|
||||
let lastHash = storage.getLastMessageHash(for: snode, associatedWith: publicKey) ?? ""
|
||||
let parameters = [ "pubKey" : publicKey, "lastHash" : lastHash ]
|
||||
return invoke(.getMessages, on: snode, associatedWith: publicKey, parameters: parameters)
|
||||
}
|
||||
|
||||
public static func clearSnodePool() {
|
||||
snodePool.removeAll()
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.setSnodePool(to: [], using: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
internal static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) {
|
||||
let swarm = SnodeAPI.swarmCache[publicKey]
|
||||
if var swarm = swarm, let index = swarm.firstIndex(of: snode) {
|
||||
swarm.remove(at: index)
|
||||
SnodeAPI.swarmCache[publicKey] = swarm
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.setSwarm(to: swarm, for: publicKey, using: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Receiving
|
||||
public static func getMessages(for publicKey: String) -> Promise<Set<MessageListPromise>> {
|
||||
let (promise, seal) = Promise<Set<MessageListPromise>>.pending()
|
||||
let storage = Configuration.shared.storage
|
||||
Threading.workQueue.async {
|
||||
attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) {
|
||||
getTargetSnodes(for: publicKey).mapValues2 { targetSnode in
|
||||
Configuration.shared.storage.with { transaction in
|
||||
Configuration.shared.storage.pruneLastMessageHashInfoIfExpired(for: targetSnode, associatedWith: publicKey, using: transaction)
|
||||
storage.with { transaction in
|
||||
storage.pruneLastMessageHashInfoIfExpired(for: targetSnode, associatedWith: publicKey, using: transaction)
|
||||
}
|
||||
let lastHash = Configuration.shared.storage.getLastMessageHash(for: targetSnode, associatedWith: publicKey) ?? ""
|
||||
let lastHash = storage.getLastMessageHash(for: targetSnode, associatedWith: publicKey) ?? ""
|
||||
let parameters = [ "pubKey" : publicKey, "lastHash" : lastHash ]
|
||||
return invoke(.getMessages, on: targetSnode, associatedWith: publicKey, parameters: parameters).map2 { rawResponse in
|
||||
parseRawMessagesResponse(rawResponse, from: targetSnode, associatedWith: publicKey)
|
||||
|
@ -185,7 +201,6 @@ public enum SnodeAPI {
|
|||
return promise
|
||||
}
|
||||
|
||||
// MARK: Sending
|
||||
public static func sendMessage(_ message: SnodeMessage) -> Promise<Set<RawResponsePromise>> {
|
||||
let (promise, seal) = Promise<Set<RawResponsePromise>>.pending()
|
||||
let publicKey = message.recipient
|
||||
|
@ -230,7 +245,7 @@ public enum SnodeAPI {
|
|||
})
|
||||
}
|
||||
|
||||
internal static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Snode, associatedWith publicKey: String) -> [JSON] {
|
||||
public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Snode, associatedWith publicKey: String) -> [JSON] {
|
||||
guard let json = rawResponse as? JSON, let rawMessages = json["messages"] as? [JSON] else { return [] }
|
||||
updateLastMessageHashValueIfPossible(for: snode, associatedWith: publicKey, from: rawMessages)
|
||||
return removeDuplicates(from: rawMessages, associatedWith: publicKey)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import PromiseKit
|
||||
|
||||
internal extension Thenable {
|
||||
public extension Thenable {
|
||||
|
||||
@discardableResult
|
||||
func then2<U>(_ body: @escaping (T) throws -> U) -> Promise<U.T> where U : Thenable {
|
||||
|
@ -23,7 +23,7 @@ internal extension Thenable {
|
|||
}
|
||||
}
|
||||
|
||||
internal extension Thenable where T: Sequence {
|
||||
public extension Thenable where T: Sequence {
|
||||
|
||||
@discardableResult
|
||||
func mapValues2<U>(_ transform: @escaping (T.Iterator.Element) throws -> U) -> Promise<[U]> {
|
||||
|
@ -31,7 +31,7 @@ internal extension Thenable where T: Sequence {
|
|||
}
|
||||
}
|
||||
|
||||
internal extension Guarantee {
|
||||
public extension Guarantee {
|
||||
|
||||
@discardableResult
|
||||
func then2<U>(_ body: @escaping (T) -> Guarantee<U>) -> Guarantee<U> {
|
||||
|
@ -54,7 +54,7 @@ internal extension Guarantee {
|
|||
}
|
||||
}
|
||||
|
||||
internal extension CatchMixin {
|
||||
public extension CatchMixin {
|
||||
|
||||
@discardableResult
|
||||
func catch2(_ body: @escaping (Error) -> Void) -> PMKFinalizer {
|
||||
|
@ -77,7 +77,7 @@ internal extension CatchMixin {
|
|||
}
|
||||
}
|
||||
|
||||
internal extension CatchMixin where T == Void {
|
||||
public extension CatchMixin where T == Void {
|
||||
|
||||
@discardableResult
|
||||
func recover2(_ body: @escaping(Error) -> Void) -> Guarantee<Void> {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import PromiseKit
|
||||
|
||||
/// Delay the execution of the promise constructed in `body` by `delay` seconds.
|
||||
internal func withDelay<T>(_ delay: TimeInterval, completionQueue: DispatchQueue, body: @escaping () -> Promise<T>) -> Promise<T> {
|
||||
public func withDelay<T>(_ delay: TimeInterval, completionQueue: DispatchQueue, body: @escaping () -> Promise<T>) -> Promise<T> {
|
||||
#if DEBUG
|
||||
assert(Thread.current.isMainThread) // Timers don't do well on background queues
|
||||
#endif
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
|
||||
// TODO define actual type, and validate length
|
||||
public typealias IdentityKey = Data
|
||||
|
||||
/// based on libsignal-service-java's AccountManager class
|
||||
@objc(SSKAccountServiceClient)
|
||||
public class AccountServiceClient: NSObject {
|
||||
|
||||
static var shared = AccountServiceClient()
|
||||
|
||||
private let serviceClient: SignalServiceClient
|
||||
|
||||
override init() {
|
||||
self.serviceClient = SignalServiceRestClient()
|
||||
}
|
||||
|
||||
public func getPreKeysCount() -> Promise<Int> {
|
||||
return serviceClient.getAvailablePreKeys()
|
||||
}
|
||||
|
||||
public func setPreKeys(identityKey: IdentityKey, signedPreKeyRecord: SignedPreKeyRecord, preKeyRecords: [PreKeyRecord]) -> Promise<Void> {
|
||||
return serviceClient.registerPreKeys(identityKey: identityKey, signedPreKeyRecord: signedPreKeyRecord, preKeyRecords: preKeyRecords)
|
||||
}
|
||||
|
||||
public func setSignedPreKey(_ signedPreKey: SignedPreKeyRecord) -> Promise<Void> {
|
||||
return serviceClient.setCurrentSignedPreKey(signedPreKey)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import PromiseKit
|
||||
|
||||
public extension AnyPromise {
|
||||
|
||||
public static func from<T : Any>(_ promise: Promise<T>) -> AnyPromise {
|
||||
let result = AnyPromise(promise)
|
||||
result.retainUntilComplete()
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
static inline BOOL OWSIsDebugBuild()
|
||||
{
|
||||
#ifdef DEBUG
|
||||
return YES;
|
||||
#else
|
||||
return NO;
|
||||
#endif
|
||||
}
|
||||
|
||||
// These are fired whenever the corresponding "main app" or "app extension"
|
||||
// notification is fired.
|
||||
//
|
||||
// 1. This saves you the work of observing both.
|
||||
// 2. This allows us to ensure that any critical work (e.g. re-opening
|
||||
// databases) has been done before app re-enters foreground, etc.
|
||||
extern NSString *const OWSApplicationDidEnterBackgroundNotification;
|
||||
extern NSString *const OWSApplicationWillEnterForegroundNotification;
|
||||
extern NSString *const OWSApplicationWillResignActiveNotification;
|
||||
extern NSString *const OWSApplicationDidBecomeActiveNotification;
|
||||
|
||||
typedef void (^BackgroundTaskExpirationHandler)(void);
|
||||
typedef void (^AppActiveBlock)(void);
|
||||
|
||||
NSString *NSStringForUIApplicationState(UIApplicationState value);
|
||||
|
||||
@class OWSAES256Key;
|
||||
|
||||
@protocol SSKKeychainStorage;
|
||||
|
||||
@protocol AppContext <NSObject>
|
||||
|
||||
@property (nonatomic, readonly) BOOL isMainApp;
|
||||
@property (nonatomic, readonly) BOOL isMainAppAndActive;
|
||||
/// Whether the app was woken up by a silent push notification. This is important for
|
||||
/// determining whether attachments should be downloaded or not.
|
||||
@property (nonatomic) BOOL wasWokenUpByPushNotification;
|
||||
|
||||
// Whether the user is using a right-to-left language like Arabic.
|
||||
@property (nonatomic, readonly) BOOL isRTL;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isRunningTests;
|
||||
|
||||
@property (atomic, nullable) UIWindow *mainWindow;
|
||||
|
||||
// Unlike UIApplication.applicationState, this is thread-safe.
|
||||
// It contains the "last known" application state.
|
||||
//
|
||||
// Because it is updated in response to "will/did-style" events, it is
|
||||
// conservative and skews toward less-active and not-foreground:
|
||||
//
|
||||
// * It doesn't report "is active" until the app is active
|
||||
// and reports "inactive" as soon as it _will become_ inactive.
|
||||
// * It doesn't report "is foreground (but inactive)" until the app is
|
||||
// foreground & inactive and reports "background" as soon as it _will
|
||||
// enter_ background.
|
||||
//
|
||||
// This conservatism is useful, since we want to err on the side of
|
||||
// caution when, for example, we do work that should only be done
|
||||
// when the app is foreground and active.
|
||||
@property (atomic, readonly) UIApplicationState reportedApplicationState;
|
||||
|
||||
// A convenience accessor for reportedApplicationState.
|
||||
//
|
||||
// This method is thread-safe.
|
||||
- (BOOL)isInBackground;
|
||||
|
||||
// A convenience accessor for reportedApplicationState.
|
||||
//
|
||||
// This method is thread-safe.
|
||||
- (BOOL)isAppForegroundAndActive;
|
||||
|
||||
// Should start a background task if isMainApp is YES.
|
||||
// Should just return UIBackgroundTaskInvalid if isMainApp is NO.
|
||||
- (UIBackgroundTaskIdentifier)beginBackgroundTaskWithExpirationHandler:
|
||||
(BackgroundTaskExpirationHandler)expirationHandler;
|
||||
|
||||
// Should be a NOOP if isMainApp is NO.
|
||||
- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)backgroundTaskIdentifier;
|
||||
|
||||
// Should be a NOOP if isMainApp is NO.
|
||||
- (void)ensureSleepBlocking:(BOOL)shouldBeBlocking blockingObjects:(NSArray<id> *)blockingObjects;
|
||||
|
||||
// Should only be called if isMainApp is YES.
|
||||
- (void)setMainAppBadgeNumber:(NSInteger)value;
|
||||
|
||||
- (void)setStatusBarHidden:(BOOL)isHidden animated:(BOOL)isAnimated;
|
||||
|
||||
@property (nonatomic, readonly) CGFloat statusBarHeight;
|
||||
|
||||
// Returns the VC that should be used to present alerts, modals, etc.
|
||||
- (nullable UIViewController *)frontmostViewController;
|
||||
|
||||
// Returns nil if isMainApp is NO
|
||||
@property (nullable, nonatomic, readonly) UIAlertAction *openSystemSettingsAction;
|
||||
|
||||
// Should be a NOOP if isMainApp is NO.
|
||||
- (void)setNetworkActivityIndicatorVisible:(BOOL)value;
|
||||
|
||||
- (void)runNowOrWhenMainAppIsActive:(AppActiveBlock)block;
|
||||
|
||||
@property (atomic, readonly) NSDate *appLaunchTime;
|
||||
|
||||
- (id<SSKKeychainStorage>)keychainStorage;
|
||||
|
||||
- (NSString *)appDocumentDirectoryPath;
|
||||
|
||||
- (NSString *)appSharedDataDirectoryPath;
|
||||
|
||||
- (NSUserDefaults *)appUserDefaults;
|
||||
|
||||
@end
|
||||
|
||||
id<AppContext> CurrentAppContext(void);
|
||||
void SetCurrentAppContext(id<AppContext> appContext);
|
||||
|
||||
void ExitShareExtension(void);
|
||||
|
||||
#ifdef DEBUG
|
||||
void ClearCurrentAppContextForTests(void);
|
||||
#endif
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "AppContext.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSString *const OWSApplicationDidEnterBackgroundNotification = @"OWSApplicationDidEnterBackgroundNotification";
|
||||
NSString *const OWSApplicationWillEnterForegroundNotification = @"OWSApplicationWillEnterForegroundNotification";
|
||||
NSString *const OWSApplicationWillResignActiveNotification = @"OWSApplicationWillResignActiveNotification";
|
||||
NSString *const OWSApplicationDidBecomeActiveNotification = @"OWSApplicationDidBecomeActiveNotification";
|
||||
|
||||
NSString *NSStringForUIApplicationState(UIApplicationState value)
|
||||
{
|
||||
switch (value) {
|
||||
case UIApplicationStateActive:
|
||||
return @"UIApplicationStateActive";
|
||||
case UIApplicationStateInactive:
|
||||
return @"UIApplicationStateInactive";
|
||||
case UIApplicationStateBackground:
|
||||
return @"UIApplicationStateBackground";
|
||||
}
|
||||
}
|
||||
|
||||
static id<AppContext> currentAppContext = nil;
|
||||
|
||||
id<AppContext> CurrentAppContext(void)
|
||||
{
|
||||
OWSCAssertDebug(currentAppContext);
|
||||
|
||||
return currentAppContext;
|
||||
}
|
||||
|
||||
void SetCurrentAppContext(id<AppContext> appContext)
|
||||
{
|
||||
// The main app context should only be set once.
|
||||
//
|
||||
// App extensions may be opened multiple times in the same process,
|
||||
// so statics will persist.
|
||||
OWSCAssertDebug(!currentAppContext || !currentAppContext.isMainApp);
|
||||
|
||||
currentAppContext = appContext;
|
||||
}
|
||||
|
||||
#ifdef DEBUG
|
||||
void ClearCurrentAppContextForTests()
|
||||
{
|
||||
currentAppContext = nil;
|
||||
}
|
||||
#endif
|
||||
|
||||
void ExitShareExtension(void)
|
||||
{
|
||||
OWSLogInfo(@"ExitShareExtension");
|
||||
[DDLog flushLog];
|
||||
exit(0);
|
||||
}
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef void (^AppReadyBlock)(void);
|
||||
|
||||
@interface AppReadiness : NSObject
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
|
||||
// This method can be called on any thread.
|
||||
+ (BOOL)isAppReady;
|
||||
|
||||
// This method should only be called on the main thread.
|
||||
+ (void)setAppIsReady;
|
||||
|
||||
// If the app is ready, the block is called immediately;
|
||||
// otherwise it is called when the app becomes ready.
|
||||
//
|
||||
// This method should only be called on the main thread.
|
||||
// The block will always be called on the main thread.
|
||||
//
|
||||
// * The "will become ready" blocks are called before the "did become ready" blocks.
|
||||
// * The "will become ready" blocks should be used for internal setup of components
|
||||
// so that they are ready to interact with other components of the system.
|
||||
// * The "did become ready" blocks should be used for any work that should be done
|
||||
// on app launch, especially work that uses other components.
|
||||
// * We should usually use "did become ready" blocks since they are safer.
|
||||
+ (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block NS_SWIFT_NAME(runNowOrWhenAppWillBecomeReady(_:));
|
||||
+ (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block NS_SWIFT_NAME(runNowOrWhenAppDidBecomeReady(_:));
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "AppReadiness.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
#import "AppContext.h"
|
||||
#import "SSKAsserts.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AppReadiness ()
|
||||
|
||||
@property (atomic) BOOL isAppReady;
|
||||
|
||||
@property (nonatomic) NSMutableArray<AppReadyBlock> *appWillBecomeReadyBlocks;
|
||||
@property (nonatomic) NSMutableArray<AppReadyBlock> *appDidBecomeReadyBlocks;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation AppReadiness
|
||||
|
||||
+ (instancetype)sharedManager
|
||||
{
|
||||
static AppReadiness *sharedMyManager = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sharedMyManager = [[self alloc] initDefault];
|
||||
});
|
||||
return sharedMyManager;
|
||||
}
|
||||
|
||||
- (instancetype)initDefault
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
OWSSingletonAssert();
|
||||
|
||||
self.appWillBecomeReadyBlocks = [NSMutableArray new];
|
||||
self.appDidBecomeReadyBlocks = [NSMutableArray new];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
+ (BOOL)isAppReady
|
||||
{
|
||||
return [self.sharedManager isAppReady];
|
||||
}
|
||||
|
||||
+ (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block
|
||||
{
|
||||
DispatchMainThreadSafe(^{
|
||||
[self.sharedManager runNowOrWhenAppWillBecomeReady:block];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
OWSAssertDebug(block);
|
||||
|
||||
if (CurrentAppContext().isRunningTests) {
|
||||
// We don't need to do any "on app ready" work in the tests.
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.isAppReady) {
|
||||
block();
|
||||
return;
|
||||
}
|
||||
|
||||
[self.appWillBecomeReadyBlocks addObject:block];
|
||||
}
|
||||
|
||||
+ (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block
|
||||
{
|
||||
DispatchMainThreadSafe(^{
|
||||
[self.sharedManager runNowOrWhenAppDidBecomeReady:block];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
OWSAssertDebug(block);
|
||||
|
||||
if (CurrentAppContext().isRunningTests) {
|
||||
// We don't need to do any "on app ready" work in the tests.
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.isAppReady) {
|
||||
block();
|
||||
return;
|
||||
}
|
||||
|
||||
[self.appDidBecomeReadyBlocks addObject:block];
|
||||
}
|
||||
|
||||
+ (void)setAppIsReady
|
||||
{
|
||||
[self.sharedManager setAppIsReady];
|
||||
}
|
||||
|
||||
- (void)setAppIsReady
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
OWSAssertDebug(!self.isAppReady);
|
||||
|
||||
OWSLogInfo(@"");
|
||||
|
||||
self.isAppReady = YES;
|
||||
|
||||
[self runAppReadyBlocks];
|
||||
}
|
||||
|
||||
- (void)runAppReadyBlocks
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
OWSAssertDebug(self.isAppReady);
|
||||
|
||||
NSArray<AppReadyBlock> *appWillBecomeReadyBlocks = [self.appWillBecomeReadyBlocks copy];
|
||||
[self.appWillBecomeReadyBlocks removeAllObjects];
|
||||
NSArray<AppReadyBlock> *appDidBecomeReadyBlocks = [self.appDidBecomeReadyBlocks copy];
|
||||
[self.appDidBecomeReadyBlocks removeAllObjects];
|
||||
|
||||
// We invoke the _will become_ blocks before the _did become_ blocks.
|
||||
for (AppReadyBlock block in appWillBecomeReadyBlocks) {
|
||||
block();
|
||||
}
|
||||
for (AppReadyBlock block in appDidBecomeReadyBlocks) {
|
||||
block();
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AppVersion : NSObject
|
||||
|
||||
// The properties are updated immediately after launch.
|
||||
@property (atomic, readonly) NSString *firstAppVersion;
|
||||
@property (atomic, nullable, readonly) NSString *lastAppVersion;
|
||||
@property (atomic, readonly) NSString *currentAppVersion;
|
||||
|
||||
// There properties aren't updated until appLaunchDidComplete is called.
|
||||
@property (atomic, nullable, readonly) NSString *lastCompletedLaunchAppVersion;
|
||||
@property (atomic, nullable, readonly) NSString *lastCompletedLaunchMainAppVersion;
|
||||
@property (atomic, nullable, readonly) NSString *lastCompletedLaunchSAEAppVersion;
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
|
||||
+ (instancetype)sharedInstance;
|
||||
|
||||
- (void)mainAppLaunchDidComplete;
|
||||
- (void)saeLaunchDidComplete;
|
||||
|
||||
- (BOOL)isFirstLaunch;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,133 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "AppVersion.h"
|
||||
#import "NSUserDefaults+OWS.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSString *const kNSUserDefaults_FirstAppVersion = @"kNSUserDefaults_FirstAppVersion";
|
||||
NSString *const kNSUserDefaults_LastAppVersion = @"kNSUserDefaults_LastVersion";
|
||||
NSString *const kNSUserDefaults_LastCompletedLaunchAppVersion = @"kNSUserDefaults_LastCompletedLaunchAppVersion";
|
||||
NSString *const kNSUserDefaults_LastCompletedLaunchAppVersion_MainApp
|
||||
= @"kNSUserDefaults_LastCompletedLaunchAppVersion_MainApp";
|
||||
NSString *const kNSUserDefaults_LastCompletedLaunchAppVersion_SAE
|
||||
= @"kNSUserDefaults_LastCompletedLaunchAppVersion_SAE";
|
||||
|
||||
@interface AppVersion ()
|
||||
|
||||
@property (atomic) NSString *firstAppVersion;
|
||||
@property (atomic, nullable) NSString *lastAppVersion;
|
||||
@property (atomic) NSString *currentAppVersion;
|
||||
|
||||
@property (atomic, nullable) NSString *lastCompletedLaunchAppVersion;
|
||||
@property (atomic, nullable) NSString *lastCompletedLaunchMainAppVersion;
|
||||
@property (atomic, nullable) NSString *lastCompletedLaunchSAEAppVersion;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation AppVersion
|
||||
|
||||
+ (instancetype)sharedInstance
|
||||
{
|
||||
static AppVersion *instance = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
instance = [AppVersion new];
|
||||
[instance configure];
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
- (void)configure {
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
self.currentAppVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
|
||||
|
||||
// The version of the app when it was first launched.
|
||||
// nil if the app has never been launched before.
|
||||
self.firstAppVersion = [[NSUserDefaults appUserDefaults] objectForKey:kNSUserDefaults_FirstAppVersion];
|
||||
// The version of the app the last time it was launched.
|
||||
// nil if the app has never been launched before.
|
||||
self.lastAppVersion = [[NSUserDefaults appUserDefaults] objectForKey:kNSUserDefaults_LastAppVersion];
|
||||
self.lastCompletedLaunchAppVersion =
|
||||
[[NSUserDefaults appUserDefaults] objectForKey:kNSUserDefaults_LastCompletedLaunchAppVersion];
|
||||
self.lastCompletedLaunchMainAppVersion =
|
||||
[[NSUserDefaults appUserDefaults] objectForKey:kNSUserDefaults_LastCompletedLaunchAppVersion_MainApp];
|
||||
self.lastCompletedLaunchSAEAppVersion =
|
||||
[[NSUserDefaults appUserDefaults] objectForKey:kNSUserDefaults_LastCompletedLaunchAppVersion_SAE];
|
||||
|
||||
// Ensure the value for the "first launched version".
|
||||
if (!self.firstAppVersion) {
|
||||
self.firstAppVersion = self.currentAppVersion;
|
||||
[[NSUserDefaults appUserDefaults] setObject:self.currentAppVersion forKey:kNSUserDefaults_FirstAppVersion];
|
||||
}
|
||||
|
||||
// Update the value for the "most recently launched version".
|
||||
[[NSUserDefaults appUserDefaults] setObject:self.currentAppVersion forKey:kNSUserDefaults_LastAppVersion];
|
||||
[[NSUserDefaults appUserDefaults] synchronize];
|
||||
|
||||
// The long version string looks like an IPv4 address.
|
||||
// To prevent the log scrubber from scrubbing it,
|
||||
// we replace . with _.
|
||||
NSString *longVersionString = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]
|
||||
stringByReplacingOccurrencesOfString:@"."
|
||||
withString:@"_"];
|
||||
|
||||
OWSLogInfo(@"firstAppVersion: %@", self.firstAppVersion);
|
||||
OWSLogInfo(@"lastAppVersion: %@", self.lastAppVersion);
|
||||
OWSLogInfo(@"currentAppVersion: %@ (%@)", self.currentAppVersion, longVersionString);
|
||||
|
||||
OWSLogInfo(@"lastCompletedLaunchAppVersion: %@", self.lastCompletedLaunchAppVersion);
|
||||
OWSLogInfo(@"lastCompletedLaunchMainAppVersion: %@", self.lastCompletedLaunchMainAppVersion);
|
||||
OWSLogInfo(@"lastCompletedLaunchSAEAppVersion: %@", self.lastCompletedLaunchSAEAppVersion);
|
||||
}
|
||||
|
||||
- (void)appLaunchDidComplete
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
OWSLogInfo(@"appLaunchDidComplete");
|
||||
|
||||
self.lastCompletedLaunchAppVersion = self.currentAppVersion;
|
||||
|
||||
// Update the value for the "most recently launch-completed version".
|
||||
[[NSUserDefaults appUserDefaults] setObject:self.currentAppVersion
|
||||
forKey:kNSUserDefaults_LastCompletedLaunchAppVersion];
|
||||
[[NSUserDefaults appUserDefaults] synchronize];
|
||||
}
|
||||
|
||||
- (void)mainAppLaunchDidComplete
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
self.lastCompletedLaunchMainAppVersion = self.currentAppVersion;
|
||||
[[NSUserDefaults appUserDefaults] setObject:self.currentAppVersion
|
||||
forKey:kNSUserDefaults_LastCompletedLaunchAppVersion_MainApp];
|
||||
|
||||
[self appLaunchDidComplete];
|
||||
}
|
||||
|
||||
- (void)saeLaunchDidComplete
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
self.lastCompletedLaunchSAEAppVersion = self.currentAppVersion;
|
||||
[[NSUserDefaults appUserDefaults] setObject:self.currentAppVersion
|
||||
forKey:kNSUserDefaults_LastCompletedLaunchAppVersion_SAE];
|
||||
|
||||
[self appLaunchDidComplete];
|
||||
}
|
||||
|
||||
- (BOOL)isFirstLaunch
|
||||
{
|
||||
return self.firstAppVersion != nil;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
public extension Array where Element : CustomStringConvertible {
|
||||
|
||||
public var prettifiedDescription: String {
|
||||
return "[ " + map { $0.description }.joined(separator: ", ") + " ]"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
public enum BuildConfiguration : String, CustomStringConvertible {
|
||||
case debug, production
|
||||
|
||||
public static let current: BuildConfiguration = {
|
||||
#if DEBUG
|
||||
return .debug
|
||||
#else
|
||||
return .production
|
||||
#endif
|
||||
}()
|
||||
|
||||
public var description: String { return rawValue }
|
||||
}
|
||||
|
||||
@objc public final class LKBuildConfiguration : NSObject {
|
||||
|
||||
override private init() { }
|
||||
|
||||
@objc public static var current: String { return BuildConfiguration.current.description }
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ByteParser : NSObject
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasError;
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
|
||||
- (instancetype)initWithData:(NSData *)data littleEndian:(BOOL)littleEndian;
|
||||
|
||||
#pragma mark - Short
|
||||
|
||||
- (uint16_t)shortAtIndex:(NSUInteger)index;
|
||||
- (uint16_t)nextShort;
|
||||
|
||||
#pragma mark - Int
|
||||
|
||||
- (uint32_t)intAtIndex:(NSUInteger)index;
|
||||
- (uint32_t)nextInt;
|
||||
|
||||
#pragma mark - Long
|
||||
|
||||
- (uint64_t)longAtIndex:(NSUInteger)index;
|
||||
- (uint64_t)nextLong;
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (BOOL)readZero:(NSUInteger)length;
|
||||
|
||||
- (nullable NSData *)readBytes:(NSUInteger)length;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,143 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "ByteParser.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ByteParser ()
|
||||
|
||||
@property (nonatomic, readonly) BOOL littleEndian;
|
||||
@property (nonatomic, readonly) NSData *data;
|
||||
@property (nonatomic) NSUInteger cursor;
|
||||
@property (nonatomic) BOOL hasError;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation ByteParser
|
||||
|
||||
- (instancetype)initWithData:(NSData *)data littleEndian:(BOOL)littleEndian
|
||||
{
|
||||
if (self = [super init]) {
|
||||
_littleEndian = littleEndian;
|
||||
_data = data;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Short
|
||||
|
||||
- (uint16_t)shortAtIndex:(NSUInteger)index
|
||||
{
|
||||
uint16_t value;
|
||||
const size_t valueSize = sizeof(value);
|
||||
OWSAssertDebug(valueSize == 2);
|
||||
if (index + valueSize > self.data.length) {
|
||||
self.hasError = YES;
|
||||
return 0;
|
||||
}
|
||||
[self.data getBytes:&value range:NSMakeRange(index, valueSize)];
|
||||
if (self.littleEndian) {
|
||||
return CFSwapInt16LittleToHost(value);
|
||||
} else {
|
||||
return CFSwapInt16BigToHost(value);
|
||||
}
|
||||
}
|
||||
|
||||
- (uint16_t)nextShort
|
||||
{
|
||||
uint16_t value = [self shortAtIndex:self.cursor];
|
||||
self.cursor += sizeof(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
#pragma mark - Int
|
||||
|
||||
- (uint32_t)intAtIndex:(NSUInteger)index
|
||||
{
|
||||
uint32_t value;
|
||||
const size_t valueSize = sizeof(value);
|
||||
OWSAssertDebug(valueSize == 4);
|
||||
if (index + valueSize > self.data.length) {
|
||||
self.hasError = YES;
|
||||
return 0;
|
||||
}
|
||||
[self.data getBytes:&value range:NSMakeRange(index, valueSize)];
|
||||
if (self.littleEndian) {
|
||||
return CFSwapInt32LittleToHost(value);
|
||||
} else {
|
||||
return CFSwapInt32BigToHost(value);
|
||||
}
|
||||
}
|
||||
|
||||
- (uint32_t)nextInt
|
||||
{
|
||||
uint32_t value = [self intAtIndex:self.cursor];
|
||||
self.cursor += sizeof(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
#pragma mark - Long
|
||||
|
||||
- (uint64_t)longAtIndex:(NSUInteger)index
|
||||
{
|
||||
uint64_t value;
|
||||
const size_t valueSize = sizeof(value);
|
||||
OWSAssertDebug(valueSize == 8);
|
||||
if (index + valueSize > self.data.length) {
|
||||
self.hasError = YES;
|
||||
return 0;
|
||||
}
|
||||
[self.data getBytes:&value range:NSMakeRange(index, valueSize)];
|
||||
if (self.littleEndian) {
|
||||
return CFSwapInt64LittleToHost(value);
|
||||
} else {
|
||||
return CFSwapInt64BigToHost(value);
|
||||
}
|
||||
}
|
||||
|
||||
- (uint64_t)nextLong
|
||||
{
|
||||
uint64_t value = [self longAtIndex:self.cursor];
|
||||
self.cursor += sizeof(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (BOOL)readZero:(NSUInteger)length
|
||||
{
|
||||
NSData *_Nullable subdata = [self readBytes:length];
|
||||
if (!subdata) {
|
||||
return NO;
|
||||
}
|
||||
uint8_t bytes[length];
|
||||
[subdata getBytes:bytes range:NSMakeRange(0, length)];
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (bytes[i] != 0) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (nullable NSData *)readBytes:(NSUInteger)length
|
||||
{
|
||||
NSUInteger index = self.cursor;
|
||||
if (index + length > self.data.length) {
|
||||
self.hasError = YES;
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable subdata = [self.data subdataWithRange:NSMakeRange(index, length)];
|
||||
self.cursor += length;
|
||||
return subdata;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface CDSQuote : NSObject
|
||||
|
||||
@property (nonatomic, readonly) uint16_t version;
|
||||
@property (nonatomic, readonly) uint16_t signType;
|
||||
@property (nonatomic, readonly) BOOL isSigLinkable;
|
||||
@property (nonatomic, readonly) uint32_t gid;
|
||||
@property (nonatomic, readonly) uint16_t qeSvn;
|
||||
@property (nonatomic, readonly) uint16_t pceSvn;
|
||||
@property (nonatomic, readonly) NSData *basename;
|
||||
@property (nonatomic, readonly) NSData *cpuSvn;
|
||||
@property (nonatomic, readonly) uint64_t flags;
|
||||
@property (nonatomic, readonly) uint64_t xfrm;
|
||||
@property (nonatomic, readonly) NSData *mrenclave;
|
||||
@property (nonatomic, readonly) NSData *mrsigner;
|
||||
@property (nonatomic, readonly) uint16_t isvProdId;
|
||||
@property (nonatomic, readonly) uint16_t isvSvn;
|
||||
@property (nonatomic, readonly) NSData *reportData;
|
||||
@property (nonatomic, readonly) NSData *signature;
|
||||
|
||||
+ (nullable CDSQuote *)parseQuoteFromData:(NSData *)quoteData;
|
||||
|
||||
- (BOOL)isDebugQuote;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,192 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "CDSQuote.h"
|
||||
#import "ByteParser.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
static const long SGX_FLAGS_INITTED = 0x0000000000000001L;
|
||||
static const long SGX_FLAGS_DEBUG = 0x0000000000000002L;
|
||||
static const long SGX_FLAGS_MODE64BIT = 0x0000000000000004L;
|
||||
static const long __unused SGX_FLAGS_PROVISION_KEY = 0x0000000000000004L;
|
||||
static const long __unused SGX_FLAGS_EINITTOKEN_KEY = 0x0000000000000004L;
|
||||
static const long SGX_FLAGS_RESERVED = 0xFFFFFFFFFFFFFFC8L;
|
||||
static const long __unused SGX_XFRM_LEGACY = 0x0000000000000003L;
|
||||
static const long __unused SGX_XFRM_AVX = 0x0000000000000006L;
|
||||
static const long SGX_XFRM_RESERVED = 0xFFFFFFFFFFFFFFF8L;
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface CDSQuote ()
|
||||
|
||||
@property (nonatomic) uint16_t version;
|
||||
@property (nonatomic) uint16_t signType;
|
||||
@property (nonatomic) BOOL isSigLinkable;
|
||||
@property (nonatomic) uint32_t gid;
|
||||
@property (nonatomic) uint16_t qeSvn;
|
||||
@property (nonatomic) uint16_t pceSvn;
|
||||
@property (nonatomic) NSData *basename;
|
||||
@property (nonatomic) NSData *cpuSvn;
|
||||
@property (nonatomic) uint64_t flags;
|
||||
@property (nonatomic) uint64_t xfrm;
|
||||
@property (nonatomic) NSData *mrenclave;
|
||||
@property (nonatomic) NSData *mrsigner;
|
||||
@property (nonatomic) uint16_t isvProdId;
|
||||
@property (nonatomic) uint16_t isvSvn;
|
||||
@property (nonatomic) NSData *reportData;
|
||||
@property (nonatomic) NSData *signature;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation CDSQuote
|
||||
|
||||
+ (nullable CDSQuote *)parseQuoteFromData:(NSData *)quoteData
|
||||
{
|
||||
ByteParser *_Nullable parser = [[ByteParser alloc] initWithData:quoteData littleEndian:YES];
|
||||
|
||||
// NOTE: This version is separate from and does _NOT_ match the signature body entity version.
|
||||
uint16_t version = parser.nextShort;
|
||||
if (version < 1 || version > 2) {
|
||||
OWSFailDebug(@"unexpected quote version: %d", (int)version);
|
||||
return nil;
|
||||
}
|
||||
|
||||
uint16_t signType = parser.nextShort;
|
||||
if ((signType & ~1) != 0) {
|
||||
OWSFailDebug(@"invalid signType: %d", (int)signType);
|
||||
return nil;
|
||||
}
|
||||
|
||||
BOOL isSigLinkable = signType == 1;
|
||||
uint32_t gid = parser.nextInt;
|
||||
uint16_t qeSvn = parser.nextShort;
|
||||
|
||||
uint16_t pceSvn = 0;
|
||||
if (version > 1) {
|
||||
pceSvn = parser.nextShort;
|
||||
} else {
|
||||
if (![parser readZero:2]) {
|
||||
OWSFailDebug(@"non-zero pceSvn.");
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
if (![parser readZero:4]) {
|
||||
OWSFailDebug(@"non-zero xeid.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *_Nullable basename = [parser readBytes:32];
|
||||
if (!basename) {
|
||||
OWSFailDebug(@"couldn't read basename.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
// report_body
|
||||
|
||||
NSData *_Nullable cpuSvn = [parser readBytes:16];
|
||||
if (!cpuSvn) {
|
||||
OWSFailDebug(@"couldn't read cpuSvn.");
|
||||
return nil;
|
||||
}
|
||||
if (![parser readZero:4]) {
|
||||
OWSFailDebug(@"non-zero misc_select.");
|
||||
return nil;
|
||||
}
|
||||
if (![parser readZero:28]) {
|
||||
OWSFailDebug(@"non-zero reserved1.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
uint64_t flags = parser.nextLong;
|
||||
if ((flags & SGX_FLAGS_RESERVED) != 0 || (flags & SGX_FLAGS_INITTED) == 0 || (flags & SGX_FLAGS_MODE64BIT) == 0) {
|
||||
OWSFailDebug(@"invalid flags.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
uint64_t xfrm = parser.nextLong;
|
||||
if ((xfrm & SGX_XFRM_RESERVED) != 0) {
|
||||
OWSFailDebug(@"invalid xfrm.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *_Nullable mrenclave = [parser readBytes:32];
|
||||
if (!mrenclave) {
|
||||
OWSFailDebug(@"couldn't read mrenclave.");
|
||||
return nil;
|
||||
}
|
||||
if (![parser readZero:32]) {
|
||||
OWSFailDebug(@"non-zero reserved2.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable mrsigner = [parser readBytes:32];
|
||||
if (!mrsigner) {
|
||||
OWSFailDebug(@"couldn't read mrsigner.");
|
||||
return nil;
|
||||
}
|
||||
if (![parser readZero:96]) {
|
||||
OWSFailDebug(@"non-zero reserved3.");
|
||||
return nil;
|
||||
}
|
||||
uint16_t isvProdId = parser.nextShort;
|
||||
uint16_t isvSvn = parser.nextShort;
|
||||
if (![parser readZero:60]) {
|
||||
OWSFailDebug(@"non-zero reserved4.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable reportData = [parser readBytes:64];
|
||||
if (!reportData) {
|
||||
OWSFailDebug(@"couldn't read reportData.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
// quote signature
|
||||
uint32_t signatureLength = parser.nextInt;
|
||||
if (signatureLength != quoteData.length - 436) {
|
||||
OWSFailDebug(@"invalid signatureLength.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable signature = [parser readBytes:signatureLength];
|
||||
if (!signature) {
|
||||
OWSFailDebug(@"couldn't read signature.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (parser.hasError) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
CDSQuote *quote = [CDSQuote new];
|
||||
quote.version = version;
|
||||
quote.signType = signType;
|
||||
quote.isSigLinkable = isSigLinkable;
|
||||
quote.gid = gid;
|
||||
quote.qeSvn = qeSvn;
|
||||
quote.pceSvn = pceSvn;
|
||||
quote.basename = basename;
|
||||
quote.cpuSvn = cpuSvn;
|
||||
quote.flags = flags;
|
||||
quote.xfrm = xfrm;
|
||||
quote.mrenclave = mrenclave;
|
||||
quote.mrsigner = mrsigner;
|
||||
quote.isvProdId = isvProdId;
|
||||
quote.isvSvn = isvSvn;
|
||||
quote.reportData = reportData;
|
||||
quote.signature = signature;
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
- (BOOL)isDebugQuote
|
||||
{
|
||||
return (self.flags & SGX_FLAGS_DEBUG) != 0;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSUInteger, CDSSigningCertificateErrorCode) {
|
||||
// AssertionError's indicate either developer or some serious system error that should never happen.
|
||||
//
|
||||
// Do not use this for an "expected" error, e.g. something that could be induced by user input which
|
||||
// we specifically need to handle gracefull.
|
||||
CDSSigningCertificateError_AssertionError = 1,
|
||||
|
||||
CDSSigningCertificateError_InvalidPEMSupplied,
|
||||
CDSSigningCertificateError_CouldNotExtractLeafCertificate,
|
||||
CDSSigningCertificateError_InvalidDistinguishedName,
|
||||
CDSSigningCertificateError_UntrustedCertificate
|
||||
};
|
||||
|
||||
NSError *CDSSigningCertificateErrorMake(CDSSigningCertificateErrorCode code, NSString *localizedDescription);
|
||||
|
||||
@interface CDSSigningCertificate : NSObject
|
||||
|
||||
+ (nullable CDSSigningCertificate *)parseCertificateFromPem:(NSString *)certificatePem error:(NSError **)error;
|
||||
|
||||
- (BOOL)verifySignatureOfBody:(NSString *)body signature:(NSData *)theirSignature;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,391 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "CDSSigningCertificate.h"
|
||||
#import <CommonCrypto/CommonCrypto.h>
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
#import <openssl/x509.h>
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSError *CDSSigningCertificateErrorMake(CDSSigningCertificateErrorCode code, NSString *localizedDescription)
|
||||
{
|
||||
return [NSError errorWithDomain:@"CDSSigningCertificate"
|
||||
code:code
|
||||
userInfo:@{ NSLocalizedDescriptionKey : localizedDescription }];
|
||||
}
|
||||
|
||||
@interface CDSSigningCertificate ()
|
||||
|
||||
@property (nonatomic) SecPolicyRef policy;
|
||||
@property (nonatomic) SecTrustRef trust;
|
||||
@property (nonatomic) SecKeyRef publicKey;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation CDSSigningCertificate
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
if (self = [super init]) {
|
||||
_policy = NULL;
|
||||
_trust = NULL;
|
||||
_publicKey = NULL;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
if (_policy) {
|
||||
CFRelease(_policy);
|
||||
_policy = NULL;
|
||||
}
|
||||
if (_trust) {
|
||||
CFRelease(_trust);
|
||||
_trust = NULL;
|
||||
}
|
||||
if (_publicKey) {
|
||||
CFRelease(_publicKey);
|
||||
_publicKey = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
+ (nullable CDSSigningCertificate *)parseCertificateFromPem:(NSString *)certificatePem error:(NSError **)error
|
||||
{
|
||||
OWSAssertDebug(certificatePem);
|
||||
*error = nil;
|
||||
|
||||
CDSSigningCertificate *signingCertificate = [CDSSigningCertificate new];
|
||||
|
||||
NSArray<NSData *> *_Nullable anchorCertificates = [self anchorCertificates];
|
||||
if (anchorCertificates.count < 1) {
|
||||
OWSFailDebug(@"Could not load anchor certificates.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"Could not load anchor certificates.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSArray<NSData *> *_Nullable certificateDerDatas = [self convertPemToDer:certificatePem];
|
||||
|
||||
if (certificateDerDatas.count < 1) {
|
||||
OWSFailDebug(@"Could not parse PEM.");
|
||||
*error = CDSSigningCertificateErrorMake(CDSSigningCertificateError_InvalidPEMSupplied, @"Could not parse PEM.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
// The leaf is always the first certificate.
|
||||
NSData *_Nullable leafCertificateData = [certificateDerDatas firstObject];
|
||||
if (!leafCertificateData) {
|
||||
OWSFailDebug(@"Could not extract leaf certificate data.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_CouldNotExtractLeafCertificate, @"Could not extract leaf certificate data.");
|
||||
return nil;
|
||||
}
|
||||
if (![self verifyDistinguishedNameOfCertificate:leafCertificateData]) {
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_InvalidDistinguishedName, @"Could not extract leaf certificate data.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableArray *certificates = [NSMutableArray new];
|
||||
for (NSData *certificateDerData in certificateDerDatas) {
|
||||
SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)(certificateDerData));
|
||||
if (!certificate) {
|
||||
OWSFailDebug(@"Could not create SecCertificate.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"Could not create SecCertificate.");
|
||||
return nil;
|
||||
}
|
||||
[certificates addObject:(__bridge_transfer id)certificate];
|
||||
}
|
||||
|
||||
SecPolicyRef policy = SecPolicyCreateBasicX509();
|
||||
signingCertificate.policy = policy;
|
||||
if (!policy) {
|
||||
OWSFailDebug(@"Could not create policy.");
|
||||
*error = CDSSigningCertificateErrorMake(CDSSigningCertificateError_AssertionError, @"Could not create policy.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
SecTrustRef trust;
|
||||
OSStatus status = SecTrustCreateWithCertificates((__bridge CFTypeRef)certificates, policy, &trust);
|
||||
signingCertificate.trust = trust;
|
||||
if (status != errSecSuccess) {
|
||||
OWSFailDebug(@"Creating trust did not succeed.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"Creating trust did not succeed.");
|
||||
return nil;
|
||||
}
|
||||
if (!trust) {
|
||||
OWSFailDebug(@"Could not create trust.");
|
||||
*error = CDSSigningCertificateErrorMake(CDSSigningCertificateError_AssertionError, @"Could not create trust.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
status = SecTrustSetNetworkFetchAllowed(trust, NO);
|
||||
if (status != errSecSuccess) {
|
||||
OWSFailDebug(@"trust fetch could not be configured.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"trust fetch could not be configured.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
status = SecTrustSetAnchorCertificatesOnly(trust, YES);
|
||||
if (status != errSecSuccess) {
|
||||
OWSFailDebug(@"trust anchor certs could not be configured.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"trust anchor certs could not be configured.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableArray *pinnedCertificates = [NSMutableArray array];
|
||||
for (NSData *certificateData in anchorCertificates) {
|
||||
SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)(certificateData));
|
||||
if (!certificate) {
|
||||
OWSFailDebug(@"Could not create pinned SecCertificate.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"Could not create pinned SecCertificate.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
[pinnedCertificates addObject:(__bridge_transfer id)certificate];
|
||||
}
|
||||
status = SecTrustSetAnchorCertificates(trust, (__bridge CFArrayRef)pinnedCertificates);
|
||||
if (status != errSecSuccess) {
|
||||
OWSFailDebug(@"The anchor certificates couldn't be set.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"The anchor certificates couldn't be set.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
SecTrustResultType result;
|
||||
status = SecTrustEvaluate(trust, &result);
|
||||
if (status != errSecSuccess) {
|
||||
OWSFailDebug(@"Could not evaluate certificates.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"Could not evaluate certificates.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
// `kSecTrustResultUnspecified` is confusingly named. It indicates success.
|
||||
// See the comments in the header where it is defined.
|
||||
BOOL isValid = (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
|
||||
if (!isValid) {
|
||||
OWSFailDebug(@"Certificate was not trusted.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_UntrustedCertificate, @"Certificate was not trusted.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
SecKeyRef publicKey = SecTrustCopyPublicKey(trust);
|
||||
signingCertificate.publicKey = publicKey;
|
||||
if (!publicKey) {
|
||||
OWSFailDebug(@"Could not extract public key.");
|
||||
*error = CDSSigningCertificateErrorMake(
|
||||
CDSSigningCertificateError_AssertionError, @"Could not extract public key.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
return signingCertificate;
|
||||
}
|
||||
|
||||
// PEM is just a series of blocks of base-64 encoded DER data.
|
||||
//
|
||||
// https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail
|
||||
+ (nullable NSArray<NSData *> *)convertPemToDer:(NSString *)pemString
|
||||
{
|
||||
NSMutableArray<NSData *> *certificateDatas = [NSMutableArray new];
|
||||
|
||||
NSError *error;
|
||||
// We use ? for non-greedy matching.
|
||||
NSRegularExpression *_Nullable regex = [NSRegularExpression
|
||||
regularExpressionWithPattern:@"-----BEGIN.*?-----(.+?)-----END.*?-----"
|
||||
options:NSRegularExpressionCaseInsensitive | NSRegularExpressionDotMatchesLineSeparators
|
||||
error:&error];
|
||||
if (!regex || error) {
|
||||
OWSFailDebug(@"could parse regex: %@.", error);
|
||||
return nil;
|
||||
}
|
||||
|
||||
[regex enumerateMatchesInString:pemString
|
||||
options:0
|
||||
range:NSMakeRange(0, pemString.length)
|
||||
usingBlock:^(NSTextCheckingResult *_Nullable result, NSMatchingFlags flags, BOOL *stop) {
|
||||
if (result.numberOfRanges != 2) {
|
||||
OWSFailDebug(@"invalid PEM regex match.");
|
||||
return;
|
||||
}
|
||||
NSString *_Nullable derString = [pemString substringWithRange:[result rangeAtIndex:1]];
|
||||
if (derString.length < 1) {
|
||||
OWSFailDebug(@"empty PEM match.");
|
||||
return;
|
||||
}
|
||||
// dataFromBase64String will ignore whitespace, which is
|
||||
// necessary.
|
||||
NSData *_Nullable derData = [NSData dataFromBase64String:derString];
|
||||
if (derData.length < 1) {
|
||||
return;
|
||||
}
|
||||
[certificateDatas addObject:derData];
|
||||
}];
|
||||
|
||||
return certificateDatas;
|
||||
}
|
||||
|
||||
+ (nullable NSArray<NSData *> *)anchorCertificates
|
||||
{
|
||||
static NSArray<NSData *> *anchorCertificates = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// We need to use an Intel certificate as the anchor for IAS verification.
|
||||
NSData *_Nullable anchorCertificate = [self certificateDataForService:@"ias-root"];
|
||||
if (!anchorCertificate) {
|
||||
OWSFail(@"could not load anchor certificate.");
|
||||
} else {
|
||||
anchorCertificates = @[ anchorCertificate ];
|
||||
}
|
||||
});
|
||||
return anchorCertificates;
|
||||
}
|
||||
|
||||
+ (nullable NSData *)certificateDataForService:(NSString *)service
|
||||
{
|
||||
NSBundle *bundle = [NSBundle bundleForClass:self.class];
|
||||
NSString *path = [bundle pathForResource:service ofType:@"cer"];
|
||||
|
||||
if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
|
||||
OWSFailDebug(@"could not locate certificate file.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *_Nullable certificateData = [NSData dataWithContentsOfFile:path];
|
||||
return certificateData;
|
||||
}
|
||||
|
||||
- (BOOL)verifySignatureOfBody:(NSString *)body signature:(NSData *)signature
|
||||
{
|
||||
OWSAssertDebug(self.publicKey);
|
||||
|
||||
NSData *bodyData = [body dataUsingEncoding:NSUTF8StringEncoding];
|
||||
|
||||
size_t signedHashBytesSize = SecKeyGetBlockSize(self.publicKey);
|
||||
const void *signedHashBytes = [signature bytes];
|
||||
|
||||
NSData *_Nullable hashData = [Cryptography computeSHA256Digest:bodyData];
|
||||
if (hashData.length != CC_SHA256_DIGEST_LENGTH) {
|
||||
OWSFailDebug(@"could not SHA256 for signature verification.");
|
||||
return NO;
|
||||
}
|
||||
size_t hashBytesSize = CC_SHA256_DIGEST_LENGTH;
|
||||
const void *hashBytes = [hashData bytes];
|
||||
|
||||
OSStatus status = SecKeyRawVerify(
|
||||
self.publicKey, kSecPaddingPKCS1SHA256, hashBytes, hashBytesSize, signedHashBytes, signedHashBytesSize);
|
||||
|
||||
BOOL isValid = status == errSecSuccess;
|
||||
if (!isValid) {
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
+ (BOOL)verifyDistinguishedNameOfCertificate:(NSData *)certificateData
|
||||
{
|
||||
OWSAssertDebug(certificateData);
|
||||
|
||||
// The Security framework doesn't offer access to certificate properties
|
||||
// with API available on iOS 9. We use OpenSSL to extract the name.
|
||||
NSDictionary<NSString *, NSString *> *_Nullable properties = [self propertiesForCertificate:certificateData];
|
||||
if (!properties) {
|
||||
OWSFailDebug(@"Could not retrieve certificate properties.");
|
||||
return NO;
|
||||
}
|
||||
// NSString *expectedDistinguishedName
|
||||
// = @"CN=Intel SGX Attestation Report Signing,O=Intel Corporation,L=Santa Clara,ST=CA,C=US";
|
||||
NSDictionary<NSString *, NSString *> *expectedProperties = @{
|
||||
@(SN_commonName) : // "CN"
|
||||
@"Intel SGX Attestation Report Signing",
|
||||
@(SN_organizationName) : // "O"
|
||||
@"Intel Corporation",
|
||||
@(SN_localityName) : // "L"
|
||||
@"Santa Clara",
|
||||
@(SN_stateOrProvinceName) : // "ST"
|
||||
@"CA",
|
||||
@(SN_countryName) : // "C"
|
||||
@"US",
|
||||
};
|
||||
|
||||
if (![properties isEqualToDictionary:expectedProperties]) {
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
+ (nullable NSDictionary<NSString *, NSString *> *)propertiesForCertificate:(NSData *)certificateData
|
||||
{
|
||||
OWSAssertDebug(certificateData);
|
||||
|
||||
if (certificateData.length >= UINT32_MAX) {
|
||||
OWSFailDebug(@"certificate data is too long.");
|
||||
return nil;
|
||||
}
|
||||
const unsigned char *certificateDataBytes = (const unsigned char *)[certificateData bytes];
|
||||
X509 *_Nullable certificateX509 = d2i_X509(NULL, &certificateDataBytes, [certificateData length]);
|
||||
if (!certificateX509) {
|
||||
OWSFailDebug(@"could not parse certificate.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
X509_NAME *_Nullable subjectName = X509_get_subject_name(certificateX509);
|
||||
if (!subjectName) {
|
||||
OWSFailDebug(@"could not extract subject name.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableDictionary<NSString *, NSString *> *certificateProperties = [NSMutableDictionary new];
|
||||
for (NSString *oid in @[
|
||||
@(SN_commonName), // "CN"
|
||||
@(SN_organizationName), // "O"
|
||||
@(SN_localityName), // "L"
|
||||
@(SN_stateOrProvinceName), // "ST"
|
||||
@(SN_countryName), // "C"
|
||||
]) {
|
||||
int nid = OBJ_txt2nid(oid.UTF8String);
|
||||
int index = X509_NAME_get_index_by_NID(subjectName, nid, -1);
|
||||
|
||||
X509_NAME_ENTRY *_Nullable entry = X509_NAME_get_entry(subjectName, index);
|
||||
if (!entry) {
|
||||
OWSFailDebug(@"could not extract entry.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
ASN1_STRING *_Nullable entryData = X509_NAME_ENTRY_get_data(entry);
|
||||
if (!entryData) {
|
||||
OWSFailDebug(@"could not extract entry data.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
unsigned char *entryName = ASN1_STRING_data(entryData);
|
||||
if (entryName == NULL) {
|
||||
OWSFailDebug(@"could not extract entry string.");
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable entryString = [NSString stringWithUTF8String:(char *)entryName];
|
||||
if (!entryString) {
|
||||
OWSFailDebug(@"could not parse entry name data.");
|
||||
return nil;
|
||||
}
|
||||
certificateProperties[oid] = entryString;
|
||||
}
|
||||
return certificateProperties;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
@objc public final class ClosedGroupParser : NSObject {
|
||||
private let data: Data
|
||||
|
||||
@objc public init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
@objc public func parseGroupModels() -> [TSGroupModel] {
|
||||
var index = 0
|
||||
var result: [TSGroupModel] = []
|
||||
while index < data.endIndex {
|
||||
var uncheckedSize: UInt32? = try? data[index..<(index+4)].withUnsafeBytes { $0.pointee }
|
||||
if let size = uncheckedSize, size >= data.count, let intermediate = try? data[index..<(index+4)].reversed() {
|
||||
uncheckedSize = Data(intermediate).withUnsafeBytes { $0.pointee }
|
||||
}
|
||||
guard let size = uncheckedSize, size < data.count else { break }
|
||||
let sizeAsInt = Int(size)
|
||||
index += 4
|
||||
guard index + sizeAsInt <= data.count else { break }
|
||||
let protoAsData = data[index..<(index+sizeAsInt)]
|
||||
guard let proto = try? SSKProtoGroupDetails.parseData(protoAsData) else { break }
|
||||
index += sizeAsInt
|
||||
var groupModel = TSGroupModel(title: proto.name, memberIds: proto.members, image: nil,
|
||||
groupId: proto.id, groupType: GroupType.closedGroup, adminIds: proto.admins)
|
||||
result.append(groupModel)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import PromiseKit
|
||||
|
||||
@objc(LKClosedGroupPoller)
|
||||
public final class ClosedGroupPoller : NSObject {
|
||||
private var isPolling = false
|
||||
private var timer: Timer?
|
||||
|
||||
// MARK: Settings
|
||||
private static let pollInterval: TimeInterval = 2
|
||||
|
||||
// MARK: Error
|
||||
private enum Error : LocalizedError {
|
||||
case insufficientSnodes
|
||||
case pollingCanceled
|
||||
|
||||
internal var errorDescription: String? {
|
||||
switch self {
|
||||
case .insufficientSnodes: return "No snodes left to poll."
|
||||
case .pollingCanceled: return "Polling canceled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
@objc public func startIfNeeded() {
|
||||
AssertIsOnMainThread() // Timers don't do well on background queues
|
||||
guard !isPolling else { return }
|
||||
isPolling = true
|
||||
timer = Timer.scheduledTimer(withTimeInterval: ClosedGroupPoller.pollInterval, repeats: true) { [weak self] _ in
|
||||
self?.poll()
|
||||
}
|
||||
}
|
||||
|
||||
public func pollOnce() -> [Promise<Void>] {
|
||||
guard !isPolling else { return [] }
|
||||
isPolling = true
|
||||
return poll()
|
||||
}
|
||||
|
||||
@objc public func stop() {
|
||||
isPolling = false
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: Private API
|
||||
private func poll() -> [Promise<Void>] {
|
||||
guard isPolling else { return [] }
|
||||
let publicKeys = Storage.getUserClosedGroupPublicKeys()
|
||||
return publicKeys.map { publicKey in
|
||||
let promise = SnodeAPI.getSwarm(for: publicKey).then2 { [weak self] swarm -> Promise<[JSON]> in
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) }
|
||||
guard let self = self, self.isPolling else { return Promise(error: Error.pollingCanceled) }
|
||||
return SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey).map2 {
|
||||
SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: publicKey)
|
||||
}
|
||||
}
|
||||
promise.done2 { [weak self] messages in
|
||||
guard let self = self, self.isPolling else { return }
|
||||
if !messages.isEmpty {
|
||||
print("[Loki] Received \(messages.count) new message(s) in closed group with public key: \(publicKey).")
|
||||
}
|
||||
messages.forEach { json in
|
||||
guard let envelope = SSKProtoEnvelope.from(json) else { return }
|
||||
do {
|
||||
let data = try envelope.serializedData()
|
||||
SSKEnvironment.shared.messageReceiver.handleReceivedEnvelopeData(data)
|
||||
} catch {
|
||||
print("[Loki] Failed to deserialize envelope due to error: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
print("[Loki] Polling failed for closed group with public key: \(publicKey) due to error: \(error).")
|
||||
}
|
||||
return promise.map { _ in }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
|
||||
@objc(LKClosedGroupUpdateMessage)
|
||||
internal final class ClosedGroupUpdateMessage : TSOutgoingMessage {
|
||||
private let kind: Kind
|
||||
|
||||
// MARK: Settings
|
||||
@objc internal override var ttl: UInt32 { return UInt32(TTLUtilities.getTTL(for: .closedGroupUpdate)) }
|
||||
|
||||
@objc internal override func shouldBeSaved() -> Bool { return false }
|
||||
@objc internal override func shouldSyncTranscript() -> Bool { return false }
|
||||
|
||||
// MARK: Kind
|
||||
internal enum Kind {
|
||||
case new(groupPublicKey: Data, name: String, groupPrivateKey: Data, senderKeys: [ClosedGroupSenderKey], members: [Data], admins: [Data])
|
||||
case info(groupPublicKey: Data, name: String, senderKeys: [ClosedGroupSenderKey], members: [Data], admins: [Data])
|
||||
case senderKeyRequest(groupPublicKey: Data)
|
||||
case senderKey(groupPublicKey: Data, senderKey: ClosedGroupSenderKey)
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
internal init(thread: TSThread, kind: Kind) {
|
||||
self.kind = kind
|
||||
super.init(outgoingMessageWithTimestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageBody: "",
|
||||
attachmentIds: NSMutableArray(), expiresInSeconds: 0, expireStartedAt: 0, isVoiceMessage: false,
|
||||
groupMetaMessage: .unspecified, quotedMessage: nil, contactShare: nil, linkPreview: nil)
|
||||
}
|
||||
|
||||
required init(dictionary: [String:Any]) throws {
|
||||
preconditionFailure("Use init(thread:kind:) instead.")
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
internal required init?(coder: NSCoder) {
|
||||
guard let thread = coder.decodeObject(forKey: "thread") as? TSThread,
|
||||
let timestamp = coder.decodeObject(forKey: "timestamp") as? UInt64,
|
||||
let groupPublicKey = coder.decodeObject(forKey: "groupPublicKey") as? Data,
|
||||
let rawKind = coder.decodeObject(forKey: "kind") as? String else { return nil }
|
||||
switch rawKind {
|
||||
case "new":
|
||||
guard let name = coder.decodeObject(forKey: "name") as? String,
|
||||
let groupPrivateKey = coder.decodeObject(forKey: "groupPrivateKey") as? Data,
|
||||
let senderKeys = coder.decodeObject(forKey: "senderKeys") as? [ClosedGroupSenderKey],
|
||||
let members = coder.decodeObject(forKey: "members") as? [Data],
|
||||
let admins = coder.decodeObject(forKey: "admins") as? [Data] else { return nil }
|
||||
self.kind = .new(groupPublicKey: groupPublicKey, name: name, groupPrivateKey: groupPrivateKey, senderKeys: senderKeys, members: members, admins: admins)
|
||||
case "info":
|
||||
guard let name = coder.decodeObject(forKey: "name") as? String,
|
||||
let senderKeys = coder.decodeObject(forKey: "senderKeys") as? [ClosedGroupSenderKey],
|
||||
let members = coder.decodeObject(forKey: "members") as? [Data],
|
||||
let admins = coder.decodeObject(forKey: "admins") as? [Data] else { return nil }
|
||||
self.kind = .info(groupPublicKey: groupPublicKey, name: name, senderKeys: senderKeys, members: members, admins: admins)
|
||||
case "senderKeyRequest":
|
||||
self.kind = .senderKeyRequest(groupPublicKey: groupPublicKey)
|
||||
case "senderKey":
|
||||
guard let senderKeys = coder.decodeObject(forKey: "senderKeys") as? [ClosedGroupSenderKey],
|
||||
let senderKey = senderKeys.first else { return nil }
|
||||
self.kind = .senderKey(groupPublicKey: groupPublicKey, senderKey: senderKey)
|
||||
default: return nil
|
||||
}
|
||||
super.init(outgoingMessageWithTimestamp: timestamp, in: thread, messageBody: "",
|
||||
attachmentIds: NSMutableArray(), expiresInSeconds: 0, expireStartedAt: 0, isVoiceMessage: false,
|
||||
groupMetaMessage: .unspecified, quotedMessage: nil, contactShare: nil, linkPreview: nil)
|
||||
}
|
||||
|
||||
internal override func encode(with coder: NSCoder) {
|
||||
coder.encode(thread, forKey: "thread")
|
||||
coder.encode(timestamp, forKey: "timestamp")
|
||||
switch kind {
|
||||
case .new(let groupPublicKey, let name, let groupPrivateKey, let senderKeys, let members, let admins):
|
||||
coder.encode("new", forKey: "kind")
|
||||
coder.encode(groupPublicKey, forKey: "groupPublicKey")
|
||||
coder.encode(name, forKey: "name")
|
||||
coder.encode(groupPrivateKey, forKey: "groupPrivateKey")
|
||||
coder.encode(senderKeys, forKey: "senderKeys")
|
||||
coder.encode(members, forKey: "members")
|
||||
coder.encode(admins, forKey: "admins")
|
||||
case .info(let groupPublicKey, let name, let senderKeys, let members, let admins):
|
||||
coder.encode("info", forKey: "kind")
|
||||
coder.encode(groupPublicKey, forKey: "groupPublicKey")
|
||||
coder.encode(name, forKey: "name")
|
||||
coder.encode(senderKeys, forKey: "senderKeys")
|
||||
coder.encode(members, forKey: "members")
|
||||
coder.encode(admins, forKey: "admins")
|
||||
case .senderKeyRequest(let groupPublicKey):
|
||||
coder.encode(groupPublicKey, forKey: "groupPublicKey")
|
||||
case .senderKey(let groupPublicKey, let senderKey):
|
||||
coder.encode("senderKey", forKey: "kind")
|
||||
coder.encode(groupPublicKey, forKey: "groupPublicKey")
|
||||
coder.encode([ senderKey ], forKey: "senderKeys")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Building
|
||||
@objc internal override func dataMessageBuilder() -> Any? {
|
||||
guard let builder = super.dataMessageBuilder() as? SSKProtoDataMessage.SSKProtoDataMessageBuilder else { return nil }
|
||||
do {
|
||||
let closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate.SSKProtoDataMessageClosedGroupUpdateBuilder
|
||||
switch kind {
|
||||
case .new(let groupPublicKey, let name, let groupPrivateKey, let senderKeys, let members, let admins):
|
||||
closedGroupUpdate = SSKProtoDataMessageClosedGroupUpdate.builder(groupPublicKey: groupPublicKey, type: .new)
|
||||
closedGroupUpdate.setName(name)
|
||||
closedGroupUpdate.setGroupPrivateKey(groupPrivateKey)
|
||||
closedGroupUpdate.setSenderKeys(try senderKeys.map { try $0.toProto() })
|
||||
closedGroupUpdate.setMembers(members)
|
||||
closedGroupUpdate.setAdmins(admins)
|
||||
case .info(let groupPublicKey, let name, let senderKeys, let members, let admins):
|
||||
closedGroupUpdate = SSKProtoDataMessageClosedGroupUpdate.builder(groupPublicKey: groupPublicKey, type: .info)
|
||||
closedGroupUpdate.setName(name)
|
||||
closedGroupUpdate.setSenderKeys(try senderKeys.map { try $0.toProto() })
|
||||
closedGroupUpdate.setMembers(members)
|
||||
closedGroupUpdate.setAdmins(admins)
|
||||
case .senderKeyRequest(let groupPublicKey):
|
||||
closedGroupUpdate = SSKProtoDataMessageClosedGroupUpdate.builder(groupPublicKey: groupPublicKey, type: .senderKeyRequest)
|
||||
case .senderKey(let groupPublicKey, let senderKey):
|
||||
closedGroupUpdate = SSKProtoDataMessageClosedGroupUpdate.builder(groupPublicKey: groupPublicKey, type: .senderKey)
|
||||
closedGroupUpdate.setSenderKeys([ try senderKey.toProto() ])
|
||||
}
|
||||
builder.setClosedGroupUpdate(try closedGroupUpdate.build())
|
||||
} catch {
|
||||
owsFailDebug("Failed to build closed group update due to error: \(error).")
|
||||
return nil
|
||||
}
|
||||
return builder
|
||||
}
|
||||
}
|
||||
|
||||
private extension ClosedGroupSenderKey {
|
||||
|
||||
func toProto() throws -> SSKProtoDataMessageClosedGroupUpdateSenderKey {
|
||||
return try SSKProtoDataMessageClosedGroupUpdateSenderKey.builder(chainKey: chainKey, keyIndex: UInt32(keyIndex), publicKey: publicKey).build()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import CryptoSwift
|
||||
|
||||
|
||||
@objc(LKClosedGroupUtilities)
|
||||
public final class ClosedGroupUtilities : NSObject {
|
||||
|
||||
@objc(LKSSKDecryptionError)
|
||||
public class SSKDecryptionError : NSError { // Not called `Error` for Obj-C interoperablity
|
||||
|
||||
@objc public static let invalidGroupPublicKey = SSKDecryptionError(domain: "SSKErrorDomain", code: 1, userInfo: [ NSLocalizedDescriptionKey : "Invalid group public key." ])
|
||||
@objc public static let noData = SSKDecryptionError(domain: "SSKErrorDomain", code: 2, userInfo: [ NSLocalizedDescriptionKey : "Received an empty envelope." ])
|
||||
@objc public static let noGroupPrivateKey = SSKDecryptionError(domain: "SSKErrorDomain", code: 3, userInfo: [ NSLocalizedDescriptionKey : "Missing group private key." ])
|
||||
@objc public static let selfSend = SSKDecryptionError(domain: "SSKErrorDomain", code: 4, userInfo: [ NSLocalizedDescriptionKey : "Message addressed at self." ])
|
||||
}
|
||||
|
||||
@objc(encryptData:usingGroupPublicKey:transaction:error:)
|
||||
public static func encrypt(data: Data, groupPublicKey: String, transaction: YapDatabaseReadWriteTransaction) throws -> Data {
|
||||
// 1. ) Encrypt the data with the user's sender key
|
||||
guard let userPublicKey = OWSIdentityManager.shared().identityKeyPair()?.hexEncodedPublicKey else {
|
||||
throw SMKError.assertionError(description: "[Loki] Couldn't find user key pair.")
|
||||
}
|
||||
let ciphertextAndKeyIndex = try SharedSenderKeysImplementation.shared.encrypt(data, forGroupWithPublicKey: groupPublicKey,
|
||||
senderPublicKey: userPublicKey, protocolContext: transaction)
|
||||
let ivAndCiphertext = ciphertextAndKeyIndex[0] as! Data
|
||||
let keyIndex = ciphertextAndKeyIndex[1] as! UInt
|
||||
let encryptedMessage = ClosedGroupCiphertextMessage(_throws_withIVAndCiphertext: ivAndCiphertext, senderPublicKey: Data(hex: userPublicKey), keyIndex: UInt32(keyIndex))
|
||||
// 2. ) Encrypt the result for the group's public key to hide the sender public key and key index
|
||||
let (ciphertext, _, ephemeralPublicKey) = try EncryptionUtilities.encrypt(encryptedMessage.serialized, using: groupPublicKey.removing05PrefixIfNeeded())
|
||||
// 3. ) Wrap the result
|
||||
return try SSKProtoClosedGroupCiphertextMessageWrapper.builder(ciphertext: ciphertext, ephemeralPublicKey: ephemeralPublicKey).build().serializedData()
|
||||
}
|
||||
|
||||
@objc(decryptEnvelope:transaction:error:)
|
||||
public static func decrypt(envelope: SSKProtoEnvelope, transaction: YapDatabaseReadWriteTransaction) throws -> [Any] {
|
||||
let (plaintext, senderPublicKey) = try decrypt(envelope: envelope, transaction: transaction)
|
||||
return [ plaintext, senderPublicKey ]
|
||||
}
|
||||
|
||||
public static func decrypt(envelope: SSKProtoEnvelope, transaction: YapDatabaseReadWriteTransaction) throws -> (plaintext: Data, senderPublicKey: String) {
|
||||
// 1. ) Check preconditions
|
||||
guard let groupPublicKey = envelope.source, SharedSenderKeysImplementation.shared.isClosedGroup(groupPublicKey) else {
|
||||
throw SSKDecryptionError.invalidGroupPublicKey
|
||||
}
|
||||
guard let data = envelope.content else {
|
||||
throw SSKDecryptionError.noData
|
||||
}
|
||||
guard let hexEncodedGroupPrivateKey = Storage.getClosedGroupPrivateKey(for: groupPublicKey) else {
|
||||
throw SSKDecryptionError.noGroupPrivateKey
|
||||
}
|
||||
let groupPrivateKey = Data(hex: hexEncodedGroupPrivateKey)
|
||||
// 2. ) Parse the wrapper
|
||||
let wrapper = try SSKProtoClosedGroupCiphertextMessageWrapper.parseData(data)
|
||||
let ivAndCiphertext = wrapper.ciphertext
|
||||
let ephemeralPublicKey = wrapper.ephemeralPublicKey
|
||||
// 3. ) Decrypt the data inside
|
||||
let groupKeyPair = ECKeyPair(publicKey: Data(hex: groupPublicKey), privateKey: groupPrivateKey)
|
||||
let ephemeralSharedSecret = Curve25519.generateSharedSecret(fromPublicKey: ephemeralPublicKey, andKeyPair: groupKeyPair)!
|
||||
let salt = "LOKI"
|
||||
let symmetricKey = try HMAC(key: salt.bytes, variant: .sha256).authenticate(ephemeralSharedSecret.bytes)
|
||||
let closedGroupCiphertextMessageAsData = try DecryptionUtilities.decrypt(ivAndCiphertext, usingAESGCMWithSymmetricKey: Data(symmetricKey))
|
||||
// 4. ) Parse the closed group ciphertext message
|
||||
let closedGroupCiphertextMessage = ClosedGroupCiphertextMessage(_throws_with: closedGroupCiphertextMessageAsData)
|
||||
let senderPublicKey = closedGroupCiphertextMessage.senderPublicKey.toHexString()
|
||||
guard senderPublicKey != getUserHexEncodedPublicKey() else { throw SSKDecryptionError.selfSend }
|
||||
// 5. ) Use the info inside the closed group ciphertext message to decrypt the actual message content
|
||||
let plaintext = try SharedSenderKeysImplementation.shared.decrypt(closedGroupCiphertextMessage.ivAndCiphertext, forGroupWithPublicKey: groupPublicKey,
|
||||
senderPublicKey: senderPublicKey, keyIndex: UInt(closedGroupCiphertextMessage.keyIndex), protocolContext: transaction)
|
||||
// 6. ) Return
|
||||
return (plaintext, senderPublicKey)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,486 @@
|
|||
import PromiseKit
|
||||
|
||||
// A few notes about making changes in this file:
|
||||
//
|
||||
// • Don't use a database transaction if you can avoid it.
|
||||
// • If you do need to use a database transaction, use a read transaction if possible.
|
||||
// • For write transactions, consider making it the caller's responsibility to manage the database transaction (this helps avoid unnecessary transactions).
|
||||
// • Think carefully about adding a function; there might already be one for what you need.
|
||||
// • Document the expected cases in which a function will be used.
|
||||
// • Express those cases in tests.
|
||||
|
||||
/// See [the documentation](https://github.com/loki-project/session-protocol-docs/wiki/Medium-Size-Groups) for more information.
|
||||
@objc(LKClosedGroupsProtocol)
|
||||
public final class ClosedGroupsProtocol : NSObject {
|
||||
public static let isSharedSenderKeysEnabled = true
|
||||
public static let groupSizeLimit = 20
|
||||
public static let maxNameSize = 64
|
||||
|
||||
public enum Error : LocalizedError {
|
||||
case noThread
|
||||
case noPrivateKey
|
||||
case invalidUpdate
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noThread: return "Couldn't find a thread associated with the given group public key."
|
||||
case .noPrivateKey: return "Couldn't find a private key associated with the given group public key."
|
||||
case .invalidUpdate: return "Invalid group update."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sending
|
||||
|
||||
/// - Note: It's recommended to batch fetch the device links for the given set of members before invoking this, to avoid the message sending pipeline
|
||||
/// making a request for each member.
|
||||
public static func createClosedGroup(name: String, members: Set<String>, transaction: YapDatabaseReadWriteTransaction) -> Promise<TSGroupThread> {
|
||||
// Prepare
|
||||
var members = members
|
||||
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
// Generate a key pair for the group
|
||||
let groupKeyPair = Curve25519.generateKeyPair()!
|
||||
let groupPublicKey = groupKeyPair.hexEncodedPublicKey // Includes the "05" prefix
|
||||
// Ensure the current user's master device is the one that's included in the member list
|
||||
members.remove(userPublicKey)
|
||||
members.insert(UserDefaults.standard[.masterHexEncodedPublicKey] ?? userPublicKey)
|
||||
let membersAsData = members.map { Data(hex: $0) }
|
||||
// Create ratchets for all members (and their linked devices)
|
||||
var membersAndLinkedDevices: Set<String> = members
|
||||
for member in members {
|
||||
let deviceLinks = OWSPrimaryStorage.shared().getDeviceLinks(for: member, in: transaction)
|
||||
membersAndLinkedDevices.formUnion(deviceLinks.flatMap { [ $0.master.publicKey, $0.slave.publicKey ] })
|
||||
}
|
||||
let senderKeys: [ClosedGroupSenderKey] = membersAndLinkedDevices.map { publicKey in
|
||||
let ratchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: publicKey, using: transaction)
|
||||
return ClosedGroupSenderKey(chainKey: Data(hex: ratchet.chainKey), keyIndex: ratchet.keyIndex, publicKey: Data(hex: publicKey))
|
||||
}
|
||||
// Create the group
|
||||
let admins = [ UserDefaults.standard[.masterHexEncodedPublicKey] ?? userPublicKey ]
|
||||
let adminsAsData = admins.map { Data(hex: $0) }
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
let group = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
|
||||
let thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction)
|
||||
thread.usesSharedSenderKeys = true
|
||||
thread.save(with: transaction)
|
||||
SSKEnvironment.shared.profileManager.addThread(toProfileWhitelist: thread)
|
||||
// Establish sessions if needed
|
||||
establishSessionsIfNeeded(with: [String](members), using: transaction) // Not `membersAndLinkedDevices` as this internally takes care of multi device already
|
||||
// Send a closed group update message to all members (and their linked devices) using established channels
|
||||
var promises: [Promise<Void>] = []
|
||||
for member in members { // Not `membersAndLinkedDevices` as this internally takes care of multi device already
|
||||
guard member != userPublicKey else { continue }
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name,
|
||||
groupPrivateKey: groupKeyPair.privateKey(), senderKeys: senderKeys, members: membersAsData, admins: adminsAsData)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
promises.append(SSKEnvironment.shared.messageSender.sendPromise(message: closedGroupUpdateMessage))
|
||||
}
|
||||
// Add the group to the user's set of public keys to poll for
|
||||
Storage.setClosedGroupPrivateKey(groupKeyPair.privateKey().toHexString(), for: groupPublicKey, using: transaction)
|
||||
// Notify the PN server
|
||||
promises.append(LokiPushNotificationManager.performOperation(.subscribe, for: groupPublicKey, publicKey: userPublicKey))
|
||||
// Notify the user
|
||||
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeGroupUpdate)
|
||||
infoMessage.save(with: transaction)
|
||||
// Return
|
||||
return when(fulfilled: promises).map2 { thread }
|
||||
}
|
||||
|
||||
/// - Note: The returned promise is only relevant for group leaving.
|
||||
public static func update(_ groupPublicKey: String, with members: Set<String>, name: String, transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
guard let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) else {
|
||||
print("[Loki] Can't update nonexistent closed group.")
|
||||
return Promise(error: Error.noThread)
|
||||
}
|
||||
let group = thread.groupModel
|
||||
let oldMembers = Set(group.groupMemberIds)
|
||||
let newMembers = members.subtracting(oldMembers)
|
||||
let membersAsData = members.map { Data(hex: $0) }
|
||||
let admins = group.groupAdminIds
|
||||
let adminsAsData = admins.map { Data(hex: $0) }
|
||||
guard let groupPrivateKey = Storage.getClosedGroupPrivateKey(for: groupPublicKey) else {
|
||||
print("[Loki] Couldn't get private key for closed group.")
|
||||
return Promise(error: Error.noPrivateKey)
|
||||
}
|
||||
let wasAnyUserRemoved = Set(members).intersection(oldMembers) != oldMembers
|
||||
let removedMembers = oldMembers.subtracting(members)
|
||||
let isUserLeaving = removedMembers.contains(userPublicKey)
|
||||
var newSenderKeys: [ClosedGroupSenderKey] = []
|
||||
if wasAnyUserRemoved {
|
||||
if isUserLeaving && removedMembers.count != 1 {
|
||||
print("[Loki] Can't remove self and others simultaneously.")
|
||||
return Promise(error: Error.invalidUpdate)
|
||||
}
|
||||
// Establish sessions if needed
|
||||
establishSessionsIfNeeded(with: [String](members), using: transaction)
|
||||
// Send the update to the existing members using established channels (don't include new ratchets as everyone should regenerate new ratchets individually)
|
||||
let promises: [Promise<Void>] = oldMembers.map { member in
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: [],
|
||||
members: membersAsData, admins: adminsAsData)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
return SSKEnvironment.shared.messageSender.sendPromise(message: closedGroupUpdateMessage)
|
||||
}
|
||||
when(resolved: promises).done2 { _ in seal.fulfill(()) }.catch2 { seal.reject($0) }
|
||||
promise.done {
|
||||
Storage.writeSync { transaction in
|
||||
let allOldRatchets = Storage.getAllClosedGroupRatchets(for: groupPublicKey)
|
||||
for (senderPublicKey, oldRatchet) in allOldRatchets {
|
||||
let collection = Storage.ClosedGroupRatchetCollectionType.old
|
||||
Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, ratchet: oldRatchet, in: collection, using: transaction)
|
||||
}
|
||||
// Delete all ratchets (it's important that this happens * after * sending out the update)
|
||||
Storage.removeAllClosedGroupRatchets(for: groupPublicKey, using: transaction)
|
||||
// Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and
|
||||
// send it out to all members (minus the removed ones) using established channels.
|
||||
if isUserLeaving {
|
||||
Storage.removeClosedGroupPrivateKey(for: groupPublicKey, using: transaction)
|
||||
// Notify the PN server
|
||||
LokiPushNotificationManager.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey)
|
||||
} else {
|
||||
// Send closed group update messages to any new members using established channels
|
||||
for member in newMembers {
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name,
|
||||
groupPrivateKey: Data(hex: groupPrivateKey), senderKeys: [], members: membersAsData, admins: adminsAsData)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
}
|
||||
// Send out the user's new ratchet to all members (minus the removed ones) using established channels
|
||||
let userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction)
|
||||
let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex, publicKey: Data(hex: userPublicKey))
|
||||
for member in members {
|
||||
guard member != userPublicKey else { continue }
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !newMembers.isEmpty {
|
||||
seal.fulfill(())
|
||||
// Generate ratchets for any new members
|
||||
newSenderKeys = newMembers.map { publicKey in
|
||||
let ratchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: publicKey, using: transaction)
|
||||
return ClosedGroupSenderKey(chainKey: Data(hex: ratchet.chainKey), keyIndex: ratchet.keyIndex, publicKey: Data(hex: publicKey))
|
||||
}
|
||||
// Send a closed group update message to the existing members with the new members' ratchets (this message is aimed at the group)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: newSenderKeys,
|
||||
members: membersAsData, admins: adminsAsData)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
// Establish sessions if needed
|
||||
establishSessionsIfNeeded(with: [String](newMembers), using: transaction)
|
||||
// Send closed group update messages to the new members using established channels
|
||||
var allSenderKeys = Storage.getAllClosedGroupSenderKeys(for: groupPublicKey)
|
||||
allSenderKeys.formUnion(newSenderKeys)
|
||||
for member in newMembers {
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name,
|
||||
groupPrivateKey: Data(hex: groupPrivateKey), senderKeys: [ClosedGroupSenderKey](allSenderKeys), members: membersAsData, admins: adminsAsData)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
}
|
||||
} else {
|
||||
seal.fulfill(())
|
||||
let allSenderKeys = Storage.getAllClosedGroupSenderKeys(for: groupPublicKey)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name,
|
||||
senderKeys: [ClosedGroupSenderKey](allSenderKeys), members: membersAsData, admins: adminsAsData)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
}
|
||||
// Update the group
|
||||
let newGroupModel = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
|
||||
thread.setGroupModel(newGroupModel, with: transaction)
|
||||
// Notify the user
|
||||
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel, contactsManager: SSKEnvironment.shared.contactsManager)
|
||||
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeGroupUpdate, customMessage: updateInfo)
|
||||
infoMessage.save(with: transaction)
|
||||
// Return
|
||||
return promise
|
||||
}
|
||||
|
||||
/// The returned promise is fulfilled when the group update message has been sent. It doesn't wait for the user's new ratchet to be distributed.
|
||||
@objc(leaveGroupWithPublicKey:transaction:)
|
||||
public static func objc_leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise {
|
||||
return AnyPromise.from(leave(groupPublicKey, using: transaction))
|
||||
}
|
||||
|
||||
/// The returned promise is fulfilled when the group update message has been sent. It doesn't wait for the user's new ratchet to be distributed.
|
||||
public static func leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
|
||||
let userPublicKey = UserDefaults.standard[.masterHexEncodedPublicKey] ?? getUserHexEncodedPublicKey()
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
guard let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) else {
|
||||
print("[Loki] Can't leave nonexistent closed group.")
|
||||
return Promise(error: Error.noThread)
|
||||
}
|
||||
let group = thread.groupModel
|
||||
var newMembers = Set(group.groupMemberIds)
|
||||
newMembers.remove(userPublicKey)
|
||||
return update(groupPublicKey, with: newMembers, name: group.groupName!, transaction: transaction)
|
||||
}
|
||||
|
||||
public static func requestSenderKey(for groupPublicKey: String, senderPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
print("[Loki] Requesting sender key for group public key: \(groupPublicKey), sender public key: \(senderPublicKey).")
|
||||
// Establish session if needed
|
||||
SessionManagementProtocol.sendSessionRequestIfNeeded(to: senderPublicKey, using: transaction)
|
||||
// Send the request
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: senderPublicKey, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKeyRequest(groupPublicKey: Data(hex: groupPublicKey))
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
}
|
||||
|
||||
// MARK: - Receiving
|
||||
|
||||
@objc(handleSharedSenderKeysUpdateIfNeeded:from:transaction:)
|
||||
public static func handleSharedSenderKeysUpdateIfNeeded(_ dataMessage: SSKProtoDataMessage, from publicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
// Note that `publicKey` is either the public key of the group or the public key of the
|
||||
// sender, depending on how the message was sent
|
||||
guard let closedGroupUpdate = dataMessage.closedGroupUpdate, isValid(closedGroupUpdate) else { return }
|
||||
switch closedGroupUpdate.type {
|
||||
case .new: handleNewGroupMessage(closedGroupUpdate, using: transaction)
|
||||
case .info: handleInfoMessage(closedGroupUpdate, from: publicKey, using: transaction)
|
||||
case .senderKeyRequest: handleSenderKeyRequestMessage(closedGroupUpdate, from: publicKey, using: transaction)
|
||||
case .senderKey: handleSenderKeyMessage(closedGroupUpdate, from: publicKey, using: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isValid(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate) -> Bool {
|
||||
guard !closedGroupUpdate.groupPublicKey.isEmpty else { return false }
|
||||
switch closedGroupUpdate.type {
|
||||
case .new: return !(closedGroupUpdate.name ?? "").isEmpty && !(closedGroupUpdate.groupPrivateKey ?? Data()).isEmpty && !closedGroupUpdate.members.isEmpty
|
||||
&& !closedGroupUpdate.admins.isEmpty // senderKeys may be empty
|
||||
case .info: return !(closedGroupUpdate.name ?? "").isEmpty && !closedGroupUpdate.members.isEmpty && !closedGroupUpdate.admins.isEmpty // senderKeys may be empty
|
||||
case .senderKeyRequest: return true
|
||||
case .senderKey: return !closedGroupUpdate.senderKeys.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleNewGroupMessage(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
|
||||
// Unwrap the message
|
||||
let groupPublicKey = closedGroupUpdate.groupPublicKey.toHexString()
|
||||
let name = closedGroupUpdate.name
|
||||
let groupPrivateKey = closedGroupUpdate.groupPrivateKey!
|
||||
let senderKeys = closedGroupUpdate.senderKeys
|
||||
let members = closedGroupUpdate.members.map { $0.toHexString() }
|
||||
let admins = closedGroupUpdate.admins.map { $0.toHexString() }
|
||||
// Persist the ratchets
|
||||
senderKeys.forEach { senderKey in
|
||||
guard members.contains(senderKey.publicKey.toHexString()) else { return } // TODO: This currently doesn't take into account multi device
|
||||
let ratchet = ClosedGroupRatchet(chainKey: senderKey.chainKey.toHexString(), keyIndex: UInt(senderKey.keyIndex), messageKeys: [])
|
||||
Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderKey.publicKey.toHexString(), ratchet: ratchet, using: transaction)
|
||||
}
|
||||
// Sort out any discrepancies between the provided sender keys and what's required
|
||||
let missingSenderKeys = Set(members).subtracting(senderKeys.map { $0.publicKey.toHexString() })
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
if missingSenderKeys.contains(userPublicKey) {
|
||||
establishSessionsIfNeeded(with: [String](members), using: transaction)
|
||||
let userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction)
|
||||
let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex, publicKey: Data(hex: userPublicKey))
|
||||
for member in members {
|
||||
guard member != userPublicKey else { continue }
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
|
||||
}
|
||||
}
|
||||
for publicKey in missingSenderKeys.subtracting([ userPublicKey ]) {
|
||||
requestSenderKey(for: groupPublicKey, senderPublicKey: publicKey, using: transaction)
|
||||
}
|
||||
// Create the group
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
let group = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
|
||||
let thread: TSGroupThread
|
||||
if let t = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) {
|
||||
thread = t
|
||||
thread.setGroupModel(group, with: transaction)
|
||||
} else {
|
||||
thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction)
|
||||
thread.usesSharedSenderKeys = true
|
||||
thread.save(with: transaction)
|
||||
}
|
||||
SSKEnvironment.shared.profileManager.addThread(toProfileWhitelist: thread)
|
||||
// Add the group to the user's set of public keys to poll for
|
||||
Storage.setClosedGroupPrivateKey(groupPrivateKey.toHexString(), for: groupPublicKey, using: transaction)
|
||||
// Notify the PN server
|
||||
LokiPushNotificationManager.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey())
|
||||
// Notify the user
|
||||
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeGroupUpdate)
|
||||
infoMessage.save(with: transaction)
|
||||
// Establish sessions if needed
|
||||
establishSessionsIfNeeded(with: members, using: transaction) // This internally takes care of multi device
|
||||
}
|
||||
|
||||
/// Invoked upon receiving a group update. A group update is sent out when a group's name is changed, when new users are added, when users leave or are
|
||||
/// kicked, or if the group admins are changed.
|
||||
private static func handleInfoMessage(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate, from senderPublicKey: String,
|
||||
using transaction: YapDatabaseReadWriteTransaction) {
|
||||
// Unwrap the message
|
||||
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
|
||||
let groupPublicKey = closedGroupUpdate.groupPublicKey.toHexString()
|
||||
let name = closedGroupUpdate.name
|
||||
let senderKeys = closedGroupUpdate.senderKeys
|
||||
let members = closedGroupUpdate.members.map { $0.toHexString() }
|
||||
let admins = closedGroupUpdate.admins.map { $0.toHexString() }
|
||||
// Get the group
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
guard let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) else {
|
||||
return print("[Loki] Ignoring closed group info message for nonexistent group.")
|
||||
}
|
||||
let group = thread.groupModel
|
||||
// Check that the sender is a member of the group (before the update)
|
||||
var membersAndLinkedDevices: Set<String> = Set(group.groupMemberIds)
|
||||
for member in group.groupMemberIds {
|
||||
let deviceLinks = OWSPrimaryStorage.shared().getDeviceLinks(for: member, in: transaction)
|
||||
membersAndLinkedDevices.formUnion(deviceLinks.flatMap { [ $0.master.publicKey, $0.slave.publicKey ] })
|
||||
}
|
||||
guard membersAndLinkedDevices.contains(senderPublicKey) else {
|
||||
return print("[Loki] Ignoring closed group info message from non-member.")
|
||||
}
|
||||
// Store the ratchets for any new members (it's important that this happens before the code below)
|
||||
senderKeys.forEach { senderKey in
|
||||
let ratchet = ClosedGroupRatchet(chainKey: senderKey.chainKey.toHexString(), keyIndex: UInt(senderKey.keyIndex), messageKeys: [])
|
||||
Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderKey.publicKey.toHexString(), ratchet: ratchet, using: transaction)
|
||||
}
|
||||
// Delete all ratchets and either:
|
||||
// • Send out the user's new ratchet using established channels if other members of the group left or were removed
|
||||
// • Remove the group from the user's set of public keys to poll for if the current user was among the members that were removed
|
||||
let oldMembers = group.groupMemberIds
|
||||
let userPublicKey = UserDefaults.standard[.masterHexEncodedPublicKey] ?? getUserHexEncodedPublicKey()
|
||||
let wasUserRemoved = !members.contains(userPublicKey)
|
||||
if Set(members).intersection(oldMembers) != Set(oldMembers) {
|
||||
let allOldRatchets = Storage.getAllClosedGroupRatchets(for: groupPublicKey)
|
||||
for (senderPublicKey, oldRatchet) in allOldRatchets {
|
||||
let collection = Storage.ClosedGroupRatchetCollectionType.old
|
||||
Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, ratchet: oldRatchet, in: collection, using: transaction)
|
||||
}
|
||||
Storage.removeAllClosedGroupRatchets(for: groupPublicKey, using: transaction)
|
||||
if wasUserRemoved {
|
||||
Storage.removeClosedGroupPrivateKey(for: groupPublicKey, using: transaction)
|
||||
// Notify the PN server
|
||||
LokiPushNotificationManager.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey)
|
||||
} else {
|
||||
establishSessionsIfNeeded(with: members, using: transaction) // This internally takes care of multi device
|
||||
let userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction)
|
||||
let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex, publicKey: Data(hex: userPublicKey))
|
||||
for member in members {
|
||||
guard member != userPublicKey else { continue }
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) // This internally takes care of multi device
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update the group
|
||||
let newGroupModel = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
|
||||
thread.setGroupModel(newGroupModel, with: transaction)
|
||||
// Notify the user if needed (don't notify them if the message just contained linked device sender keys)
|
||||
if Set(members) != Set(oldMembers) || Set(admins) != Set(group.groupAdminIds) || name != group.groupName {
|
||||
let infoMessageType: TSInfoMessageType = wasUserRemoved ? .typeGroupQuit : .typeGroupUpdate
|
||||
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel, contactsManager: SSKEnvironment.shared.contactsManager)
|
||||
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: infoMessageType, customMessage: updateInfo)
|
||||
infoMessage.save(with: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleSenderKeyRequestMessage(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate, from senderPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
// Prepare
|
||||
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let groupPublicKey = closedGroupUpdate.groupPublicKey.toHexString()
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
guard let groupThread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) else {
|
||||
return print("[Loki] Ignoring closed group sender key request for nonexistent group.")
|
||||
}
|
||||
let group = groupThread.groupModel
|
||||
// Check that the requesting user is a member of the group
|
||||
var membersAndLinkedDevices: Set<String> = Set(group.groupMemberIds)
|
||||
for member in group.groupMemberIds {
|
||||
let deviceLinks = OWSPrimaryStorage.shared().getDeviceLinks(for: member, in: transaction)
|
||||
membersAndLinkedDevices.formUnion(deviceLinks.flatMap { [ $0.master.publicKey, $0.slave.publicKey ] })
|
||||
}
|
||||
guard membersAndLinkedDevices.contains(senderPublicKey) else {
|
||||
return print("[Loki] Ignoring closed group sender key request from non-member.")
|
||||
}
|
||||
// Respond to the request
|
||||
print("[Loki] Responding to sender key request from: \(senderPublicKey).")
|
||||
SessionManagementProtocol.sendSessionRequestIfNeeded(to: senderPublicKey, using: transaction) // This internally takes care of multi device
|
||||
let userRatchet = Storage.getClosedGroupRatchet(for: groupPublicKey, senderPublicKey: userPublicKey)
|
||||
?? SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction)
|
||||
let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex, publicKey: Data(hex: userPublicKey))
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: senderPublicKey, transaction: transaction)
|
||||
thread.save(with: transaction)
|
||||
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey)
|
||||
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
|
||||
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) // This internally takes care of multi device
|
||||
}
|
||||
|
||||
/// Invoked upon receiving a sender key from another user.
|
||||
private static func handleSenderKeyMessage(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate, from senderPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
// Prepare
|
||||
let groupPublicKey = closedGroupUpdate.groupPublicKey.toHexString()
|
||||
guard let senderKey = closedGroupUpdate.senderKeys.first else {
|
||||
return print("[Loki] Ignoring invalid closed group sender key.")
|
||||
}
|
||||
guard senderKey.publicKey.toHexString() == senderPublicKey else {
|
||||
return print("[Loki] Ignoring invalid closed group sender key.")
|
||||
}
|
||||
// Store the sender key
|
||||
print("[Loki] Received a sender key from: \(senderPublicKey).")
|
||||
let ratchet = ClosedGroupRatchet(chainKey: senderKey.chainKey.toHexString(), keyIndex: UInt(senderKey.keyIndex), messageKeys: [])
|
||||
Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, ratchet: ratchet, using: transaction)
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
@objc(establishSessionsIfNeededWithClosedGroupMembers:transaction:)
|
||||
public static func establishSessionsIfNeeded(with closedGroupMembers: [String], using transaction: YapDatabaseReadWriteTransaction) {
|
||||
closedGroupMembers.forEach { publicKey in
|
||||
SessionManagementProtocol.sendSessionRequestIfNeeded(to: publicKey, using: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(shouldIgnoreClosedGroupMessage:inThread:wrappedIn:)
|
||||
public static func shouldIgnoreClosedGroupMessage(_ dataMessage: SSKProtoDataMessage, in thread: TSGroupThread, wrappedIn envelope: SSKProtoEnvelope) -> Bool {
|
||||
guard thread.groupModel.groupType == .closedGroup else { return true }
|
||||
let publicKey = envelope.source! // Set during UD decryption
|
||||
var result = false
|
||||
Storage.read { transaction in
|
||||
result = !thread.isUserMember(inGroup: publicKey, transaction: transaction)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// - Note: Deprecated.
|
||||
@objc(shouldIgnoreClosedGroupUpdateMessage:inThread:wrappedIn:)
|
||||
public static func shouldIgnoreClosedGroupUpdateMessage(_ dataMessage: SSKProtoDataMessage, in thread: TSGroupThread, wrappedIn envelope: SSKProtoEnvelope) -> Bool {
|
||||
guard thread.groupModel.groupType == .closedGroup else { return true }
|
||||
let publicKey = envelope.source! // Set during UD decryption
|
||||
var result = false
|
||||
Storage.read { transaction in
|
||||
result = !thread.isUserAdmin(inGroup: publicKey, transaction: transaction)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Mantle/MTLModel.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/**
|
||||
* An adapter for the system contacts
|
||||
*/
|
||||
|
||||
@class CNContact;
|
||||
@class PhoneNumber;
|
||||
@class SignalRecipient;
|
||||
@class UIImage;
|
||||
@class YapDatabaseReadTransaction;
|
||||
|
||||
@interface Contact : MTLModel
|
||||
|
||||
@property (nullable, readonly, nonatomic) NSString *firstName;
|
||||
@property (nullable, readonly, nonatomic) NSString *lastName;
|
||||
@property (readonly, nonatomic) NSString *fullName;
|
||||
@property (readonly, nonatomic) NSString *comparableNameFirstLast;
|
||||
@property (readonly, nonatomic) NSString *comparableNameLastFirst;
|
||||
@property (readonly, nonatomic) NSArray<PhoneNumber *> *parsedPhoneNumbers;
|
||||
@property (readonly, nonatomic) NSArray<NSString *> *userTextPhoneNumbers;
|
||||
@property (readonly, nonatomic) NSArray<NSString *> *emails;
|
||||
@property (readonly, nonatomic) NSString *uniqueId;
|
||||
@property (nonatomic, readonly) BOOL isSignalContact;
|
||||
@property (nonatomic, readonly) NSString *cnContactId;
|
||||
|
||||
- (NSArray<SignalRecipient *> *)signalRecipientsWithTransaction:(YapDatabaseReadTransaction *)transaction;
|
||||
// TODO: Remove this method.
|
||||
- (NSArray<NSString *> *)textSecureIdentifiers;
|
||||
|
||||
#if TARGET_OS_IOS
|
||||
|
||||
- (instancetype)initWithSystemContact:(CNContact *)cnContact NS_AVAILABLE_IOS(9_0);
|
||||
+ (nullable Contact *)contactWithVCardData:(NSData *)data;
|
||||
+ (nullable CNContact *)cnContactWithVCardData:(NSData *)data;
|
||||
|
||||
- (NSString *)nameForPhoneNumber:(NSString *)recipientId;
|
||||
|
||||
#endif // TARGET_OS_IOS
|
||||
|
||||
+ (NSComparator)comparatorSortingNamesByFirstThenLast:(BOOL)firstNameOrdering;
|
||||
+ (NSString *)formattedFullNameWithCNContact:(CNContact *)cnContact NS_SWIFT_NAME(formattedFullName(cnContact:));
|
||||
+ (nullable NSString *)localizedStringForCNLabel:(nullable NSString *)cnLabel;
|
||||
|
||||
+ (CNContact *)mergeCNContact:(CNContact *)oldCNContact
|
||||
newCNContact:(CNContact *)newCNContact NS_SWIFT_NAME(merge(cnContact:newCNContact:));
|
||||
|
||||
+ (nullable NSData *)avatarDataForCNContact:(nullable CNContact *)cnContact;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,434 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "Contact.h"
|
||||
#import "OWSPrimaryStorage.h"
|
||||
#import "PhoneNumber.h"
|
||||
#import "SSKEnvironment.h"
|
||||
#import "SignalRecipient.h"
|
||||
#import "TSAccountManager.h"
|
||||
#import <SessionProtocolKit/Cryptography.h>
|
||||
#import <SessionProtocolKit/NSString+OWS.h>
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
@import Contacts;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface Contact ()
|
||||
|
||||
@property (nonatomic, readonly) NSMutableDictionary<NSString *, NSString *> *phoneNumberNameMap;
|
||||
@property (nonatomic, readonly) NSUInteger imageHash;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation Contact
|
||||
|
||||
@synthesize comparableNameFirstLast = _comparableNameFirstLast;
|
||||
@synthesize comparableNameLastFirst = _comparableNameLastFirst;
|
||||
|
||||
#if TARGET_OS_IOS
|
||||
|
||||
- (instancetype)initWithSystemContact:(CNContact *)cnContact
|
||||
{
|
||||
self = [super init];
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
_cnContactId = cnContact.identifier;
|
||||
_firstName = cnContact.givenName.ows_stripped;
|
||||
_lastName = cnContact.familyName.ows_stripped;
|
||||
_fullName = [Contact formattedFullNameWithCNContact:cnContact];
|
||||
|
||||
NSMutableArray<NSString *> *phoneNumbers = [NSMutableArray new];
|
||||
NSMutableDictionary<NSString *, NSString *> *phoneNumberNameMap = [NSMutableDictionary new];
|
||||
const NSUInteger kMaxPhoneNumbersConsidered = 50;
|
||||
|
||||
NSArray<CNLabeledValue *> *consideredPhoneNumbers;
|
||||
if (cnContact.phoneNumbers.count <= kMaxPhoneNumbersConsidered) {
|
||||
consideredPhoneNumbers = cnContact.phoneNumbers;
|
||||
} else {
|
||||
OWSLogInfo(@"For perf, only considering the first %lu phone numbers for contact with many numbers.", (unsigned long)kMaxPhoneNumbersConsidered);
|
||||
consideredPhoneNumbers = [cnContact.phoneNumbers subarrayWithRange:NSMakeRange(0, kMaxPhoneNumbersConsidered)];
|
||||
}
|
||||
for (CNLabeledValue *phoneNumberField in consideredPhoneNumbers) {
|
||||
if ([phoneNumberField.value isKindOfClass:[CNPhoneNumber class]]) {
|
||||
CNPhoneNumber *phoneNumber = (CNPhoneNumber *)phoneNumberField.value;
|
||||
[phoneNumbers addObject:phoneNumber.stringValue];
|
||||
if ([phoneNumberField.label isEqualToString:CNLabelHome]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_HOME", @"Label for 'Home' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelWork]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_WORK", @"Label for 'Work' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberiPhone]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_IPHONE", @"Label for 'iPhone' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberMobile]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_MOBILE", @"Label for 'Mobile' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberMain]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_MAIN", @"Label for 'Main' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberHomeFax]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_HOME_FAX", @"Label for 'HomeFAX' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberWorkFax]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_WORK_FAX", @"Label for 'Work FAX' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberOtherFax]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_OTHER_FAX", @"Label for 'Other FAX' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelPhoneNumberPager]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_PAGER", @"Label for 'Pager' phone numbers.");
|
||||
} else if ([phoneNumberField.label isEqualToString:CNLabelOther]) {
|
||||
phoneNumberNameMap[phoneNumber.stringValue]
|
||||
= NSLocalizedString(@"PHONE_NUMBER_TYPE_OTHER", @"Label for 'Other' phone numbers.");
|
||||
} else if (phoneNumberField.label.length > 0 && ![phoneNumberField.label hasPrefix:@"_$"]) {
|
||||
// We'll reach this case for:
|
||||
//
|
||||
// * User-defined custom labels, which we want to display.
|
||||
// * Labels like "_$!<CompanyMain>!$_", which I'm guessing are synced from other platforms.
|
||||
// We don't want to display these labels. Even some of iOS' default labels (like Radio) show
|
||||
// up this way.
|
||||
phoneNumberNameMap[phoneNumber.stringValue] = phoneNumberField.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_userTextPhoneNumbers = [phoneNumbers copy];
|
||||
_phoneNumberNameMap = [NSMutableDictionary new];
|
||||
_parsedPhoneNumbers =
|
||||
[self parsedPhoneNumbersFromUserTextPhoneNumbers:phoneNumbers phoneNumberNameMap:phoneNumberNameMap];
|
||||
|
||||
NSMutableArray<NSString *> *emailAddresses = [NSMutableArray new];
|
||||
for (CNLabeledValue *emailField in cnContact.emailAddresses) {
|
||||
if ([emailField.value isKindOfClass:[NSString class]]) {
|
||||
[emailAddresses addObject:(NSString *)emailField.value];
|
||||
}
|
||||
}
|
||||
_emails = [emailAddresses copy];
|
||||
|
||||
NSData *_Nullable avatarData = [Contact avatarDataForCNContact:cnContact];
|
||||
if (avatarData) {
|
||||
NSUInteger hashValue = 0;
|
||||
NSData *_Nullable hashData = [Cryptography computeSHA256Digest:avatarData truncatedToBytes:sizeof(hashValue)];
|
||||
if (!hashData) {
|
||||
OWSFailDebug(@"could not compute hash for avatar.");
|
||||
}
|
||||
[hashData getBytes:&hashValue length:sizeof(hashValue)];
|
||||
_imageHash = hashValue;
|
||||
} else {
|
||||
_imageHash = 0;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSString *)uniqueId
|
||||
{
|
||||
return self.cnContactId;
|
||||
}
|
||||
|
||||
+ (nullable Contact *)contactWithVCardData:(NSData *)data
|
||||
{
|
||||
CNContact *_Nullable cnContact = [self cnContactWithVCardData:data];
|
||||
|
||||
if (!cnContact) {
|
||||
OWSLogError(@"Could not parse vcard data.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
return [[self alloc] initWithSystemContact:cnContact];
|
||||
}
|
||||
|
||||
#endif // TARGET_OS_IOS
|
||||
|
||||
- (NSArray<PhoneNumber *> *)parsedPhoneNumbersFromUserTextPhoneNumbers:(NSArray<NSString *> *)userTextPhoneNumbers
|
||||
phoneNumberNameMap:(nullable NSDictionary<NSString *, NSString *> *)
|
||||
phoneNumberNameMap
|
||||
{
|
||||
OWSAssertDebug(self.phoneNumberNameMap);
|
||||
|
||||
NSMutableDictionary<NSString *, PhoneNumber *> *parsedPhoneNumberMap = [NSMutableDictionary new];
|
||||
NSMutableArray<PhoneNumber *> *parsedPhoneNumbers = [NSMutableArray new];
|
||||
for (NSString *phoneNumberString in userTextPhoneNumbers) {
|
||||
for (PhoneNumber *phoneNumber in
|
||||
[PhoneNumber tryParsePhoneNumbersFromsUserSpecifiedText:phoneNumberString
|
||||
clientPhoneNumber:[TSAccountManager localNumber]]) {
|
||||
[parsedPhoneNumbers addObject:phoneNumber];
|
||||
parsedPhoneNumberMap[phoneNumber.toE164] = phoneNumber;
|
||||
NSString *phoneNumberName = phoneNumberNameMap[phoneNumberString];
|
||||
if (phoneNumberName) {
|
||||
self.phoneNumberNameMap[phoneNumber.toE164] = phoneNumberName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [parsedPhoneNumbers sortedArrayUsingSelector:@selector(compare:)];
|
||||
}
|
||||
|
||||
- (NSString *)comparableNameFirstLast {
|
||||
if (_comparableNameFirstLast == nil) {
|
||||
// Combine the two names with a tab separator, which has a lower ascii code than space, so that first names
|
||||
// that contain a space ("Mary Jo\tCatlett") will sort after those that do not ("Mary\tOliver")
|
||||
_comparableNameFirstLast = [self combineLeftName:_firstName withRightName:_lastName usingSeparator:@"\t"];
|
||||
}
|
||||
|
||||
return _comparableNameFirstLast;
|
||||
}
|
||||
|
||||
- (NSString *)comparableNameLastFirst {
|
||||
if (_comparableNameLastFirst == nil) {
|
||||
// Combine the two names with a tab separator, which has a lower ascii code than space, so that last names
|
||||
// that contain a space ("Van Der Beek\tJames") will sort after those that do not ("Van\tJames")
|
||||
_comparableNameLastFirst = [self combineLeftName:_lastName withRightName:_firstName usingSeparator:@"\t"];
|
||||
}
|
||||
|
||||
return _comparableNameLastFirst;
|
||||
}
|
||||
|
||||
- (NSString *)combineLeftName:(NSString *)leftName withRightName:(NSString *)rightName usingSeparator:(NSString *)separator {
|
||||
const BOOL leftNameNonEmpty = (leftName.length > 0);
|
||||
const BOOL rightNameNonEmpty = (rightName.length > 0);
|
||||
|
||||
if (leftNameNonEmpty && rightNameNonEmpty) {
|
||||
return [NSString stringWithFormat:@"%@%@%@", leftName, separator, rightName];
|
||||
} else if (leftNameNonEmpty) {
|
||||
return [leftName copy];
|
||||
} else if (rightNameNonEmpty) {
|
||||
return [rightName copy];
|
||||
} else {
|
||||
return @"";
|
||||
}
|
||||
}
|
||||
|
||||
- (NSString *)description {
|
||||
return [NSString stringWithFormat:@"%@: %@", self.fullName, self.userTextPhoneNumbers];
|
||||
}
|
||||
|
||||
- (BOOL)isSignalContact {
|
||||
NSArray *identifiers = [self textSecureIdentifiers];
|
||||
|
||||
return [identifiers count] > 0;
|
||||
}
|
||||
|
||||
- (NSArray<SignalRecipient *> *)signalRecipientsWithTransaction:(YapDatabaseReadTransaction *)transaction
|
||||
{
|
||||
__block NSMutableArray *result = [NSMutableArray array];
|
||||
|
||||
for (PhoneNumber *number in [self.parsedPhoneNumbers sortedArrayUsingSelector:@selector(compare:)]) {
|
||||
SignalRecipient *_Nullable signalRecipient = [SignalRecipient registeredRecipientForRecipientId:number.toE164
|
||||
mustHaveDevices:YES
|
||||
transaction:transaction];
|
||||
if (signalRecipient) {
|
||||
[result addObject:signalRecipient];
|
||||
}
|
||||
}
|
||||
|
||||
return [result copy];
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)textSecureIdentifiers {
|
||||
__block NSMutableArray *identifiers = [NSMutableArray array];
|
||||
|
||||
[OWSPrimaryStorage.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
||||
for (PhoneNumber *number in self.parsedPhoneNumbers) {
|
||||
if ([SignalRecipient isRegisteredRecipient:number.toE164 transaction:transaction]) {
|
||||
[identifiers addObject:number.toE164];
|
||||
}
|
||||
}
|
||||
}];
|
||||
return [identifiers copy];
|
||||
}
|
||||
|
||||
+ (NSComparator)comparatorSortingNamesByFirstThenLast:(BOOL)firstNameOrdering {
|
||||
return ^NSComparisonResult(id obj1, id obj2) {
|
||||
Contact *contact1 = (Contact *)obj1;
|
||||
Contact *contact2 = (Contact *)obj2;
|
||||
|
||||
if (firstNameOrdering) {
|
||||
return [contact1.comparableNameFirstLast caseInsensitiveCompare:contact2.comparableNameFirstLast];
|
||||
} else {
|
||||
return [contact1.comparableNameLastFirst caseInsensitiveCompare:contact2.comparableNameLastFirst];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+ (NSString *)formattedFullNameWithCNContact:(CNContact *)cnContact
|
||||
{
|
||||
return [CNContactFormatter stringFromContact:cnContact style:CNContactFormatterStyleFullName].ows_stripped;
|
||||
}
|
||||
|
||||
- (NSString *)nameForPhoneNumber:(NSString *)recipientId
|
||||
{
|
||||
OWSAssertDebug(recipientId.length > 0);
|
||||
OWSAssertDebug([self.textSecureIdentifiers containsObject:recipientId]);
|
||||
|
||||
NSString *value = self.phoneNumberNameMap[recipientId];
|
||||
OWSAssertDebug(value);
|
||||
if (!value) {
|
||||
return NSLocalizedString(@"PHONE_NUMBER_TYPE_UNKNOWN",
|
||||
@"Label used when we don't what kind of phone number it is (e.g. mobile/work/home).");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
+ (nullable NSData *)avatarDataForCNContact:(nullable CNContact *)cnContact
|
||||
{
|
||||
if (cnContact.thumbnailImageData) {
|
||||
return cnContact.thumbnailImageData.copy;
|
||||
} else if (cnContact.imageData) {
|
||||
// This only occurs when sharing a contact via the share extension
|
||||
return cnContact.imageData.copy;
|
||||
} else {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
// This method is used to de-bounce system contact fetch notifications
|
||||
// by checking for changes in the contact data.
|
||||
- (NSUInteger)hash
|
||||
{
|
||||
// base hash is some arbitrary number
|
||||
NSUInteger hash = 1825038313;
|
||||
|
||||
hash = hash ^ self.fullName.hash;
|
||||
|
||||
hash = hash ^ self.imageHash;
|
||||
|
||||
for (PhoneNumber *phoneNumber in self.parsedPhoneNumbers) {
|
||||
hash = hash ^ phoneNumber.toE164.hash;
|
||||
}
|
||||
|
||||
for (NSString *email in self.emails) {
|
||||
hash = hash ^ email.hash;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
#pragma mark - CNContactConverters
|
||||
|
||||
+ (nullable CNContact *)cnContactWithVCardData:(NSData *)data
|
||||
{
|
||||
OWSAssertDebug(data);
|
||||
|
||||
NSError *error;
|
||||
NSArray<CNContact *> *_Nullable contacts = [CNContactVCardSerialization contactsWithData:data error:&error];
|
||||
if (!contacts || error) {
|
||||
OWSFailDebug(@"could not parse vcard: %@", error);
|
||||
return nil;
|
||||
}
|
||||
if (contacts.count < 1) {
|
||||
OWSFailDebug(@"empty vcard: %@", error);
|
||||
return nil;
|
||||
}
|
||||
if (contacts.count > 1) {
|
||||
OWSFailDebug(@"more than one contact in vcard: %@", error);
|
||||
}
|
||||
return contacts.firstObject;
|
||||
}
|
||||
|
||||
+ (CNContact *)mergeCNContact:(CNContact *)oldCNContact newCNContact:(CNContact *)newCNContact
|
||||
{
|
||||
OWSAssertDebug(oldCNContact);
|
||||
OWSAssertDebug(newCNContact);
|
||||
|
||||
Contact *oldContact = [[Contact alloc] initWithSystemContact:oldCNContact];
|
||||
|
||||
CNMutableContact *_Nullable mergedCNContact = [oldCNContact mutableCopy];
|
||||
if (!mergedCNContact) {
|
||||
OWSFailDebug(@"mergedCNContact was unexpectedly nil");
|
||||
return [CNContact new];
|
||||
}
|
||||
|
||||
// Name
|
||||
NSString *formattedFullName = [self.class formattedFullNameWithCNContact:mergedCNContact];
|
||||
|
||||
// merged all or nothing - do not try to piece-meal merge.
|
||||
if (formattedFullName.length == 0) {
|
||||
mergedCNContact.namePrefix = newCNContact.namePrefix.ows_stripped;
|
||||
mergedCNContact.givenName = newCNContact.givenName.ows_stripped;
|
||||
mergedCNContact.middleName = newCNContact.middleName.ows_stripped;
|
||||
mergedCNContact.familyName = newCNContact.familyName.ows_stripped;
|
||||
mergedCNContact.nameSuffix = newCNContact.nameSuffix.ows_stripped;
|
||||
}
|
||||
|
||||
if (mergedCNContact.organizationName.ows_stripped.length < 1) {
|
||||
mergedCNContact.organizationName = newCNContact.organizationName.ows_stripped;
|
||||
}
|
||||
|
||||
// Phone Numbers
|
||||
NSSet<PhoneNumber *> *existingParsedPhoneNumberSet = [NSSet setWithArray:oldContact.parsedPhoneNumbers];
|
||||
NSSet<NSString *> *existingUnparsedPhoneNumberSet = [NSSet setWithArray:oldContact.userTextPhoneNumbers];
|
||||
|
||||
NSMutableArray<CNLabeledValue<CNPhoneNumber *> *> *mergedPhoneNumbers = [mergedCNContact.phoneNumbers mutableCopy];
|
||||
for (CNLabeledValue<CNPhoneNumber *> *labeledPhoneNumber in newCNContact.phoneNumbers) {
|
||||
NSString *_Nullable unparsedPhoneNumber = labeledPhoneNumber.value.stringValue;
|
||||
if ([existingUnparsedPhoneNumberSet containsObject:unparsedPhoneNumber]) {
|
||||
// Skip phone number if "unparsed" form is a duplicate.
|
||||
continue;
|
||||
}
|
||||
PhoneNumber *_Nullable parsedPhoneNumber = [PhoneNumber tryParsePhoneNumberFromUserSpecifiedText:labeledPhoneNumber.value.stringValue];
|
||||
if (parsedPhoneNumber && [existingParsedPhoneNumberSet containsObject:parsedPhoneNumber]) {
|
||||
// Skip phone number if "parsed" form is a duplicate.
|
||||
continue;
|
||||
}
|
||||
[mergedPhoneNumbers addObject:labeledPhoneNumber];
|
||||
}
|
||||
mergedCNContact.phoneNumbers = mergedPhoneNumbers;
|
||||
|
||||
// Emails
|
||||
NSSet<NSString *> *existingEmailSet = [NSSet setWithArray:oldContact.emails];
|
||||
NSMutableArray<CNLabeledValue<NSString *> *> *mergedEmailAddresses = [mergedCNContact.emailAddresses mutableCopy];
|
||||
for (CNLabeledValue<NSString *> *labeledEmail in newCNContact.emailAddresses) {
|
||||
NSString *normalizedValue = labeledEmail.value.ows_stripped;
|
||||
if (![existingEmailSet containsObject:normalizedValue]) {
|
||||
[mergedEmailAddresses addObject:labeledEmail];
|
||||
}
|
||||
}
|
||||
mergedCNContact.emailAddresses = mergedEmailAddresses;
|
||||
|
||||
// Address
|
||||
// merged all or nothing - do not try to piece-meal merge.
|
||||
if (mergedCNContact.postalAddresses.count == 0) {
|
||||
mergedCNContact.postalAddresses = newCNContact.postalAddresses;
|
||||
}
|
||||
|
||||
// Avatar
|
||||
if (!mergedCNContact.imageData) {
|
||||
mergedCNContact.imageData = newCNContact.imageData;
|
||||
}
|
||||
|
||||
return [mergedCNContact copy];
|
||||
}
|
||||
|
||||
+ (nullable NSString *)localizedStringForCNLabel:(nullable NSString *)cnLabel
|
||||
{
|
||||
if (cnLabel.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *_Nullable localizedLabel = [CNLabeledValue localizedStringForLabel:cnLabel];
|
||||
|
||||
// Docs for localizedStringForLabel say it returns:
|
||||
// > The localized string if a Contacts framework defined label, otherwise just returns the label.
|
||||
// But in practice, at least on iOS11, if the label is not one of CNContacts known labels (like CNLabelHome)
|
||||
// kUnlocalizedStringLabel is returned, rather than the unadultered label.
|
||||
NSString *const kUnlocalizedStringLabel = @"__ABUNLOCALIZEDSTRING";
|
||||
|
||||
if ([localizedLabel isEqual:kUnlocalizedStringLabel]) {
|
||||
return cnLabel;
|
||||
}
|
||||
|
||||
return localizedLabel;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSErrorUserInfoKey const ContactDiscoveryServiceErrorKey_Reason;
|
||||
extern NSErrorDomain const ContactDiscoveryServiceErrorDomain;
|
||||
typedef NS_ERROR_ENUM(ContactDiscoveryServiceErrorDomain, ContactDiscoveryServiceError){
|
||||
ContactDiscoveryServiceErrorAttestationFailed = 100, ContactDiscoveryServiceErrorAssertionError = 101
|
||||
};
|
||||
|
||||
@class ECKeyPair;
|
||||
@class OWSAES256Key;
|
||||
|
||||
@interface RemoteAttestationAuth : NSObject
|
||||
|
||||
@property (nonatomic, readonly) NSString *username;
|
||||
@property (nonatomic, readonly) NSString *password;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface RemoteAttestationKeys : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ECKeyPair *keyPair;
|
||||
@property (nonatomic, readonly) NSData *serverEphemeralPublic;
|
||||
@property (nonatomic, readonly) NSData *serverStaticPublic;
|
||||
|
||||
@property (nonatomic, readonly) OWSAES256Key *clientKey;
|
||||
@property (nonatomic, readonly) OWSAES256Key *serverKey;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface RemoteAttestation : NSObject
|
||||
|
||||
@property (nonatomic, readonly) RemoteAttestationKeys *keys;
|
||||
@property (nonatomic, readonly) NSArray<NSHTTPCookie *> *cookies;
|
||||
@property (nonatomic, readonly) NSData *requestId;
|
||||
@property (nonatomic, readonly) NSString *enclaveId;
|
||||
@property (nonatomic, readonly) RemoteAttestationAuth *auth;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ContactDiscoveryService : NSObject
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
|
||||
- (instancetype)initDefault NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
- (void)testService;
|
||||
- (void)performRemoteAttestationWithSuccess:(void (^)(RemoteAttestation *_Nonnull remoteAttestation))successHandler
|
||||
failure:(void (^)(NSError *_Nonnull error))failureHandler;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,774 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "ContactDiscoveryService.h"
|
||||
#import "CDSQuote.h"
|
||||
#import "CDSSigningCertificate.h"
|
||||
#import "NSError+MessageSending.h"
|
||||
#import "OWSError.h"
|
||||
#import "OWSRequestFactory.h"
|
||||
#import "SSKEnvironment.h"
|
||||
#import "TSNetworkManager.h"
|
||||
#import <Curve25519Kit/Curve25519.h>
|
||||
#import <HKDFKit/HKDFKit.h>
|
||||
#import "SSKAsserts.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSErrorUserInfoKey const ContactDiscoveryServiceErrorKey_Reason = @"ContactDiscoveryServiceErrorKey_Reason";
|
||||
NSErrorDomain const ContactDiscoveryServiceErrorDomain = @"SignalServiceKit.ContactDiscoveryService";
|
||||
|
||||
NSError *ContactDiscoveryServiceErrorMakeWithReason(NSInteger code, NSString *reason)
|
||||
{
|
||||
OWSCFailDebug(@"Error: %@", reason);
|
||||
|
||||
return [NSError errorWithDomain:ContactDiscoveryServiceErrorDomain
|
||||
code:code
|
||||
userInfo:@{ ContactDiscoveryServiceErrorKey_Reason : reason }];
|
||||
}
|
||||
|
||||
@interface RemoteAttestationAuth ()
|
||||
|
||||
@property (nonatomic) NSString *username;
|
||||
@property (nonatomic) NSString *password;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation RemoteAttestationAuth
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface RemoteAttestationKeys ()
|
||||
|
||||
@property (nonatomic) ECKeyPair *keyPair;
|
||||
@property (nonatomic) NSData *serverEphemeralPublic;
|
||||
@property (nonatomic) NSData *serverStaticPublic;
|
||||
|
||||
@property (nonatomic) OWSAES256Key *clientKey;
|
||||
@property (nonatomic) OWSAES256Key *serverKey;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation RemoteAttestationKeys
|
||||
|
||||
+ (nullable RemoteAttestationKeys *)keysForKeyPair:(ECKeyPair *)keyPair
|
||||
serverEphemeralPublic:(NSData *)serverEphemeralPublic
|
||||
serverStaticPublic:(NSData *)serverStaticPublic
|
||||
error:(NSError **)error
|
||||
{
|
||||
if (!keyPair) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"Missing keyPair");
|
||||
return nil;
|
||||
}
|
||||
if (serverEphemeralPublic.length < 1) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"Invalid serverEphemeralPublic");
|
||||
return nil;
|
||||
}
|
||||
if (serverStaticPublic.length < 1) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"Invalid serverStaticPublic");
|
||||
return nil;
|
||||
}
|
||||
RemoteAttestationKeys *keys = [RemoteAttestationKeys new];
|
||||
keys.keyPair = keyPair;
|
||||
keys.serverEphemeralPublic = serverEphemeralPublic;
|
||||
keys.serverStaticPublic = serverStaticPublic;
|
||||
if (![keys deriveKeys]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"failed to derive keys");
|
||||
return nil;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Returns YES on success.
|
||||
- (BOOL)deriveKeys
|
||||
{
|
||||
NSData *ephemeralToEphemeral;
|
||||
NSData *ephemeralToStatic;
|
||||
@try {
|
||||
ephemeralToEphemeral =
|
||||
[Curve25519 generateSharedSecretFromPublicKey:self.serverEphemeralPublic andKeyPair:self.keyPair];
|
||||
ephemeralToStatic =
|
||||
[Curve25519 generateSharedSecretFromPublicKey:self.serverStaticPublic andKeyPair:self.keyPair];
|
||||
} @catch (NSException *exception) {
|
||||
OWSFailDebug(@"could not generate shared secrets: %@", exception);
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSData *masterSecret = [ephemeralToEphemeral dataByAppendingData:ephemeralToStatic];
|
||||
NSData *publicKeys = [NSData join:@[
|
||||
self.keyPair.publicKey,
|
||||
self.serverEphemeralPublic,
|
||||
self.serverStaticPublic,
|
||||
]];
|
||||
|
||||
NSData *_Nullable derivedMaterial;
|
||||
@try {
|
||||
derivedMaterial =
|
||||
[HKDFKit deriveKey:masterSecret info:nil salt:publicKeys outputSize:(int)kAES256_KeyByteLength * 2];
|
||||
} @catch (NSException *exception) {
|
||||
OWSFailDebug(@"could not derive service key: %@", exception);
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (!derivedMaterial) {
|
||||
OWSFailDebug(@"missing derived service key.");
|
||||
return NO;
|
||||
}
|
||||
if (derivedMaterial.length != kAES256_KeyByteLength * 2) {
|
||||
OWSFailDebug(@"derived service key has unexpected length.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSData *_Nullable clientKeyData =
|
||||
[derivedMaterial subdataWithRange:NSMakeRange(kAES256_KeyByteLength * 0, kAES256_KeyByteLength)];
|
||||
OWSAES256Key *_Nullable clientKey = [OWSAES256Key keyWithData:clientKeyData];
|
||||
if (!clientKey) {
|
||||
OWSFailDebug(@"clientKey has unexpected length.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSData *_Nullable serverKeyData =
|
||||
[derivedMaterial subdataWithRange:NSMakeRange(kAES256_KeyByteLength * 1, kAES256_KeyByteLength)];
|
||||
OWSAES256Key *_Nullable serverKey = [OWSAES256Key keyWithData:serverKeyData];
|
||||
if (!serverKey) {
|
||||
OWSFailDebug(@"serverKey has unexpected length.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
self.clientKey = clientKey;
|
||||
self.serverKey = serverKey;
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface RemoteAttestation ()
|
||||
|
||||
@property (nonatomic) RemoteAttestationKeys *keys;
|
||||
@property (nonatomic) NSArray<NSHTTPCookie *> *cookies;
|
||||
@property (nonatomic) NSData *requestId;
|
||||
@property (nonatomic) NSString *enclaveId;
|
||||
@property (nonatomic) RemoteAttestationAuth *auth;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation RemoteAttestation
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface SignatureBodyEntity : NSObject
|
||||
|
||||
@property (nonatomic) NSData *isvEnclaveQuoteBody;
|
||||
@property (nonatomic) NSString *isvEnclaveQuoteStatus;
|
||||
@property (nonatomic) NSString *timestamp;
|
||||
@property (nonatomic) NSNumber *version;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation SignatureBodyEntity
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface NSDictionary (CDS)
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation NSDictionary (CDS)
|
||||
|
||||
- (nullable NSString *)stringForKey:(NSString *)key
|
||||
{
|
||||
NSString *_Nullable valueString = self[key];
|
||||
if (![valueString isKindOfClass:[NSString class]]) {
|
||||
OWSFailDebug(@"couldn't parse string for key: %@", key);
|
||||
return nil;
|
||||
}
|
||||
return valueString;
|
||||
}
|
||||
|
||||
- (nullable NSNumber *)numberForKey:(NSString *)key
|
||||
{
|
||||
NSNumber *_Nullable value = self[key];
|
||||
if (![value isKindOfClass:[NSNumber class]]) {
|
||||
OWSFailDebug(@"couldn't parse number for key: %@", key);
|
||||
return nil;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
- (nullable NSData *)base64DataForKey:(NSString *)key
|
||||
{
|
||||
NSString *_Nullable valueString = self[key];
|
||||
if (![valueString isKindOfClass:[NSString class]]) {
|
||||
OWSFailDebug(@"couldn't parse base 64 value for key: %@", key);
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable valueData = [[NSData alloc] initWithBase64EncodedString:valueString options:0];
|
||||
if (!valueData) {
|
||||
OWSFailDebug(@"couldn't decode base 64 value for key: %@", key);
|
||||
return nil;
|
||||
}
|
||||
return valueData;
|
||||
}
|
||||
|
||||
- (nullable NSData *)base64DataForKey:(NSString *)key expectedLength:(NSUInteger)expectedLength
|
||||
{
|
||||
NSData *_Nullable valueData = [self base64DataForKey:key];
|
||||
if (valueData && valueData.length != expectedLength) {
|
||||
OWSLogDebug(@"decoded base 64 value for key: %@, has unexpected length: %lu != %lu",
|
||||
key,
|
||||
(unsigned long)valueData.length,
|
||||
(unsigned long)expectedLength);
|
||||
OWSFailDebug(@"decoded base 64 value for key has unexpected length: %lu != %lu",
|
||||
(unsigned long)valueData.length,
|
||||
(unsigned long)expectedLength);
|
||||
return nil;
|
||||
}
|
||||
return valueData;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation ContactDiscoveryService
|
||||
|
||||
+ (instancetype)shared
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.contactDiscoveryService);
|
||||
|
||||
return SSKEnvironment.shared.contactDiscoveryService;
|
||||
}
|
||||
|
||||
- (instancetype)initDefault
|
||||
{
|
||||
self = [super init];
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
OWSSingletonAssert();
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)testService
|
||||
{
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
[self
|
||||
performRemoteAttestationWithSuccess:^(RemoteAttestation *remoteAttestation) {
|
||||
OWSLogDebug(@"succeeded");
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
OWSLogDebug(@"failed with error: %@", error);
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)performRemoteAttestationWithSuccess:(void (^)(RemoteAttestation *remoteAttestation))successHandler
|
||||
failure:(void (^)(NSError *error))failureHandler
|
||||
{
|
||||
[self
|
||||
getRemoteAttestationAuthWithSuccess:^(RemoteAttestationAuth *auth) {
|
||||
[self performRemoteAttestationWithAuth:auth success:successHandler failure:failureHandler];
|
||||
}
|
||||
failure:failureHandler];
|
||||
}
|
||||
|
||||
- (void)getRemoteAttestationAuthWithSuccess:(void (^)(RemoteAttestationAuth *))successHandler
|
||||
failure:(void (^)(NSError *error))failureHandler
|
||||
{
|
||||
TSRequest *request = [OWSRequestFactory remoteAttestationAuthRequest];
|
||||
[[TSNetworkManager sharedManager] makeRequest:request
|
||||
success:^(NSURLSessionDataTask *task, id responseDict) {
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
RemoteAttestationAuth *_Nullable auth = [self parseAuthParams:responseDict];
|
||||
if (!auth) {
|
||||
OWSLogError(@"remote attestation auth could not be parsed: %@", responseDict);
|
||||
NSError *error = OWSErrorMakeUnableToProcessServerResponseError();
|
||||
failureHandler(error);
|
||||
return;
|
||||
}
|
||||
|
||||
successHandler(auth);
|
||||
});
|
||||
}
|
||||
failure:^(NSURLSessionDataTask *task, NSError *error) {
|
||||
NSHTTPURLResponse *response = (NSHTTPURLResponse *)task.response;
|
||||
OWSLogVerbose(@"remote attestation auth failure: %lu", (unsigned long)response.statusCode);
|
||||
failureHandler(error);
|
||||
}];
|
||||
}
|
||||
|
||||
- (nullable RemoteAttestationAuth *)parseAuthParams:(id)response
|
||||
{
|
||||
if (![response isKindOfClass:[NSDictionary class]]) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSDictionary *responseDict = response;
|
||||
NSString *_Nullable password = [responseDict stringForKey:@"password"];
|
||||
if (password.length < 1) {
|
||||
OWSFailDebug(@"missing or empty password.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *_Nullable username = [responseDict stringForKey:@"username"];
|
||||
if (username.length < 1) {
|
||||
OWSFailDebug(@"missing or empty username.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
RemoteAttestationAuth *result = [RemoteAttestationAuth new];
|
||||
result.username = username;
|
||||
result.password = password;
|
||||
return result;
|
||||
}
|
||||
|
||||
- (void)performRemoteAttestationWithAuth:(RemoteAttestationAuth *)auth
|
||||
success:(void (^)(RemoteAttestation *remoteAttestation))successHandler
|
||||
failure:(void (^)(NSError *error))failureHandler
|
||||
{
|
||||
return; // Loki: Do nothing
|
||||
|
||||
ECKeyPair *keyPair = [Curve25519 generateKeyPair];
|
||||
|
||||
NSString *enclaveId = @"cd6cfc342937b23b1bdd3bbf9721aa5615ac9ff50a75c5527d441cd3276826c9";
|
||||
|
||||
TSRequest *request = [OWSRequestFactory remoteAttestationRequest:keyPair
|
||||
enclaveId:enclaveId
|
||||
authUsername:auth.username
|
||||
authPassword:auth.password];
|
||||
|
||||
[[TSNetworkManager sharedManager] makeRequest:request
|
||||
success:^(NSURLSessionDataTask *task, id responseJson) {
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSError *_Nullable error;
|
||||
RemoteAttestation *_Nullable attestation = [self parseAttestationResponseJson:responseJson
|
||||
response:task.response
|
||||
keyPair:keyPair
|
||||
enclaveId:enclaveId
|
||||
auth:auth
|
||||
error:&error];
|
||||
|
||||
if (!attestation) {
|
||||
if (!error) {
|
||||
OWSFailDebug(@"error was unexpectedly nil");
|
||||
error = ContactDiscoveryServiceErrorMakeWithReason(ContactDiscoveryServiceErrorAssertionError,
|
||||
@"failure when parsing attestation - no reason given");
|
||||
} else {
|
||||
OWSFailDebug(@"error with attestation: %@", error);
|
||||
}
|
||||
error.isRetryable = NO;
|
||||
failureHandler(error);
|
||||
return;
|
||||
}
|
||||
|
||||
successHandler(attestation);
|
||||
});
|
||||
}
|
||||
failure:^(NSURLSessionDataTask *task, NSError *error) {
|
||||
failureHandler(error);
|
||||
}];
|
||||
}
|
||||
|
||||
- (nullable RemoteAttestation *)parseAttestationResponseJson:(id)responseJson
|
||||
response:(NSURLResponse *)response
|
||||
keyPair:(ECKeyPair *)keyPair
|
||||
enclaveId:(NSString *)enclaveId
|
||||
auth:(RemoteAttestationAuth *)auth
|
||||
error:(NSError **)error
|
||||
{
|
||||
OWSAssertDebug(responseJson);
|
||||
OWSAssertDebug(response);
|
||||
OWSAssertDebug(keyPair);
|
||||
OWSAssertDebug(enclaveId.length > 0);
|
||||
|
||||
if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(ContactDiscoveryServiceErrorAssertionError, @"unexpected response type.");
|
||||
return nil;
|
||||
}
|
||||
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
|
||||
NSArray<NSHTTPCookie *> *cookies =
|
||||
[NSHTTPCookie cookiesWithResponseHeaderFields:httpResponse.allHeaderFields forURL:httpResponse.URL];
|
||||
if (cookies.count < 1) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse cookie.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (![responseJson isKindOfClass:[NSDictionary class]]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"invalid json response");
|
||||
return nil;
|
||||
}
|
||||
NSDictionary *responseDict = responseJson;
|
||||
NSData *_Nullable serverEphemeralPublic =
|
||||
[responseDict base64DataForKey:@"serverEphemeralPublic" expectedLength:32];
|
||||
if (!serverEphemeralPublic) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse serverEphemeralPublic.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable serverStaticPublic = [responseDict base64DataForKey:@"serverStaticPublic" expectedLength:32];
|
||||
if (!serverStaticPublic) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse serverStaticPublic.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable encryptedRequestId = [responseDict base64DataForKey:@"ciphertext"];
|
||||
if (!encryptedRequestId) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse encryptedRequestId.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable encryptedRequestIv = [responseDict base64DataForKey:@"iv" expectedLength:12];
|
||||
if (!encryptedRequestIv) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse encryptedRequestIv.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable encryptedRequestTag = [responseDict base64DataForKey:@"tag" expectedLength:16];
|
||||
if (!encryptedRequestTag) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse encryptedRequestTag.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable quoteData = [responseDict base64DataForKey:@"quote"];
|
||||
if (!quoteData) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse quote data.");
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable signatureBody = [responseDict stringForKey:@"signatureBody"];
|
||||
if (![signatureBody isKindOfClass:[NSString class]]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse signatureBody.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable signature = [responseDict base64DataForKey:@"signature"];
|
||||
if (!signature) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse signature.");
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable encodedCertificates = [responseDict stringForKey:@"certificates"];
|
||||
if (![encodedCertificates isKindOfClass:[NSString class]]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse encodedCertificates.");
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable certificates = [encodedCertificates stringByRemovingPercentEncoding];
|
||||
if (!certificates) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't parse certificates.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
RemoteAttestationKeys *_Nullable keys = [RemoteAttestationKeys keysForKeyPair:keyPair
|
||||
serverEphemeralPublic:serverEphemeralPublic
|
||||
serverStaticPublic:serverStaticPublic
|
||||
error:error];
|
||||
if (!keys || *error != nil) {
|
||||
if (*error == nil) {
|
||||
OWSFailDebug(@"missing error specifics");
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"Couldn't derive keys. No reason given");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
CDSQuote *_Nullable quote = [CDSQuote parseQuoteFromData:quoteData];
|
||||
if (!quote) {
|
||||
OWSFailDebug(@"couldn't parse quote.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable requestId = [self decryptRequestId:encryptedRequestId
|
||||
encryptedRequestIv:encryptedRequestIv
|
||||
encryptedRequestTag:encryptedRequestTag
|
||||
keys:keys];
|
||||
if (!requestId) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"couldn't decrypt request id.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (![self verifyServerQuote:quote keys:keys enclaveId:enclaveId]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAttestationFailed, @"couldn't verify quote.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (![self verifyIasSignatureWithCertificates:certificates
|
||||
signatureBody:signatureBody
|
||||
signature:signature
|
||||
quoteData:quoteData
|
||||
error:error]) {
|
||||
|
||||
if (*error == nil) {
|
||||
OWSFailDebug(@"missing error specifics");
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(ContactDiscoveryServiceErrorAssertionError,
|
||||
@"verifyIasSignatureWithCertificates failed. No reason given");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
RemoteAttestation *result = [RemoteAttestation new];
|
||||
result.cookies = cookies;
|
||||
result.keys = keys;
|
||||
result.requestId = requestId;
|
||||
result.enclaveId = enclaveId;
|
||||
result.auth = auth;
|
||||
|
||||
OWSLogVerbose(@"remote attestation complete.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
- (BOOL)verifyIasSignatureWithCertificates:(NSString *)certificates
|
||||
signatureBody:(NSString *)signatureBody
|
||||
signature:(NSData *)signature
|
||||
quoteData:(NSData *)quoteData
|
||||
error:(NSError **)error
|
||||
{
|
||||
OWSAssertDebug(certificates.length > 0);
|
||||
OWSAssertDebug(signatureBody.length > 0);
|
||||
OWSAssertDebug(signature.length > 0);
|
||||
OWSAssertDebug(quoteData);
|
||||
|
||||
NSError *signingError;
|
||||
CDSSigningCertificate *_Nullable certificate =
|
||||
[CDSSigningCertificate parseCertificateFromPem:certificates error:&signingError];
|
||||
if (signingError) {
|
||||
*error = signingError;
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (!certificate) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"could not parse signing certificate.");
|
||||
return NO;
|
||||
}
|
||||
if (![certificate verifySignatureOfBody:signatureBody signature:signature]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAttestationFailed, @"could not verify signature.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
SignatureBodyEntity *_Nullable signatureBodyEntity = [self parseSignatureBodyEntity:signatureBody];
|
||||
if (!signatureBodyEntity) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"could not parse signature body.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Compare the first N bytes of the quote data with the signed quote body.
|
||||
const NSUInteger kQuoteBodyComparisonLength = 432;
|
||||
if (signatureBodyEntity.isvEnclaveQuoteBody.length < kQuoteBodyComparisonLength) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"isvEnclaveQuoteBody has unexpected length.");
|
||||
return NO;
|
||||
}
|
||||
// NOTE: This version is separate from and does _NOT_ match the CDS quote version.
|
||||
const NSUInteger kSignatureBodyVersion = 3;
|
||||
if (![signatureBodyEntity.version isEqual:@(kSignatureBodyVersion)]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"signatureBodyEntity has unexpected version.");
|
||||
return NO;
|
||||
}
|
||||
if (quoteData.length < kQuoteBodyComparisonLength) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"quoteData has unexpected length.");
|
||||
return NO;
|
||||
}
|
||||
NSData *isvEnclaveQuoteBodyForComparison =
|
||||
[signatureBodyEntity.isvEnclaveQuoteBody subdataWithRange:NSMakeRange(0, kQuoteBodyComparisonLength)];
|
||||
NSData *quoteDataForComparison = [quoteData subdataWithRange:NSMakeRange(0, kQuoteBodyComparisonLength)];
|
||||
if (![isvEnclaveQuoteBodyForComparison ows_constantTimeIsEqualToData:quoteDataForComparison]) {
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAttestationFailed, @"isvEnclaveQuoteBody and quoteData do not match.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (![@"OK" isEqualToString:signatureBodyEntity.isvEnclaveQuoteStatus]) {
|
||||
NSString *reason =
|
||||
[NSString stringWithFormat:@"invalid isvEnclaveQuoteStatus: %@", signatureBodyEntity.isvEnclaveQuoteStatus];
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(ContactDiscoveryServiceErrorAttestationFailed, reason);
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
|
||||
NSTimeZone *timeZone = [NSTimeZone timeZoneWithName:@"UTC"];
|
||||
[dateFormatter setTimeZone:timeZone];
|
||||
[dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss.SSSSSS"];
|
||||
|
||||
// Specify parsing locale
|
||||
// from: https://developer.apple.com/library/archive/qa/qa1480/_index.html
|
||||
// Q: I'm using NSDateFormatter to parse an Internet-style date, but this fails for some users in some regions.
|
||||
// I've set a specific date format string; shouldn't that force NSDateFormatter to work independently of the user's
|
||||
// region settings? A: No. While setting a date format string will appear to work for most users, it's not the right
|
||||
// solution to this problem. There are many places where format strings behave in unexpected ways. [...]
|
||||
NSLocale *enUSPOSIXLocale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
|
||||
[dateFormatter setLocale:enUSPOSIXLocale];
|
||||
NSDate *timestampDate = [dateFormatter dateFromString:signatureBodyEntity.timestamp];
|
||||
if (!timestampDate) {
|
||||
OWSFailDebug(@"Could not parse signature body timestamp: %@", signatureBodyEntity.timestamp);
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAssertionError, @"could not parse signature body timestamp.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Only accept signatures from the last 24 hours.
|
||||
NSDateComponents *dayComponent = [[NSDateComponents alloc] init];
|
||||
dayComponent.day = 1;
|
||||
NSCalendar *calendar = [NSCalendar currentCalendar];
|
||||
NSDate *timestampDatePlus1Day = [calendar dateByAddingComponents:dayComponent toDate:timestampDate options:0];
|
||||
|
||||
NSDate *now = [NSDate new];
|
||||
BOOL isExpired = [now isAfterDate:timestampDatePlus1Day];
|
||||
|
||||
if (isExpired) {
|
||||
OWSFailDebug(@"Signature is expired: %@", signatureBodyEntity.timestamp);
|
||||
*error = ContactDiscoveryServiceErrorMakeWithReason(
|
||||
ContactDiscoveryServiceErrorAttestationFailed, @"Signature is expired.");
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (nullable SignatureBodyEntity *)parseSignatureBodyEntity:(NSString *)signatureBody
|
||||
{
|
||||
OWSAssertDebug(signatureBody.length > 0);
|
||||
|
||||
NSError *error = nil;
|
||||
NSDictionary *_Nullable jsonDict =
|
||||
[NSJSONSerialization JSONObjectWithData:[signatureBody dataUsingEncoding:NSUTF8StringEncoding]
|
||||
options:0
|
||||
error:&error];
|
||||
if (error || ![jsonDict isKindOfClass:[NSDictionary class]]) {
|
||||
OWSFailDebug(@"could not parse signature body JSON: %@.", error);
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable timestamp = [jsonDict stringForKey:@"timestamp"];
|
||||
if (timestamp.length < 1) {
|
||||
OWSFailDebug(@"could not parse signature timestamp.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable isvEnclaveQuoteBody = [jsonDict base64DataForKey:@"isvEnclaveQuoteBody"];
|
||||
if (isvEnclaveQuoteBody.length < 1) {
|
||||
OWSFailDebug(@"could not parse signature isvEnclaveQuoteBody.");
|
||||
return nil;
|
||||
}
|
||||
NSString *_Nullable isvEnclaveQuoteStatus = [jsonDict stringForKey:@"isvEnclaveQuoteStatus"];
|
||||
if (isvEnclaveQuoteStatus.length < 1) {
|
||||
OWSFailDebug(@"could not parse signature isvEnclaveQuoteStatus.");
|
||||
return nil;
|
||||
}
|
||||
NSNumber *_Nullable version = [jsonDict numberForKey:@"version"];
|
||||
if (!version) {
|
||||
OWSFailDebug(@"could not parse signature version.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
SignatureBodyEntity *result = [SignatureBodyEntity new];
|
||||
result.isvEnclaveQuoteBody = isvEnclaveQuoteBody;
|
||||
result.isvEnclaveQuoteStatus = isvEnclaveQuoteStatus;
|
||||
result.timestamp = timestamp;
|
||||
result.version = version;
|
||||
return result;
|
||||
}
|
||||
|
||||
- (BOOL)verifyServerQuote:(CDSQuote *)quote keys:(RemoteAttestationKeys *)keys enclaveId:(NSString *)enclaveId
|
||||
{
|
||||
OWSAssertDebug(quote);
|
||||
OWSAssertDebug(keys);
|
||||
OWSAssertDebug(enclaveId.length > 0);
|
||||
|
||||
if (quote.reportData.length < keys.serverStaticPublic.length) {
|
||||
OWSFailDebug(@"reportData has unexpected length: %lu != %lu.",
|
||||
(unsigned long)quote.reportData.length,
|
||||
(unsigned long)keys.serverStaticPublic.length);
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSData *_Nullable theirServerPublicStatic =
|
||||
[quote.reportData subdataWithRange:NSMakeRange(0, keys.serverStaticPublic.length)];
|
||||
if (theirServerPublicStatic.length != keys.serverStaticPublic.length) {
|
||||
OWSFailDebug(@"could not extract server public static.");
|
||||
return NO;
|
||||
}
|
||||
if (![keys.serverStaticPublic ows_constantTimeIsEqualToData:theirServerPublicStatic]) {
|
||||
OWSFailDebug(@"server public statics do not match.");
|
||||
return NO;
|
||||
}
|
||||
// It's easier to compare as hex data than parsing hexadecimal.
|
||||
NSData *_Nullable ourEnclaveIdHexData = [enclaveId dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSData *_Nullable theirEnclaveIdHexData =
|
||||
[quote.mrenclave.hexadecimalString dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!ourEnclaveIdHexData || !theirEnclaveIdHexData
|
||||
|| ![ourEnclaveIdHexData ows_constantTimeIsEqualToData:theirEnclaveIdHexData]) {
|
||||
OWSFailDebug(@"enclave ids do not match.");
|
||||
return NO;
|
||||
}
|
||||
if (quote.isDebugQuote) {
|
||||
OWSFailDebug(@"quote has invalid isDebugQuote value.");
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (nullable NSData *)decryptRequestId:(NSData *)encryptedRequestId
|
||||
encryptedRequestIv:(NSData *)encryptedRequestIv
|
||||
encryptedRequestTag:(NSData *)encryptedRequestTag
|
||||
keys:(RemoteAttestationKeys *)keys
|
||||
{
|
||||
OWSAssertDebug(encryptedRequestId.length > 0);
|
||||
OWSAssertDebug(encryptedRequestIv.length > 0);
|
||||
OWSAssertDebug(encryptedRequestTag.length > 0);
|
||||
OWSAssertDebug(keys);
|
||||
|
||||
OWSAES256Key *_Nullable key = keys.serverKey;
|
||||
if (!key) {
|
||||
OWSFailDebug(@"invalid server key.");
|
||||
return nil;
|
||||
}
|
||||
NSData *_Nullable decryptedData = [Cryptography decryptAESGCMWithInitializationVector:encryptedRequestIv
|
||||
ciphertext:encryptedRequestId
|
||||
additionalAuthenticatedData:nil
|
||||
authTag:encryptedRequestTag
|
||||
key:key];
|
||||
if (!decryptedData) {
|
||||
OWSFailDebug(@"couldn't decrypt request id.");
|
||||
return nil;
|
||||
}
|
||||
return decryptedData;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
public final class ContactParser {
|
||||
private let data: Data
|
||||
|
||||
public init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
public func parse() -> [(publicKey: String, isBlocked: Bool)] {
|
||||
var index = 0
|
||||
var result: [(String, Bool)] = []
|
||||
while index < data.endIndex {
|
||||
var uncheckedSize: UInt32? = try? data[index..<(index+4)].withUnsafeBytes { $0.pointee }
|
||||
if let size = uncheckedSize, size >= data.count, let intermediate = try? data[index..<(index+4)].reversed() {
|
||||
uncheckedSize = Data(intermediate).withUnsafeBytes { $0.pointee }
|
||||
}
|
||||
guard let size = uncheckedSize, size < data.count else { break }
|
||||
let sizeAsInt = Int(size)
|
||||
index += 4
|
||||
guard index + sizeAsInt <= data.count else { break }
|
||||
let protoAsData = data[index..<(index+sizeAsInt)]
|
||||
guard let proto = try? SSKProtoContactDetails.parseData(protoAsData) else { break }
|
||||
index += sizeAsInt
|
||||
result.append((publicKey: proto.number, isBlocked: proto.blocked))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class CNContact;
|
||||
@class Contact;
|
||||
@class PhoneNumber;
|
||||
@class SignalAccount;
|
||||
@class UIImage;
|
||||
@class YapDatabaseReadTransaction;
|
||||
|
||||
@protocol ContactsManagerProtocol <NSObject>
|
||||
|
||||
- (NSString *)displayNameForPhoneIdentifier:(nullable NSString *)recipientId;
|
||||
- (NSString *)displayNameForPhoneIdentifier:(NSString *_Nullable)recipientId
|
||||
transaction:(YapDatabaseReadTransaction *)transaction;
|
||||
- (NSArray<SignalAccount *> *)signalAccounts;
|
||||
|
||||
- (BOOL)isSystemContact:(NSString *)recipientId;
|
||||
- (BOOL)isSystemContactWithSignalAccount:(NSString *)recipientId;
|
||||
|
||||
- (NSComparisonResult)compareSignalAccount:(SignalAccount *)left
|
||||
withSignalAccount:(SignalAccount *)right NS_SWIFT_NAME(compare(signalAccount:with:));
|
||||
|
||||
#pragma mark - CNContacts
|
||||
|
||||
- (nullable CNContact *)cnContactWithId:(nullable NSString *)contactId;
|
||||
- (nullable NSData *)avatarDataForCNContactId:(nullable NSString *)contactId;
|
||||
- (nullable UIImage *)avatarImageForCNContactId:(nullable NSString *)contactId;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SignalRecipient.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ContactsUpdater : NSObject
|
||||
|
||||
+ (instancetype)sharedUpdater;
|
||||
|
||||
// This asynchronously tries to verify whether or not a group of possible
|
||||
// contact ids correspond to service accounts.
|
||||
//
|
||||
// The failure callback is only invoked if the lookup fails. Otherwise,
|
||||
// the success callback is invoked with the (possibly empty) set of contacts
|
||||
// that were found.
|
||||
- (void)lookupIdentifiers:(NSArray<NSString *> *)identifiers
|
||||
success:(void (^)(NSArray<SignalRecipient *> *recipients))success
|
||||
failure:(void (^)(NSError *error))failure;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,120 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "ContactsUpdater.h"
|
||||
#import "OWSError.h"
|
||||
#import "OWSPrimaryStorage.h"
|
||||
#import "OWSRequestFactory.h"
|
||||
#import "PhoneNumber.h"
|
||||
#import "SSKEnvironment.h"
|
||||
#import "TSNetworkManager.h"
|
||||
#import <SessionProtocolKit/Cryptography.h>
|
||||
#import <SessionProtocolKit/Threading.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <YapDatabase/YapDatabase.h>
|
||||
#import "SSKAsserts.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ContactsUpdater ()
|
||||
|
||||
@property (nonatomic, readonly) NSOperationQueue *contactIntersectionQueue;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation ContactsUpdater
|
||||
|
||||
+ (instancetype)sharedUpdater {
|
||||
OWSAssertDebug(SSKEnvironment.shared.contactsUpdater);
|
||||
|
||||
return SSKEnvironment.shared.contactsUpdater;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
self = [super init];
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
_contactIntersectionQueue = [NSOperationQueue new];
|
||||
_contactIntersectionQueue.maxConcurrentOperationCount = 1;
|
||||
_contactIntersectionQueue.name = self.logTag;
|
||||
|
||||
OWSSingletonAssert();
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)lookupIdentifiers:(NSArray<NSString *> *)identifiers
|
||||
success:(void (^)(NSArray<SignalRecipient *> *recipients))success
|
||||
failure:(void (^)(NSError *error))failure
|
||||
{
|
||||
if (identifiers.count < 1) {
|
||||
OWSFailDebug(@"Cannot lookup zero identifiers");
|
||||
DispatchMainThreadSafe(^{
|
||||
failure(
|
||||
OWSErrorWithCodeDescription(OWSErrorCodeInvalidMethodParameters, @"Cannot lookup zero identifiers"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[self contactIntersectionWithSet:[NSSet setWithArray:identifiers]
|
||||
success:^(NSSet<SignalRecipient *> *recipients) {
|
||||
if (recipients.count == 0) {
|
||||
OWSLogInfo(@"no contacts are Signal users");
|
||||
}
|
||||
DispatchMainThreadSafe(^{
|
||||
success(recipients.allObjects);
|
||||
});
|
||||
}
|
||||
failure:^(NSError *error) {
|
||||
DispatchMainThreadSafe(^{
|
||||
failure(error);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)contactIntersectionWithSet:(NSSet<NSString *> *)recipientIdsToLookup
|
||||
success:(void (^)(NSSet<SignalRecipient *> *recipients))success
|
||||
failure:(void (^)(NSError *error))failure
|
||||
{
|
||||
OWSLegacyContactDiscoveryOperation *operation =
|
||||
[[OWSLegacyContactDiscoveryOperation alloc] initWithRecipientIdsToLookup:recipientIdsToLookup.allObjects];
|
||||
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSArray<NSOperation *> *operationAndDependencies = [operation.dependencies arrayByAddingObject:operation];
|
||||
[self.contactIntersectionQueue addOperations:operationAndDependencies waitUntilFinished:YES];
|
||||
|
||||
if (operation.failingError != nil) {
|
||||
failure(operation.failingError);
|
||||
return;
|
||||
}
|
||||
|
||||
NSSet<NSString *> *registeredRecipientIds = operation.registeredRecipientIds;
|
||||
|
||||
NSMutableSet<SignalRecipient *> *recipients = [NSMutableSet new];
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
for (NSString *recipientId in recipientIdsToLookup) {
|
||||
if ([registeredRecipientIds containsObject:recipientId]) {
|
||||
SignalRecipient *recipient =
|
||||
[SignalRecipient markRecipientAsRegisteredAndGet:recipientId transaction:transaction];
|
||||
[recipients addObject:recipient];
|
||||
} else {
|
||||
[SignalRecipient markRecipientAsUnregistered:recipientId transaction:transaction];
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
success([recipients copy]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,127 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@objc
|
||||
public class ContentProxy: NSObject {
|
||||
|
||||
@available(*, unavailable, message:"do not instantiate this class.")
|
||||
private override init() {
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func sessionConfiguration() -> URLSessionConfiguration {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
let proxyHost = "contentproxy.signal.org"
|
||||
let proxyPort = 443
|
||||
configuration.connectionProxyDictionary = [
|
||||
"HTTPEnable": 1,
|
||||
"HTTPProxy": proxyHost,
|
||||
"HTTPPort": proxyPort,
|
||||
"HTTPSEnable": 1,
|
||||
"HTTPSProxy": proxyHost,
|
||||
"HTTPSPort": proxyPort
|
||||
]
|
||||
return configuration
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func sessionManager(baseUrl baseUrlString: String?) -> AFHTTPSessionManager? {
|
||||
guard let baseUrlString = baseUrlString else {
|
||||
return AFHTTPSessionManager(baseURL: nil, sessionConfiguration: sessionConfiguration())
|
||||
}
|
||||
guard let baseUrl = URL(string: baseUrlString) else {
|
||||
owsFailDebug("Invalid base URL.")
|
||||
return nil
|
||||
}
|
||||
let sessionManager = AFHTTPSessionManager(baseURL: baseUrl,
|
||||
sessionConfiguration: sessionConfiguration())
|
||||
return sessionManager
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func jsonSessionManager(baseUrl: String) -> AFHTTPSessionManager? {
|
||||
guard let sessionManager = self.sessionManager(baseUrl: baseUrl) else {
|
||||
owsFailDebug("Could not create session manager")
|
||||
return nil
|
||||
}
|
||||
sessionManager.requestSerializer = AFJSONRequestSerializer()
|
||||
sessionManager.responseSerializer = AFJSONResponseSerializer()
|
||||
return sessionManager
|
||||
}
|
||||
|
||||
static let userAgent = "Signal iOS (+https://signal.org/download)"
|
||||
|
||||
public class func configureProxiedRequest(request: inout URLRequest) -> Bool {
|
||||
request.addValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
padRequestSize(request: &request)
|
||||
|
||||
guard let url = request.url,
|
||||
let scheme = url.scheme,
|
||||
scheme.lowercased() == "https" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// This mutates the session manager state, so its the caller's obligation to avoid conflicts by:
|
||||
//
|
||||
// * Using a new session manager for each request.
|
||||
// * Pooling session managers.
|
||||
// * Using a single session manager on a single queue.
|
||||
@objc
|
||||
public class func configureSessionManager(sessionManager: AFHTTPSessionManager,
|
||||
forUrl urlString: String) -> Bool {
|
||||
|
||||
guard let url = URL(string: urlString, relativeTo: sessionManager.baseURL) else {
|
||||
owsFailDebug("Invalid URL query: \(urlString).")
|
||||
return false
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
guard configureProxiedRequest(request: &request) else {
|
||||
owsFailDebug("Invalid URL query: \(urlString).")
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove all headers from the request.
|
||||
for headerField in sessionManager.requestSerializer.httpRequestHeaders.keys {
|
||||
sessionManager.requestSerializer.setValue(nil, forHTTPHeaderField: headerField)
|
||||
}
|
||||
// Honor the request's headers.
|
||||
if let allHTTPHeaderFields = request.allHTTPHeaderFields {
|
||||
for (headerField, headerValue) in allHTTPHeaderFields {
|
||||
sessionManager.requestSerializer.setValue(headerValue, forHTTPHeaderField: headerField)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public class func padRequestSize(request: inout URLRequest) {
|
||||
// Generate 1-64 chars of padding.
|
||||
let paddingLength: Int = 1 + Int(arc4random_uniform(64))
|
||||
let padding = self.padding(withLength: paddingLength)
|
||||
assert(padding.count == paddingLength)
|
||||
request.addValue(padding, forHTTPHeaderField: "X-SignalPadding")
|
||||
}
|
||||
|
||||
private class func padding(withLength length: Int) -> String {
|
||||
// Pick a random ASCII char in the range 48-122
|
||||
var result = ""
|
||||
// Min and max values, inclusive.
|
||||
let minValue: UInt32 = 48
|
||||
let maxValue: UInt32 = 122
|
||||
for _ in 1...length {
|
||||
let value = minValue + arc4random_uniform(maxValue - minValue + 1)
|
||||
assert(value >= minValue)
|
||||
assert(value <= maxValue)
|
||||
result += String(UnicodeScalar(UInt8(value)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
|
||||
@objc(SSKCreatePreKeysOperation)
|
||||
public class CreatePreKeysOperation: OWSOperation {
|
||||
|
||||
private var accountServiceClient: AccountServiceClient {
|
||||
return AccountServiceClient.shared
|
||||
}
|
||||
|
||||
private var primaryStorage: OWSPrimaryStorage {
|
||||
return OWSPrimaryStorage.shared()
|
||||
}
|
||||
|
||||
private var identityKeyManager: OWSIdentityManager {
|
||||
return OWSIdentityManager.shared()
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
Logger.debug("")
|
||||
|
||||
if identityKeyManager.identityKeyPair() == nil {
|
||||
identityKeyManager.generateNewIdentityKeyPair()
|
||||
}
|
||||
|
||||
SessionManagementProtocol.createPreKeys()
|
||||
reportSuccess()
|
||||
|
||||
/* Loki: Original code
|
||||
* ================
|
||||
let identityKey: Data = self.identityKeyManager.identityKeyPair()!.publicKey
|
||||
let signedPreKeyRecord: SignedPreKeyRecord = self.primaryStorage.generateRandomSignedRecord()
|
||||
let preKeyRecords: [PreKeyRecord] = self.primaryStorage.generatePreKeyRecords()
|
||||
|
||||
self.primaryStorage.storeSignedPreKey(signedPreKeyRecord.id, signedPreKeyRecord: signedPreKeyRecord)
|
||||
self.primaryStorage.storePreKeyRecords(preKeyRecords)
|
||||
|
||||
firstly {
|
||||
self.accountServiceClient.setPreKeys(identityKey: identityKey, signedPreKeyRecord: signedPreKeyRecord, preKeyRecords: preKeyRecords)
|
||||
}.done {
|
||||
signedPreKeyRecord.markAsAcceptedByService()
|
||||
self.primaryStorage.storeSignedPreKey(signedPreKeyRecord.id, signedPreKeyRecord: signedPreKeyRecord)
|
||||
self.primaryStorage.setCurrentSignedPrekeyId(signedPreKeyRecord.id)
|
||||
|
||||
Logger.debug("done")
|
||||
self.reportSuccess()
|
||||
}.catch { error in
|
||||
self.reportError(error)
|
||||
}.retainUntilComplete()
|
||||
* ================
|
||||
*/
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
public extension Data {
|
||||
|
||||
/// Returns `size` bytes of random data generated using the default secure random number generator. See
|
||||
/// [SecRandomCopyBytes](https://developer.apple.com/documentation/security/1399291-secrandomcopybytes) for more information.
|
||||
public static func getSecureRandomData(ofSize size: UInt) -> Data? {
|
||||
var data = Data(count: Int(size))
|
||||
let result = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, Int(size), $0.baseAddress!) }
|
||||
guard result == errSecSuccess else { return nil }
|
||||
return data
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
extension Data {
|
||||
|
||||
init(from inputStream: InputStream) throws {
|
||||
self.init()
|
||||
inputStream.open()
|
||||
defer { inputStream.close() }
|
||||
let bufferSize = 1024
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||
defer { buffer.deallocate() }
|
||||
while inputStream.hasBytesAvailable {
|
||||
let count = inputStream.read(buffer, maxLength: bufferSize)
|
||||
if count < 0 {
|
||||
throw inputStream.streamError!
|
||||
} else if count == 0 {
|
||||
break
|
||||
} else {
|
||||
append(buffer, count: count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// A base class that abstracts away a source of NSData
|
||||
// and allows us to:
|
||||
//
|
||||
// * Lazy-load if possible.
|
||||
// * Avoid duplicate reads & writes.
|
||||
@interface DataSource : NSObject
|
||||
|
||||
@property (nonatomic, nullable) NSString *sourceFilename;
|
||||
|
||||
// Should not be called unless necessary as it can involve an expensive read.
|
||||
- (NSData *)data;
|
||||
|
||||
// The URL for the data. Should always be a File URL.
|
||||
//
|
||||
// Should not be called unless necessary as it can involve an expensive write.
|
||||
//
|
||||
// Will only return nil in the error case.
|
||||
- (nullable NSURL *)dataUrl;
|
||||
|
||||
// Will return zero in the error case.
|
||||
- (NSUInteger)dataLength;
|
||||
|
||||
// Returns YES on success.
|
||||
- (BOOL)writeToPath:(NSString *)dstFilePath;
|
||||
|
||||
- (BOOL)isValidImage;
|
||||
|
||||
- (BOOL)isValidVideo;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface DataSourceValue : DataSource
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithData:(NSData *)data fileExtension:(NSString *)fileExtension;
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithData:(NSData *)data utiType:(NSString *)utiType;
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text;
|
||||
|
||||
+ (DataSource *)dataSourceWithSyncMessageData:(NSData *)data;
|
||||
|
||||
+ (DataSource *)emptyDataSource;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface DataSourcePath : DataSource
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithURL:(NSURL *)fileUrl shouldDeleteOnDeallocation:(BOOL)shouldDeleteOnDeallocation;
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithFilePath:(NSString *)filePath
|
||||
shouldDeleteOnDeallocation:(BOOL)shouldDeleteOnDeallocation;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,401 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DataSource.h"
|
||||
#import "MIMETypeUtil.h"
|
||||
#import "NSData+Image.h"
|
||||
#import "OWSFileSystem.h"
|
||||
#import <SessionProtocolKit/NSString+OWS.h>
|
||||
#import <SessionProtocolKit/iOSVersions.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface DataSource ()
|
||||
|
||||
@property (nonatomic) BOOL shouldDeleteOnDeallocation;
|
||||
|
||||
// The file path for the data, if it already exists on disk.
|
||||
//
|
||||
// This method is safe to call as it will not do any expensive reads or writes.
|
||||
//
|
||||
// May return nil if the data does not (yet) reside on disk.
|
||||
//
|
||||
// Use dataUrl instead if you need to access the data; it will
|
||||
// ensure the data is on disk and return a URL, barring an error.
|
||||
- (nullable NSString *)dataPathIfOnDisk;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation DataSource
|
||||
|
||||
- (NSData *)data
|
||||
{
|
||||
OWSAbstractMethod();
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (nullable NSURL *)dataUrl
|
||||
{
|
||||
OWSAbstractMethod();
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (nullable NSString *)dataPathIfOnDisk
|
||||
{
|
||||
OWSAbstractMethod();
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (NSUInteger)dataLength
|
||||
{
|
||||
OWSAbstractMethod();
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (BOOL)writeToPath:(NSString *)dstFilePath
|
||||
{
|
||||
OWSAbstractMethod();
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (BOOL)isValidImage
|
||||
{
|
||||
NSString *_Nullable dataPath = [self dataPathIfOnDisk];
|
||||
if (dataPath) {
|
||||
// if ows_isValidImage is given a file path, it will
|
||||
// avoid loading most of the data into memory, which
|
||||
// is considerably more performant, so try to do that.
|
||||
return [NSData ows_isValidImageAtPath:dataPath mimeType:self.mimeType];
|
||||
}
|
||||
NSData *data = [self data];
|
||||
return [data ows_isValidImage];
|
||||
}
|
||||
|
||||
- (BOOL)isValidVideo
|
||||
{
|
||||
return [OWSMediaUtils isValidVideoWithPath:self.dataUrl.path];
|
||||
}
|
||||
|
||||
- (void)setSourceFilename:(nullable NSString *)sourceFilename
|
||||
{
|
||||
_sourceFilename = sourceFilename.filterFilename;
|
||||
}
|
||||
|
||||
// Returns the MIME type, if known.
|
||||
- (nullable NSString *)mimeType
|
||||
{
|
||||
OWSAbstractMethod();
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface DataSourceValue ()
|
||||
|
||||
@property (nonatomic) NSData *dataValue;
|
||||
|
||||
@property (nonatomic) NSString *fileExtension;
|
||||
|
||||
// This property is lazy-populated.
|
||||
@property (nonatomic, nullable) NSString *cachedFilePath;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation DataSourceValue
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
if (self.shouldDeleteOnDeallocation) {
|
||||
NSString *_Nullable filePath = self.cachedFilePath;
|
||||
if (filePath) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSError *error;
|
||||
BOOL success = [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error];
|
||||
if (!success || error) {
|
||||
OWSCFailDebug(@"DataSourceValue could not delete file: %@, %@", filePath, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithData:(NSData *)data
|
||||
fileExtension:(NSString *)fileExtension
|
||||
{
|
||||
OWSAssertDebug(data);
|
||||
|
||||
if (!data) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
DataSourceValue *instance = [DataSourceValue new];
|
||||
instance.dataValue = data;
|
||||
instance.fileExtension = fileExtension;
|
||||
instance.shouldDeleteOnDeallocation = YES;
|
||||
return instance;
|
||||
}
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithData:(NSData *)data
|
||||
utiType:(NSString *)utiType
|
||||
{
|
||||
NSString *fileExtension = [MIMETypeUtil fileExtensionForUTIType:utiType];
|
||||
return [self dataSourceWithData:data fileExtension:fileExtension];
|
||||
}
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text
|
||||
{
|
||||
if (!text) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *data = [text.filterStringForDisplay dataUsingEncoding:NSUTF8StringEncoding];
|
||||
return [self dataSourceWithData:data fileExtension:kOversizeTextAttachmentFileExtension];
|
||||
}
|
||||
|
||||
+ (DataSource *)dataSourceWithSyncMessageData:(NSData *)data
|
||||
{
|
||||
return [self dataSourceWithData:data fileExtension:kSyncMessageFileExtension];
|
||||
}
|
||||
|
||||
+ (DataSource *)emptyDataSource
|
||||
{
|
||||
return [self dataSourceWithData:[NSData new] fileExtension:@"bin"];
|
||||
}
|
||||
|
||||
- (NSData *)data
|
||||
{
|
||||
OWSAssertDebug(self.dataValue);
|
||||
|
||||
return self.dataValue;
|
||||
}
|
||||
|
||||
- (nullable NSURL *)dataUrl
|
||||
{
|
||||
NSString *_Nullable path = [self dataPath];
|
||||
return (path ? [NSURL fileURLWithPath:path] : nil);
|
||||
}
|
||||
|
||||
- (nullable NSString *)dataPath
|
||||
{
|
||||
OWSAssertDebug(self.dataValue);
|
||||
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!self.cachedFilePath) {
|
||||
NSString *filePath = [OWSFileSystem temporaryFilePathWithFileExtension:self.fileExtension];
|
||||
if ([self writeToPath:filePath]) {
|
||||
self.cachedFilePath = filePath;
|
||||
} else {
|
||||
OWSLogDebug(@"Could not write data to disk: %@", self.fileExtension);
|
||||
OWSFailDebug(@"Could not write data to disk.");
|
||||
}
|
||||
}
|
||||
|
||||
return self.cachedFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSString *)dataPathIfOnDisk
|
||||
{
|
||||
return self.cachedFilePath;
|
||||
}
|
||||
|
||||
- (NSUInteger)dataLength
|
||||
{
|
||||
OWSAssertDebug(self.dataValue);
|
||||
|
||||
return self.dataValue.length;
|
||||
}
|
||||
|
||||
- (BOOL)writeToPath:(NSString *)dstFilePath
|
||||
{
|
||||
OWSAssertDebug(self.dataValue);
|
||||
|
||||
NSData *dataCopy = self.dataValue;
|
||||
|
||||
BOOL success = [dataCopy writeToFile:dstFilePath atomically:YES];
|
||||
if (!success) {
|
||||
OWSLogDebug(@"Could not write data to disk: %@", dstFilePath);
|
||||
OWSFailDebug(@"Could not write data to disk.");
|
||||
return NO;
|
||||
} else {
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSString *)mimeType
|
||||
{
|
||||
return (self.fileExtension ? [MIMETypeUtil mimeTypeForFileExtension:self.fileExtension] : nil);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface DataSourcePath ()
|
||||
|
||||
@property (nonatomic) NSString *filePath;
|
||||
|
||||
// These properties are lazy-populated.
|
||||
@property (nonatomic) NSData *cachedData;
|
||||
@property (nonatomic) NSNumber *cachedDataLength;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation DataSourcePath
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
if (self.shouldDeleteOnDeallocation) {
|
||||
NSString *filePath = self.filePath;
|
||||
if (filePath) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSError *error;
|
||||
BOOL success = [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error];
|
||||
if (!success || error) {
|
||||
OWSCFailDebug(@"DataSourcePath could not delete file: %@, %@", filePath, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithURL:(NSURL *)fileUrl shouldDeleteOnDeallocation:(BOOL)shouldDeleteOnDeallocation
|
||||
{
|
||||
OWSAssertDebug(fileUrl);
|
||||
|
||||
if (!fileUrl || ![fileUrl isFileURL]) {
|
||||
return nil;
|
||||
}
|
||||
DataSourcePath *instance = [DataSourcePath new];
|
||||
instance.filePath = fileUrl.path;
|
||||
instance.shouldDeleteOnDeallocation = shouldDeleteOnDeallocation;
|
||||
return instance;
|
||||
}
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithFilePath:(NSString *)filePath
|
||||
shouldDeleteOnDeallocation:(BOOL)shouldDeleteOnDeallocation
|
||||
{
|
||||
OWSAssertDebug(filePath);
|
||||
|
||||
if (!filePath) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
DataSourcePath *instance = [DataSourcePath new];
|
||||
instance.filePath = filePath;
|
||||
instance.shouldDeleteOnDeallocation = shouldDeleteOnDeallocation;
|
||||
return instance;
|
||||
}
|
||||
|
||||
- (void)setFilePath:(NSString *)filePath
|
||||
{
|
||||
OWSAssertDebug(filePath.length > 0);
|
||||
|
||||
#ifdef DEBUG
|
||||
BOOL isDirectory;
|
||||
BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:filePath isDirectory:&isDirectory];
|
||||
OWSAssertDebug(exists);
|
||||
OWSAssertDebug(!isDirectory);
|
||||
#endif
|
||||
|
||||
_filePath = filePath;
|
||||
}
|
||||
|
||||
- (NSData *)data
|
||||
{
|
||||
OWSAssertDebug(self.filePath);
|
||||
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!self.cachedData) {
|
||||
self.cachedData = [NSData dataWithContentsOfFile:self.filePath];
|
||||
}
|
||||
if (!self.cachedData) {
|
||||
OWSLogDebug(@"Could not read data from disk: %@", self.filePath);
|
||||
OWSFailDebug(@"Could not read data from disk.");
|
||||
self.cachedData = [NSData new];
|
||||
}
|
||||
return self.cachedData;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSURL *)dataUrl
|
||||
{
|
||||
OWSAssertDebug(self.filePath);
|
||||
|
||||
return [NSURL fileURLWithPath:self.filePath];
|
||||
}
|
||||
|
||||
- (nullable NSString *)dataPath
|
||||
{
|
||||
OWSAssertDebug(self.filePath);
|
||||
|
||||
return self.filePath;
|
||||
}
|
||||
|
||||
- (nullable NSString *)dataPathIfOnDisk
|
||||
{
|
||||
OWSAssertDebug(self.filePath);
|
||||
|
||||
return self.filePath;
|
||||
}
|
||||
|
||||
- (NSUInteger)dataLength
|
||||
{
|
||||
OWSAssertDebug(self.filePath);
|
||||
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!self.cachedDataLength) {
|
||||
NSError *error;
|
||||
NSDictionary<NSFileAttributeKey, id> *_Nullable attributes =
|
||||
[[NSFileManager defaultManager] attributesOfItemAtPath:self.filePath error:&error];
|
||||
if (!attributes || error) {
|
||||
OWSLogDebug(@"Could not read data length from disk: %@, %@", self.filePath, error);
|
||||
OWSFailDebug(@"Could not read data length from disk with error: %@", error);
|
||||
self.cachedDataLength = @(0);
|
||||
} else {
|
||||
uint64_t fileSize = [attributes fileSize];
|
||||
self.cachedDataLength = @(fileSize);
|
||||
}
|
||||
}
|
||||
return [self.cachedDataLength unsignedIntegerValue];
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)writeToPath:(NSString *)dstFilePath
|
||||
{
|
||||
OWSAssertDebug(self.filePath);
|
||||
|
||||
NSError *error;
|
||||
BOOL success = [[NSFileManager defaultManager] copyItemAtPath:self.filePath toPath:dstFilePath error:&error];
|
||||
if (!success || error) {
|
||||
OWSLogDebug(@"Could not write data from path: %@, to path: %@, %@", self.filePath, dstFilePath, error);
|
||||
OWSFailDebug(@"Could not write data with error: %@", error);
|
||||
return NO;
|
||||
} else {
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSString *)mimeType
|
||||
{
|
||||
NSString *_Nullable fileExtension = self.filePath.pathExtension;
|
||||
return (fileExtension ? [MIMETypeUtil mimeTypeForFileExtension:fileExtension] : nil);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
// For some reason NSLog doesn't seem to work from SignalServiceKit. This is a workaround to still allow debugging from Obj-C.
|
||||
|
||||
@objc(LKLogger)
|
||||
public final class ObjC_Logger : NSObject {
|
||||
|
||||
private override init() { }
|
||||
|
||||
@objc public static func print(_ message: String) {
|
||||
Swift.print(message)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import CryptoSwift
|
||||
|
||||
enum DecryptionUtilities {
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
internal static func decrypt(_ ivAndCiphertext: Data, usingAESGCMWithSymmetricKey symmetricKey: Data) throws -> Data {
|
||||
if Thread.isMainThread {
|
||||
#if DEBUG
|
||||
preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.")
|
||||
#endif
|
||||
}
|
||||
let iv = ivAndCiphertext[0..<Int(EncryptionUtilities.ivSize)]
|
||||
let ciphertext = ivAndCiphertext[Int(EncryptionUtilities.ivSize)...]
|
||||
let gcm = GCM(iv: iv.bytes, tagLength: Int(EncryptionUtilities.gcmTagSize), mode: .combined)
|
||||
let aes = try AES(key: symmetricKey.bytes, blockMode: gcm, padding: .noPadding)
|
||||
return Data(try aes.decrypt(ciphertext.bytes))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
|
||||
@objc(LKDeviceLink)
|
||||
public final class DeviceLink : NSObject, NSCoding {
|
||||
@objc public let master: Device
|
||||
@objc public let slave: Device
|
||||
|
||||
@objc public var isAuthorized: Bool { return master.signature != nil }
|
||||
|
||||
@objc public var other: Device {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
return (userPublicKey == master.publicKey) ? slave : master
|
||||
}
|
||||
|
||||
// MARK: Device
|
||||
@objc(LKDevice)
|
||||
public final class Device : NSObject, NSCoding {
|
||||
@objc public let publicKey: String
|
||||
@objc public let signature: Data?
|
||||
|
||||
@objc public var displayName: String {
|
||||
if let customDisplayName = UserDefaults.standard[.slaveDeviceName(publicKey)] {
|
||||
return customDisplayName
|
||||
} else {
|
||||
return NSLocalizedString("Unnamed Device", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
@objc public init(publicKey: String, signature: Data? = nil) {
|
||||
self.publicKey = publicKey
|
||||
self.signature = signature
|
||||
}
|
||||
|
||||
@objc public init?(coder: NSCoder) {
|
||||
publicKey = coder.decodeObject(forKey: "hexEncodedPublicKey") as! String
|
||||
signature = coder.decodeObject(forKey: "signature") as! Data?
|
||||
}
|
||||
|
||||
@objc public func encode(with coder: NSCoder) {
|
||||
coder.encode(publicKey, forKey: "hexEncodedPublicKey")
|
||||
if let signature = signature { coder.encode(signature, forKey: "signature") }
|
||||
}
|
||||
|
||||
@objc public override func isEqual(_ other: Any?) -> Bool {
|
||||
guard let other = other as? Device else { return false }
|
||||
return publicKey == other.publicKey && signature == other.signature
|
||||
}
|
||||
|
||||
@objc override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:)
|
||||
var result = publicKey.hashValue
|
||||
if let signature = signature { result = result ^ signature.hashValue }
|
||||
return result
|
||||
}
|
||||
|
||||
@objc override public var description: String { return publicKey }
|
||||
}
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc public init(between master: Device, and slave: Device) {
|
||||
self.master = master
|
||||
self.slave = slave
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
@objc public init?(coder: NSCoder) {
|
||||
master = coder.decodeObject(forKey: "master") as! Device
|
||||
slave = coder.decodeObject(forKey: "slave") as! Device
|
||||
super.init()
|
||||
}
|
||||
|
||||
@objc public func encode(with coder: NSCoder) {
|
||||
coder.encode(master, forKey: "master")
|
||||
coder.encode(slave, forKey: "slave")
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
public func toJSON() -> JSON {
|
||||
var result = [ "primaryDevicePubKey" : master.publicKey, "secondaryDevicePubKey" : slave.publicKey ]
|
||||
if let masterSignature = master.signature { result["grantSignature"] = masterSignature.base64EncodedString() }
|
||||
if let slaveSignature = slave.signature { result["requestSignature"] = slaveSignature.base64EncodedString() }
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Equality
|
||||
@objc override public func isEqual(_ other: Any?) -> Bool {
|
||||
guard let other = other as? DeviceLink else { return false }
|
||||
return master == other.master && slave == other.slave
|
||||
}
|
||||
|
||||
// MARK: Hashing
|
||||
@objc override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:)
|
||||
return master.hash ^ slave.hash
|
||||
}
|
||||
|
||||
// MARK: Description
|
||||
@objc override public var description: String { return "\(master) - \(slave)" }
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
|
||||
@objc(LKDeviceLinkIndex)
|
||||
public final class DeviceLinkIndex : NSObject {
|
||||
|
||||
private static let name = "loki_device_link_index"
|
||||
|
||||
@objc public static let masterPublicKey = "master_hex_encoded_public_key"
|
||||
@objc public static let slavePublicKey = "slave_hex_encoded_public_key"
|
||||
@objc public static let isAuthorized = "is_authorized"
|
||||
|
||||
@objc public static let indexDatabaseExtension: YapDatabaseSecondaryIndex = {
|
||||
let setup = YapDatabaseSecondaryIndexSetup()
|
||||
setup.addColumn(masterPublicKey, with: .text)
|
||||
setup.addColumn(slavePublicKey, with: .text)
|
||||
setup.addColumn(isAuthorized, with: .integer)
|
||||
let handler = YapDatabaseSecondaryIndexHandler.withObjectBlock { _, map, _, _, object in
|
||||
guard let deviceLink = object as? DeviceLink else { return }
|
||||
map[masterPublicKey] = deviceLink.master.publicKey
|
||||
map[slavePublicKey] = deviceLink.slave.publicKey
|
||||
map[isAuthorized] = deviceLink.isAuthorized
|
||||
}
|
||||
return YapDatabaseSecondaryIndex(setup: setup, handler: handler)
|
||||
}()
|
||||
|
||||
@objc public static let databaseExtensionName: String = name
|
||||
|
||||
@objc public static func asyncRegisterDatabaseExtensions(_ storage: OWSStorage) {
|
||||
storage.asyncRegister(indexDatabaseExtension, withName: name)
|
||||
}
|
||||
|
||||
@objc public static func getDeviceLinks(for query: YapDatabaseQuery, in transaction: YapDatabaseReadTransaction) -> [DeviceLink] {
|
||||
guard let ext = transaction.ext(DeviceLinkIndex.name) as? YapDatabaseSecondaryIndexTransaction else {
|
||||
print("[Loki] Couldn't get device link index database extension.")
|
||||
return []
|
||||
}
|
||||
var result: [DeviceLink] = []
|
||||
ext.enumerateKeysAndObjects(matching: query) { _, _, object, _ in
|
||||
guard let deviceLink = object as? DeviceLink else { return }
|
||||
result.append(deviceLink)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import Curve25519Kit
|
||||
import PromiseKit
|
||||
|
||||
@objc (LKDeviceLinkingSession)
|
||||
public final class DeviceLinkingSession : NSObject {
|
||||
private let delegate: DeviceLinkingSessionDelegate
|
||||
@objc public var isListeningForLinkingRequests = false
|
||||
@objc public var isProcessingLinkingRequest = false
|
||||
@objc public var isListeningForLinkingAuthorization = false
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc public static var current: DeviceLinkingSession?
|
||||
|
||||
private init(delegate: DeviceLinkingSessionDelegate) {
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
public static func startListeningForLinkingRequests(with delegate: DeviceLinkingSessionDelegate) -> DeviceLinkingSession {
|
||||
let session = DeviceLinkingSession(delegate: delegate)
|
||||
session.isListeningForLinkingRequests = true
|
||||
DeviceLinkingSession.current = session
|
||||
return session
|
||||
}
|
||||
|
||||
public static func startListeningForLinkingAuthorization(with delegate: DeviceLinkingSessionDelegate) -> DeviceLinkingSession {
|
||||
let session = DeviceLinkingSession(delegate: delegate)
|
||||
session.isListeningForLinkingAuthorization = true
|
||||
DeviceLinkingSession.current = session
|
||||
return session
|
||||
}
|
||||
|
||||
@objc public func processLinkingRequest(from slavePublicKey: String, to masterPublicKey: String, with slaveSignature: Data) {
|
||||
guard isListeningForLinkingRequests, !isProcessingLinkingRequest, masterPublicKey == getUserHexEncodedPublicKey() else { return }
|
||||
let master = DeviceLink.Device(publicKey: masterPublicKey)
|
||||
let slave = DeviceLink.Device(publicKey: slavePublicKey, signature: slaveSignature)
|
||||
let deviceLink = DeviceLink(between: master, and: slave)
|
||||
guard DeviceLinkingUtilities.hasValidSlaveSignature(deviceLink) else { return }
|
||||
isProcessingLinkingRequest = true
|
||||
DispatchQueue.main.async {
|
||||
self.delegate.requestUserAuthorization(for: deviceLink)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func processLinkingAuthorization(from masterPublicKey: String, for slavePublicKey: String, masterSignature: Data, slaveSignature: Data) {
|
||||
guard isListeningForLinkingAuthorization, slavePublicKey == getUserHexEncodedPublicKey() else { return }
|
||||
let master = DeviceLink.Device(publicKey: masterPublicKey, signature: masterSignature)
|
||||
let slave = DeviceLink.Device(publicKey: slavePublicKey, signature: slaveSignature)
|
||||
let deviceLink = DeviceLink(between: master, and: slave)
|
||||
guard DeviceLinkingUtilities.hasValidSlaveSignature(deviceLink) && DeviceLinkingUtilities.hasValidMasterSignature(deviceLink) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.delegate.handleDeviceLinkAuthorized(deviceLink)
|
||||
}
|
||||
}
|
||||
|
||||
public func stopListeningForLinkingRequests() {
|
||||
DeviceLinkingSession.current = nil
|
||||
isListeningForLinkingRequests = false
|
||||
}
|
||||
|
||||
public func stopListeningForLinkingAuthorization() {
|
||||
DeviceLinkingSession.current = nil
|
||||
isListeningForLinkingAuthorization = false
|
||||
}
|
||||
|
||||
public func markLinkingRequestAsProcessed() {
|
||||
isProcessingLinkingRequest = false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
public protocol DeviceLinkingSessionDelegate {
|
||||
|
||||
func requestUserAuthorization(for deviceLink: DeviceLink)
|
||||
func handleDeviceLinkAuthorized(_ deviceLink: DeviceLink)
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
|
||||
@objc(LKDeviceLinkingUtilities)
|
||||
public final class DeviceLinkingUtilities : NSObject {
|
||||
private static var lastUnexpectedDeviceLinkRequestDate: Date? = nil
|
||||
|
||||
private override init() { }
|
||||
|
||||
@objc public static var shouldShowUnexpectedDeviceLinkRequestReceivedAlert: Bool {
|
||||
let now = Date()
|
||||
if let lastUnexpectedDeviceLinkRequestDate = lastUnexpectedDeviceLinkRequestDate {
|
||||
if now.timeIntervalSince(lastUnexpectedDeviceLinkRequestDate) < 30 { return false }
|
||||
}
|
||||
lastUnexpectedDeviceLinkRequestDate = now
|
||||
return true
|
||||
}
|
||||
|
||||
// When requesting a device link, the slave device signs the master device's public key. When authorizing
|
||||
// a device link, the master device signs the slave device's public key.
|
||||
|
||||
public static func getLinkingRequestMessage(for masterPublicKey: String) -> DeviceLinkMessage {
|
||||
let slaveKeyPair = OWSIdentityManager.shared().identityKeyPair()!
|
||||
let slavePublicKey = slaveKeyPair.hexEncodedPublicKey
|
||||
var kind = UInt8(LKDeviceLinkMessageKind.request.rawValue)
|
||||
let data = Data(hex: masterPublicKey) + Data(bytes: &kind, count: MemoryLayout.size(ofValue: kind))
|
||||
let slaveSignature = try! Ed25519.sign(data, with: slaveKeyPair)
|
||||
let thread = TSContactThread.getOrCreateThread(contactId: masterPublicKey)
|
||||
return DeviceLinkMessage(in: thread, masterPublicKey: masterPublicKey, slavePublicKey: slavePublicKey, masterSignature: nil, slaveSignature: slaveSignature)
|
||||
}
|
||||
|
||||
public static func getLinkingAuthorizationMessage(for deviceLink: DeviceLink) -> DeviceLinkMessage {
|
||||
let masterKeyPair = OWSIdentityManager.shared().identityKeyPair()!
|
||||
let masterPublicKey = masterKeyPair.hexEncodedPublicKey
|
||||
let slavePublicKey = deviceLink.slave.publicKey
|
||||
var kind = UInt8(LKDeviceLinkMessageKind.authorization.rawValue)
|
||||
let data = Data(hex: slavePublicKey) + Data(bytes: &kind, count: MemoryLayout.size(ofValue: kind))
|
||||
let masterSignature = try! Ed25519.sign(data, with: masterKeyPair)
|
||||
let slaveSignature = deviceLink.slave.signature!
|
||||
let thread = TSContactThread.getOrCreateThread(contactId: slavePublicKey)
|
||||
return DeviceLinkMessage(in: thread, masterPublicKey: masterPublicKey, slavePublicKey: slavePublicKey, masterSignature: masterSignature, slaveSignature: slaveSignature)
|
||||
}
|
||||
|
||||
public static func hasValidSlaveSignature(_ deviceLink: DeviceLink) -> Bool {
|
||||
guard let slaveSignature = deviceLink.slave.signature else { return false }
|
||||
let slavePublicKey = Data(hex: deviceLink.slave.publicKey.removing05PrefixIfNeeded())
|
||||
var kind = UInt8(LKDeviceLinkMessageKind.request.rawValue)
|
||||
let data = Data(hex: deviceLink.master.publicKey) + Data(bytes: &kind, count: MemoryLayout.size(ofValue: kind))
|
||||
return (try? Ed25519.verifySignature(slaveSignature, publicKey: slavePublicKey, data: data)) ?? false
|
||||
}
|
||||
|
||||
public static func hasValidMasterSignature(_ deviceLink: DeviceLink) -> Bool {
|
||||
guard let masterSignature = deviceLink.master.signature else { return false }
|
||||
let masterPublicKey = Data(hex: deviceLink.master.publicKey.removing05PrefixIfNeeded())
|
||||
var kind = UInt8(LKDeviceLinkMessageKind.authorization.rawValue)
|
||||
let data = Data(hex: deviceLink.slave.publicKey) + Data(bytes: &kind, count: MemoryLayout.size(ofValue: kind))
|
||||
return (try? Ed25519.verifySignature(masterSignature, publicKey: masterPublicKey, data: data)) ?? false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Curve25519Kit
|
||||
|
||||
@objc
|
||||
public enum DeviceNameError: Int, Error {
|
||||
case assertionFailure
|
||||
case invalidInput
|
||||
}
|
||||
|
||||
@objc
|
||||
public class DeviceNames: NSObject {
|
||||
// Never instantiate this class.
|
||||
private override init() {}
|
||||
|
||||
private static let syntheticIVLength: UInt = 16
|
||||
|
||||
@objc
|
||||
public class func encryptDeviceName(plaintext: String,
|
||||
identityKeyPair: ECKeyPair) throws -> Data {
|
||||
|
||||
guard let plaintextData = plaintext.data(using: .utf8) else {
|
||||
owsFailDebug("Could not convert text to UTF-8.")
|
||||
throw DeviceNameError.invalidInput
|
||||
}
|
||||
|
||||
let ephemeralKeyPair = Curve25519.generateKeyPair()!
|
||||
|
||||
// master_secret = ECDH(ephemeral_private, identity_public).
|
||||
let masterSecret: Data
|
||||
do {
|
||||
masterSecret = try Curve25519.generateSharedSecret(fromPublicKey: identityKeyPair.publicKey(), andKeyPair: ephemeralKeyPair)
|
||||
} catch {
|
||||
Logger.error("Could not generate shared secret: \(error)")
|
||||
throw error
|
||||
}
|
||||
|
||||
// synthetic_iv = HmacSHA256(key=HmacSHA256(key=master_secret, input=“auth”), input=plaintext)[0:16]
|
||||
let syntheticIV = try computeSyntheticIV(masterSecret: masterSecret,
|
||||
plaintextData: plaintextData)
|
||||
|
||||
// cipher_key = HmacSHA256(key=HmacSHA256(key=master_secret, “cipher”), input=synthetic_iv)
|
||||
let cipherKey = try computeCipherKey(masterSecret: masterSecret, syntheticIV: syntheticIV)
|
||||
|
||||
// cipher_text = AES-CTR(key=cipher_key, input=plaintext, counter=0)
|
||||
//
|
||||
// An all-zeros IV corresponds to an AES CTR counter of zero.
|
||||
let ciphertextIV = Data(count: Int(kAES256CTR_IVLength))
|
||||
guard let ciphertextKey = OWSAES256Key(data: cipherKey) else {
|
||||
owsFailDebug("Invalid cipher key.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard let ciphertext: AES256CTREncryptionResult = Cryptography.encryptAESCTR(plaintextData: plaintextData, initializationVector: ciphertextIV, key: ciphertextKey) else {
|
||||
owsFailDebug("Could not encrypt cipher text.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
|
||||
guard let keyData = (ephemeralKeyPair.publicKey() as NSData).prependKeyType() else {
|
||||
owsFailDebug("Could not prepend key type.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
let protoBuilder = SignalIOSProtoDeviceName.builder(ephemeralPublic: keyData as Data,
|
||||
syntheticIv: syntheticIV,
|
||||
ciphertext: ciphertext.ciphertext)
|
||||
let protoData = try protoBuilder.buildSerializedData()
|
||||
|
||||
// NOTE: This uses Data's foundation method rather than the NSData's SSK method.
|
||||
let protoDataBase64 = protoData.base64EncodedData()
|
||||
|
||||
return protoDataBase64
|
||||
}
|
||||
|
||||
private class func computeSyntheticIV(masterSecret: Data,
|
||||
plaintextData: Data) throws -> Data {
|
||||
// synthetic_iv = HmacSHA256(key=HmacSHA256(key=master_secret, input=“auth”), input=plaintext)[0:16]
|
||||
guard let syntheticIVInput = "auth".data(using: .utf8) else {
|
||||
owsFailDebug("Could not convert text to UTF-8.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard let syntheticIVKey = Cryptography.computeSHA256HMAC(syntheticIVInput, withHMACKey: masterSecret) else {
|
||||
owsFailDebug("Could not compute synthetic IV key.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard let syntheticIV = Cryptography.truncatedSHA256HMAC(plaintextData, withHMACKey: syntheticIVKey, truncation: syntheticIVLength) else {
|
||||
owsFailDebug("Could not compute synthetic IV.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
return syntheticIV
|
||||
}
|
||||
|
||||
private class func computeCipherKey(masterSecret: Data,
|
||||
syntheticIV: Data) throws -> Data {
|
||||
// cipher_key = HmacSHA256(key=HmacSHA256(key=master_secret, “cipher”), input=synthetic_iv)
|
||||
guard let cipherKeyInput = "cipher".data(using: .utf8) else {
|
||||
owsFailDebug("Could not convert text to UTF-8.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard let cipherKeyKey = Cryptography.computeSHA256HMAC(cipherKeyInput, withHMACKey: masterSecret) else {
|
||||
owsFailDebug("Could not compute cipher key key.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard let cipherKey = Cryptography.computeSHA256HMAC(syntheticIV, withHMACKey: cipherKeyKey) else {
|
||||
owsFailDebug("Could not compute cipher key.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
return cipherKey
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func decryptDeviceName(base64String: String,
|
||||
identityKeyPair: ECKeyPair) throws -> String {
|
||||
|
||||
guard let protoData = Data(base64Encoded: base64String) else {
|
||||
// Not necessarily an error; might be a legacy device name.
|
||||
throw DeviceNameError.invalidInput
|
||||
}
|
||||
|
||||
return try decryptDeviceName(protoData: protoData,
|
||||
identityKeyPair: identityKeyPair)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func decryptDeviceName(base64Data: Data,
|
||||
identityKeyPair: ECKeyPair) throws -> String {
|
||||
|
||||
guard let protoData = Data(base64Encoded: base64Data) else {
|
||||
// Not necessarily an error; might be a legacy device name.
|
||||
throw DeviceNameError.invalidInput
|
||||
}
|
||||
|
||||
return try decryptDeviceName(protoData: protoData,
|
||||
identityKeyPair: identityKeyPair)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func decryptDeviceName(protoData: Data,
|
||||
identityKeyPair: ECKeyPair) throws -> String {
|
||||
|
||||
let proto: SignalIOSProtoDeviceName
|
||||
do {
|
||||
proto = try SignalIOSProtoDeviceName.parseData(protoData)
|
||||
} catch {
|
||||
// Not necessarily an error; might be a legacy device name.
|
||||
Logger.error("failed to parse proto")
|
||||
throw DeviceNameError.invalidInput
|
||||
}
|
||||
|
||||
let ephemeralPublicData = proto.ephemeralPublic
|
||||
let receivedSyntheticIV = proto.syntheticIv
|
||||
let ciphertext = proto.ciphertext
|
||||
|
||||
let ephemeralPublic: Data
|
||||
do {
|
||||
ephemeralPublic = try (ephemeralPublicData as NSData).removeKeyType() as Data
|
||||
} catch {
|
||||
owsFailDebug("failed to remove key type")
|
||||
throw DeviceNameError.invalidInput
|
||||
}
|
||||
|
||||
guard ephemeralPublic.count > 0 else {
|
||||
owsFailDebug("Invalid ephemeral public.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard receivedSyntheticIV.count == syntheticIVLength else {
|
||||
owsFailDebug("Invalid synthetic IV.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard ciphertext.count > 0 else {
|
||||
owsFailDebug("Invalid cipher text.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
|
||||
// master_secret = ECDH(identity_private, ephemeral_public)
|
||||
let masterSecret: Data
|
||||
do {
|
||||
masterSecret = try Curve25519.generateSharedSecret(fromPublicKey: ephemeralPublic, andKeyPair: identityKeyPair)
|
||||
} catch {
|
||||
Logger.error("Could not generate shared secret: \(error)")
|
||||
throw error
|
||||
}
|
||||
|
||||
// cipher_key = HmacSHA256(key=HmacSHA256(key=master_secret, input=“cipher”), input=synthetic_iv)
|
||||
let cipherKey = try computeCipherKey(masterSecret: masterSecret, syntheticIV: receivedSyntheticIV)
|
||||
|
||||
// plaintext = AES-CTR(key=cipher_key, input=ciphertext, counter=0)
|
||||
//
|
||||
// An all-zeros IV corresponds to an AES CTR counter of zero.
|
||||
let ciphertextIV = Data(count: Int(kAES256CTR_IVLength))
|
||||
guard let ciphertextKey = OWSAES256Key(data: cipherKey) else {
|
||||
owsFailDebug("Invalid cipher key.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
guard let plaintextData = Cryptography.decryptAESCTR(cipherText: ciphertext, initializationVector: ciphertextIV, key: ciphertextKey) else {
|
||||
owsFailDebug("Could not decrypt cipher text.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
|
||||
// Verify the synthetic IV was correct.
|
||||
// constant_time_compare(HmacSHA256(key=HmacSHA256(key=master_secret, input=”auth”), input=plaintext)[0:16], synthetic_iv) == true
|
||||
let computedSyntheticIV = try computeSyntheticIV(masterSecret: masterSecret,
|
||||
plaintextData: plaintextData)
|
||||
guard receivedSyntheticIV.ows_constantTimeIsEqual(to: computedSyntheticIV) else {
|
||||
owsFailDebug("Synthetic IV did not match.")
|
||||
throw DeviceNameError.assertionFailure
|
||||
}
|
||||
|
||||
guard let plaintext = String(bytes: plaintextData, encoding: .utf8) else {
|
||||
owsFailDebug("Invalid plaintext.")
|
||||
throw DeviceNameError.invalidInput
|
||||
}
|
||||
|
||||
return plaintext
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
public extension Dictionary {
|
||||
|
||||
public var prettifiedDescription: String {
|
||||
return "[ " + map { key, value in
|
||||
let keyDescription = String(describing: key)
|
||||
let valueDescription = String(describing: value)
|
||||
let maxLength = 20
|
||||
let truncatedValueDescription = valueDescription.count > maxLength ? valueDescription.prefix(maxLength) + "..." : valueDescription
|
||||
return keyDescription + " : " + truncatedValueDescription
|
||||
}.joined(separator: ", ") + " ]"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
|
||||
@objc(LKUserDisplayNameUtilities)
|
||||
public final class UserDisplayNameUtilities : NSObject {
|
||||
|
||||
override private init() { }
|
||||
|
||||
private static var userPublicKey: String {
|
||||
return getUserHexEncodedPublicKey()
|
||||
}
|
||||
|
||||
private static var userDisplayName: String? {
|
||||
return SSKEnvironment.shared.profileManager.localProfileName()
|
||||
}
|
||||
|
||||
// MARK: Sessions
|
||||
@objc(getPrivateChatDisplayNameAvoidWriteTransaction:)
|
||||
public static func getPrivateChatDisplayNameAvoidingWriteTransaction(for publicKey: String) -> String? {
|
||||
if publicKey == userPublicKey {
|
||||
return userDisplayName
|
||||
} else {
|
||||
return SSKEnvironment.shared.profileManager.profileNameForRecipient(withID: publicKey, avoidingWriteTransaction: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public static func getPrivateChatDisplayName(for publicKey: String) -> String? {
|
||||
if publicKey == userPublicKey {
|
||||
return userDisplayName
|
||||
} else {
|
||||
return SSKEnvironment.shared.profileManager.profileNameForRecipient(withID: publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Open Groups
|
||||
@objc public static func getPublicChatDisplayName(for publicKey: String, in channel: UInt64, on server: String) -> String? {
|
||||
var result: String?
|
||||
OWSPrimaryStorage.shared().dbReadConnection.read { transaction in
|
||||
result = getPublicChatDisplayName(for: publicKey, in: channel, on: server, using: transaction)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@objc public static func getPublicChatDisplayName(for publicKey: String, in channel: UInt64, on server: String, using transaction: YapDatabaseReadTransaction) -> String? {
|
||||
if publicKey == userPublicKey {
|
||||
return userDisplayName
|
||||
} else {
|
||||
let collection = "\(server).\(channel)"
|
||||
return transaction.object(forKey: publicKey, inCollection: collection) as! String?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(LKGroupDisplayNameUtilities)
|
||||
public final class GroupDisplayNameUtilities : NSObject {
|
||||
|
||||
override private init() { }
|
||||
|
||||
// MARK: Closed Groups
|
||||
@objc public static func getDefaultDisplayName(for group: TSGroupThread) -> String {
|
||||
let members = group.groupModel.groupMemberIds
|
||||
let displayNames = members.map { hexEncodedPublicKey -> String in
|
||||
guard let displayName = UserDisplayNameUtilities.getPrivateChatDisplayName(for: hexEncodedPublicKey) else { return hexEncodedPublicKey }
|
||||
let regex = try! NSRegularExpression(pattern: ".* \\(\\.\\.\\.[0-9a-fA-F]*\\)")
|
||||
guard regex.hasMatch(input: displayName) else { return displayName }
|
||||
return String(displayName[displayName.startIndex..<(displayName.index(displayName.endIndex, offsetBy: -14))])
|
||||
}.sorted()
|
||||
return displayNames.joined(separator: ", ")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
@objc(LKDisplayNameUtilities2)
|
||||
public final class DisplayNameUtilities2 : NSObject {
|
||||
|
||||
private override init() { }
|
||||
|
||||
@objc(getDisplayNameForPublicKey:threadID:transaction:)
|
||||
public static func getDisplayName(for publicKey: String, inThreadWithID threadID: String, using transaction: YapDatabaseReadWriteTransaction) -> String {
|
||||
// Case 1: The public key belongs to the user themselves
|
||||
if publicKey == getUserHexEncodedPublicKey() { return SSKEnvironment.shared.profileManager.localProfileName() ?? publicKey }
|
||||
// Case 2: The given thread is an open group
|
||||
var openGroup: OpenGroup? = nil
|
||||
Storage.read { transaction in
|
||||
openGroup = LokiDatabaseUtilities.getPublicChat(for: threadID, in: transaction)
|
||||
}
|
||||
if let openGroup = openGroup {
|
||||
var displayName: String? = nil
|
||||
Storage.read { transaction in
|
||||
displayName = transaction.object(forKey: publicKey, inCollection: openGroup.id) as! String?
|
||||
}
|
||||
if let displayName = displayName { return displayName }
|
||||
}
|
||||
// Case 3: The given thread is a closed group or a one-to-one conversation
|
||||
// FIXME: The line below opens a write transaction under certain circumstances. We should move away from this and towards passing
|
||||
// a write transaction into this function.
|
||||
return SSKEnvironment.shared.profileManager.profileNameForRecipient(withID: publicKey) ?? publicKey
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
public extension ECKeyPair {
|
||||
|
||||
@objc public var hexEncodedPrivateKey: String {
|
||||
return privateKey().map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
@objc public var hexEncodedPublicKey: String {
|
||||
// Prefixing with "05" is necessary for what seems to be a sort of Signal public key versioning system
|
||||
return "05" + publicKey().map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
@objc public static func isValidHexEncodedPublicKey(candidate: String) -> Bool {
|
||||
// Check that it's a valid hexadecimal encoding
|
||||
let allowedCharacters = CharacterSet(charactersIn: "0123456789ABCDEF")
|
||||
guard candidate.uppercased().unicodeScalars.allSatisfy({ allowedCharacters.contains($0) }) else { return false }
|
||||
// Check that it has length 66 and a leading "05"
|
||||
guard candidate.count == 66 && candidate.hasPrefix("05") else { return false }
|
||||
// It appears to be a valid public key
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import CryptoSwift
|
||||
|
||||
internal typealias EncryptionResult = (ciphertext: Data, symmetricKey: Data, ephemeralPublicKey: Data)
|
||||
|
||||
enum EncryptionUtilities {
|
||||
internal static let gcmTagSize: UInt = 16
|
||||
internal static let ivSize: UInt = 12
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
internal static func encrypt(_ plaintext: Data, usingAESGCMWithSymmetricKey symmetricKey: Data) throws -> Data {
|
||||
if Thread.isMainThread {
|
||||
#if DEBUG
|
||||
preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.")
|
||||
#endif
|
||||
}
|
||||
let iv = Data.getSecureRandomData(ofSize: ivSize)!
|
||||
let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagSize), mode: .combined)
|
||||
let aes = try AES(key: symmetricKey.bytes, blockMode: gcm, padding: .noPadding)
|
||||
let ciphertext = try aes.encrypt(plaintext.bytes)
|
||||
return iv + Data(bytes: ciphertext)
|
||||
}
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
internal static func encrypt(_ plaintext: Data, using hexEncodedX25519PublicKey: String) throws -> EncryptionResult {
|
||||
if Thread.isMainThread {
|
||||
#if DEBUG
|
||||
preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.")
|
||||
#endif
|
||||
}
|
||||
let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey)
|
||||
let ephemeralKeyPair = Curve25519.generateKeyPair()!
|
||||
let ephemeralSharedSecret = Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, andKeyPair: ephemeralKeyPair)!
|
||||
let salt = "LOKI"
|
||||
let symmetricKey = try HMAC(key: salt.bytes, variant: .sha256).authenticate(ephemeralSharedSecret.bytes)
|
||||
let ciphertext = try encrypt(plaintext, usingAESGCMWithSymmetricKey: Data(bytes: symmetricKey))
|
||||
return (ciphertext, Data(bytes: symmetricKey), ephemeralKeyPair.publicKey())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// By centralizing feature flags here and documenting their rollout plan, it's easier to review
|
||||
/// which feature flags are in play.
|
||||
@objc(SSKFeatureFlags)
|
||||
public class FeatureFlags: NSObject {
|
||||
|
||||
@objc
|
||||
public static var conversationSearch: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
/// iOS has long supported sending oversized text as a sidecar attachment. The other clients
|
||||
/// simply displayed it as a text attachment. As part of the new cross-client long-text feature,
|
||||
/// we want to be able to display long text with attachments as well. Existing iOS clients
|
||||
/// won't properly display this, so we'll need to wait a while for rollout.
|
||||
/// The stakes aren't __too__ high, because legacy clients won't lose data - they just won't
|
||||
/// see the media attached to a long text message until they update their client.
|
||||
@objc
|
||||
public static var sendingMediaWithOversizeText: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
public static var useCustomPhotoCapture: Bool {
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
import PromiseKit
|
||||
|
||||
public extension FileServerAPI {
|
||||
|
||||
/// Gets the device links associated with the given hex encoded public key from the
|
||||
/// server and stores and returns the valid ones.
|
||||
///
|
||||
/// - Note: Deprecated.
|
||||
public static func getDeviceLinks(associatedWith hexEncodedPublicKey: String) -> Promise<Set<DeviceLink>> {
|
||||
return getDeviceLinks(associatedWith: [ hexEncodedPublicKey ])
|
||||
}
|
||||
|
||||
/// Gets the device links associated with the given hex encoded public keys from the
|
||||
/// server and stores and returns the valid ones.
|
||||
///
|
||||
/// - Note: Deprecated.
|
||||
public static func getDeviceLinks(associatedWith hexEncodedPublicKeys: Set<String>) -> Promise<Set<DeviceLink>> {
|
||||
return Promise.value([])
|
||||
/*
|
||||
let hexEncodedPublicKeysDescription = "[ \(hexEncodedPublicKeys.joined(separator: ", ")) ]"
|
||||
print("[Loki] Getting device links for: \(hexEncodedPublicKeysDescription).")
|
||||
return getAuthToken(for: server).then2 { token -> Promise<Set<DeviceLink>> in
|
||||
let queryParameters = "ids=\(hexEncodedPublicKeys.map { "@\($0)" }.joined(separator: ","))&include_user_annotations=1"
|
||||
let url = URL(string: "\(server)/users?\(queryParameters)")!
|
||||
let request = TSRequest(url: url)
|
||||
return OnionRequestAPI.sendOnionRequest(request, to: server, using: fileServerPublicKey).map2 { rawResponse -> Set<DeviceLink> in
|
||||
guard let data = rawResponse["data"] as? [JSON] else {
|
||||
print("[Loki] Couldn't parse device links for users: \(hexEncodedPublicKeys) from: \(rawResponse).")
|
||||
throw DotNetAPIError.parsingFailed
|
||||
}
|
||||
return Set(data.flatMap { data -> [DeviceLink] in
|
||||
guard let annotations = data["annotations"] as? [JSON], !annotations.isEmpty else { return [] }
|
||||
guard let annotation = annotations.first(where: { $0["type"] as? String == deviceLinkType }),
|
||||
let value = annotation["value"] as? JSON, let rawDeviceLinks = value["authorisations"] as? [JSON],
|
||||
let hexEncodedPublicKey = data["username"] as? String else {
|
||||
print("[Loki] Couldn't parse device links from: \(rawResponse).")
|
||||
return []
|
||||
}
|
||||
return rawDeviceLinks.compactMap { rawDeviceLink in
|
||||
guard let masterPublicKey = rawDeviceLink["primaryDevicePubKey"] as? String, let slavePublicKey = rawDeviceLink["secondaryDevicePubKey"] as? String,
|
||||
let base64EncodedSlaveSignature = rawDeviceLink["requestSignature"] as? String else {
|
||||
print("[Loki] Couldn't parse device link for user: \(hexEncodedPublicKey) from: \(rawResponse).")
|
||||
return nil
|
||||
}
|
||||
let masterSignature: Data?
|
||||
if let base64EncodedMasterSignature = rawDeviceLink["grantSignature"] as? String {
|
||||
masterSignature = Data(base64Encoded: base64EncodedMasterSignature)
|
||||
} else {
|
||||
masterSignature = nil
|
||||
}
|
||||
let slaveSignature = Data(base64Encoded: base64EncodedSlaveSignature)
|
||||
let master = DeviceLink.Device(publicKey: masterPublicKey, signature: masterSignature)
|
||||
let slave = DeviceLink.Device(publicKey: slavePublicKey, signature: slaveSignature)
|
||||
let deviceLink = DeviceLink(between: master, and: slave)
|
||||
if let masterSignature = masterSignature {
|
||||
guard DeviceLinkingUtilities.hasValidMasterSignature(deviceLink) else {
|
||||
print("[Loki] Received a device link with an invalid master signature.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
guard DeviceLinkingUtilities.hasValidSlaveSignature(deviceLink) else {
|
||||
print("[Loki] Received a device link with an invalid slave signature.")
|
||||
return nil
|
||||
}
|
||||
return deviceLink
|
||||
}
|
||||
})
|
||||
}.map2 { deviceLinks in
|
||||
storage.setDeviceLinks(deviceLinks)
|
||||
return deviceLinks
|
||||
}
|
||||
}.handlingInvalidAuthTokenIfNeeded(for: server)
|
||||
*/
|
||||
}
|
||||
|
||||
/// - Note: Deprecated.
|
||||
public static func setDeviceLinks(_ deviceLinks: Set<DeviceLink>) -> Promise<Void> {
|
||||
return Promise.value(())
|
||||
/*
|
||||
print("[Loki] Updating device links.")
|
||||
return getAuthToken(for: server).then2 { token -> Promise<Void> in
|
||||
let isMaster = deviceLinks.contains { $0.master.publicKey == getUserHexEncodedPublicKey() }
|
||||
let deviceLinksAsJSON = deviceLinks.map { $0.toJSON() }
|
||||
let value = !deviceLinksAsJSON.isEmpty ? [ "isPrimary" : isMaster ? 1 : 0, "authorisations" : deviceLinksAsJSON ] : nil
|
||||
let annotation: JSON = [ "type" : deviceLinkType, "value" : value ]
|
||||
let parameters: JSON = [ "annotations" : [ annotation ] ]
|
||||
let url = URL(string: "\(server)/users/me")!
|
||||
let request = TSRequest(url: url, method: "PATCH", parameters: parameters)
|
||||
request.allHTTPHeaderFields = [ "Content-Type" : "application/json", "Authorization" : "Bearer \(token)" ]
|
||||
return attempt(maxRetryCount: 8, recoveringOn: SnodeAPI.workQueue) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, using: fileServerPublicKey).map2 { _ in }
|
||||
}.handlingInvalidAuthTokenIfNeeded(for: server).recover2 { error in
|
||||
print("[Loki] Couldn't update device links due to error: \(error).")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/// Adds the given device link to the user's device mapping on the server.
|
||||
///
|
||||
/// - Note: Deprecated.
|
||||
public static func addDeviceLink(_ deviceLink: DeviceLink) -> Promise<Void> {
|
||||
return Promise.value(())
|
||||
/*
|
||||
var deviceLinks: Set<DeviceLink> = []
|
||||
storage.dbReadConnection.read { transaction in
|
||||
deviceLinks = storage.getDeviceLinks(for: getUserHexEncodedPublicKey(), in: transaction)
|
||||
}
|
||||
deviceLinks.insert(deviceLink)
|
||||
return setDeviceLinks(deviceLinks).map2 { _ in
|
||||
storage.addDeviceLink(deviceLink)
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/// Removes the given device link from the user's device mapping on the server.
|
||||
///
|
||||
/// - Note: Deprecated.
|
||||
public static func removeDeviceLink(_ deviceLink: DeviceLink) -> Promise<Void> {
|
||||
return Promise.value(())
|
||||
/*
|
||||
var deviceLinks: Set<DeviceLink> = []
|
||||
storage.dbReadConnection.read { transaction in
|
||||
deviceLinks = storage.getDeviceLinks(for: getUserHexEncodedPublicKey(), in: transaction)
|
||||
}
|
||||
deviceLinks.remove(deviceLink)
|
||||
return setDeviceLinks(deviceLinks).map2 { _ in
|
||||
storage.removeDeviceLink(deviceLink)
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@objc public extension FileServerAPI {
|
||||
|
||||
/// - Note: Deprecated.
|
||||
@objc(getDeviceLinksAssociatedWithHexEncodedPublicKey:)
|
||||
public static func objc_getDeviceLinks(associatedWith hexEncodedPublicKey: String) -> AnyPromise {
|
||||
return AnyPromise.from(getDeviceLinks(associatedWith: hexEncodedPublicKey))
|
||||
}
|
||||
|
||||
/// - Note: Deprecated.
|
||||
@objc(getDeviceLinksAssociatedWithHexEncodedPublicKeys:)
|
||||
public static func objc_getDeviceLinks(associatedWith hexEncodedPublicKeys: Set<String>) -> AnyPromise {
|
||||
return AnyPromise.from(getDeviceLinks(associatedWith: hexEncodedPublicKeys))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
// DO NOT EDIT.
|
||||
// swift-format-ignore-file
|
||||
//
|
||||
// Generated by the Swift generator plugin for the protocol buffer compiler.
|
||||
// Source: Fingerprint.proto
|
||||
//
|
||||
// For information on using the generated types, please see the documentation:
|
||||
// https://github.com/apple/swift-protobuf/
|
||||
|
||||
/// iOS - since we use a modern proto-compiler, we must specify
|
||||
/// the legacy proto format.
|
||||
|
||||
import Foundation
|
||||
import SwiftProtobuf
|
||||
|
||||
// If the compiler emits an error on this type, it is because this file
|
||||
// was generated by a version of the `protoc` Swift plug-in that is
|
||||
// incompatible with the version of SwiftProtobuf to which you are linking.
|
||||
// Please ensure that you are building against the same version of the API
|
||||
// that was used to generate this file.
|
||||
fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
|
||||
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
|
||||
typealias Version = _2
|
||||
}
|
||||
|
||||
struct FingerprintProtos_LogicalFingerprint {
|
||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||
// methods supported on all messages.
|
||||
|
||||
/// @required
|
||||
var identityData: Data {
|
||||
get {return _identityData ?? SwiftProtobuf.Internal.emptyData}
|
||||
set {_identityData = newValue}
|
||||
}
|
||||
/// Returns true if `identityData` has been explicitly set.
|
||||
var hasIdentityData: Bool {return self._identityData != nil}
|
||||
/// Clears the value of `identityData`. Subsequent reads from it will return its default value.
|
||||
mutating func clearIdentityData() {self._identityData = nil}
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
fileprivate var _identityData: Data? = nil
|
||||
}
|
||||
|
||||
struct FingerprintProtos_LogicalFingerprints {
|
||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||
// methods supported on all messages.
|
||||
|
||||
/// @required
|
||||
var version: UInt32 {
|
||||
get {return _version ?? 0}
|
||||
set {_version = newValue}
|
||||
}
|
||||
/// Returns true if `version` has been explicitly set.
|
||||
var hasVersion: Bool {return self._version != nil}
|
||||
/// Clears the value of `version`. Subsequent reads from it will return its default value.
|
||||
mutating func clearVersion() {self._version = nil}
|
||||
|
||||
/// @required
|
||||
var localFingerprint: FingerprintProtos_LogicalFingerprint {
|
||||
get {return _localFingerprint ?? FingerprintProtos_LogicalFingerprint()}
|
||||
set {_localFingerprint = newValue}
|
||||
}
|
||||
/// Returns true if `localFingerprint` has been explicitly set.
|
||||
var hasLocalFingerprint: Bool {return self._localFingerprint != nil}
|
||||
/// Clears the value of `localFingerprint`. Subsequent reads from it will return its default value.
|
||||
mutating func clearLocalFingerprint() {self._localFingerprint = nil}
|
||||
|
||||
/// @required
|
||||
var remoteFingerprint: FingerprintProtos_LogicalFingerprint {
|
||||
get {return _remoteFingerprint ?? FingerprintProtos_LogicalFingerprint()}
|
||||
set {_remoteFingerprint = newValue}
|
||||
}
|
||||
/// Returns true if `remoteFingerprint` has been explicitly set.
|
||||
var hasRemoteFingerprint: Bool {return self._remoteFingerprint != nil}
|
||||
/// Clears the value of `remoteFingerprint`. Subsequent reads from it will return its default value.
|
||||
mutating func clearRemoteFingerprint() {self._remoteFingerprint = nil}
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
fileprivate var _version: UInt32? = nil
|
||||
fileprivate var _localFingerprint: FingerprintProtos_LogicalFingerprint? = nil
|
||||
fileprivate var _remoteFingerprint: FingerprintProtos_LogicalFingerprint? = nil
|
||||
}
|
||||
|
||||
// MARK: - Code below here is support for the SwiftProtobuf runtime.
|
||||
|
||||
fileprivate let _protobuf_package = "FingerprintProtos"
|
||||
|
||||
extension FingerprintProtos_LogicalFingerprint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||
static let protoMessageName: String = _protobuf_package + ".LogicalFingerprint"
|
||||
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
1: .same(proto: "identityData"),
|
||||
]
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
switch fieldNumber {
|
||||
case 1: try decoder.decodeSingularBytesField(value: &self._identityData)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if let v = self._identityData {
|
||||
try visitor.visitSingularBytesField(value: v, fieldNumber: 1)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: FingerprintProtos_LogicalFingerprint, rhs: FingerprintProtos_LogicalFingerprint) -> Bool {
|
||||
if lhs._identityData != rhs._identityData {return false}
|
||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension FingerprintProtos_LogicalFingerprints: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||
static let protoMessageName: String = _protobuf_package + ".LogicalFingerprints"
|
||||
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
1: .same(proto: "version"),
|
||||
2: .same(proto: "localFingerprint"),
|
||||
3: .same(proto: "remoteFingerprint"),
|
||||
]
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
switch fieldNumber {
|
||||
case 1: try decoder.decodeSingularUInt32Field(value: &self._version)
|
||||
case 2: try decoder.decodeSingularMessageField(value: &self._localFingerprint)
|
||||
case 3: try decoder.decodeSingularMessageField(value: &self._remoteFingerprint)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if let v = self._version {
|
||||
try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1)
|
||||
}
|
||||
if let v = self._localFingerprint {
|
||||
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
|
||||
}
|
||||
if let v = self._remoteFingerprint {
|
||||
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: FingerprintProtos_LogicalFingerprints, rhs: FingerprintProtos_LogicalFingerprints) -> Bool {
|
||||
if lhs._version != rhs._version {return false}
|
||||
if lhs._localFingerprint != rhs._localFingerprint {return false}
|
||||
if lhs._remoteFingerprint != rhs._remoteFingerprint {return false}
|
||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// WARNING: This code is generated. Only edit within the markers.
|
||||
|
||||
public enum FingerprintProtoError: Error {
|
||||
case invalidProtobuf(description: String)
|
||||
}
|
||||
|
||||
// MARK: - FingerprintProtoLogicalFingerprint
|
||||
|
||||
@objc public class FingerprintProtoLogicalFingerprint: NSObject {
|
||||
|
||||
// MARK: - FingerprintProtoLogicalFingerprintBuilder
|
||||
|
||||
@objc public class func builder(identityData: Data) -> FingerprintProtoLogicalFingerprintBuilder {
|
||||
return FingerprintProtoLogicalFingerprintBuilder(identityData: identityData)
|
||||
}
|
||||
|
||||
// asBuilder() constructs a builder that reflects the proto's contents.
|
||||
@objc public func asBuilder() -> FingerprintProtoLogicalFingerprintBuilder {
|
||||
let builder = FingerprintProtoLogicalFingerprintBuilder(identityData: identityData)
|
||||
return builder
|
||||
}
|
||||
|
||||
@objc public class FingerprintProtoLogicalFingerprintBuilder: NSObject {
|
||||
|
||||
private var proto = FingerprintProtos_LogicalFingerprint()
|
||||
|
||||
@objc fileprivate override init() {}
|
||||
|
||||
@objc fileprivate init(identityData: Data) {
|
||||
super.init()
|
||||
|
||||
setIdentityData(identityData)
|
||||
}
|
||||
|
||||
@objc public func setIdentityData(_ valueParam: Data) {
|
||||
proto.identityData = valueParam
|
||||
}
|
||||
|
||||
@objc public func build() throws -> FingerprintProtoLogicalFingerprint {
|
||||
return try FingerprintProtoLogicalFingerprint.parseProto(proto)
|
||||
}
|
||||
|
||||
@objc public func buildSerializedData() throws -> Data {
|
||||
return try FingerprintProtoLogicalFingerprint.parseProto(proto).serializedData()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let proto: FingerprintProtos_LogicalFingerprint
|
||||
|
||||
@objc public let identityData: Data
|
||||
|
||||
private init(proto: FingerprintProtos_LogicalFingerprint,
|
||||
identityData: Data) {
|
||||
self.proto = proto
|
||||
self.identityData = identityData
|
||||
}
|
||||
|
||||
@objc
|
||||
public func serializedData() throws -> Data {
|
||||
return try self.proto.serializedData()
|
||||
}
|
||||
|
||||
@objc public class func parseData(_ serializedData: Data) throws -> FingerprintProtoLogicalFingerprint {
|
||||
let proto = try FingerprintProtos_LogicalFingerprint(serializedData: serializedData)
|
||||
return try parseProto(proto)
|
||||
}
|
||||
|
||||
fileprivate class func parseProto(_ proto: FingerprintProtos_LogicalFingerprint) throws -> FingerprintProtoLogicalFingerprint {
|
||||
guard proto.hasIdentityData else {
|
||||
throw FingerprintProtoError.invalidProtobuf(description: "\(logTag) missing required field: identityData")
|
||||
}
|
||||
let identityData = proto.identityData
|
||||
|
||||
// MARK: - Begin Validation Logic for FingerprintProtoLogicalFingerprint -
|
||||
|
||||
// MARK: - End Validation Logic for FingerprintProtoLogicalFingerprint -
|
||||
|
||||
let result = FingerprintProtoLogicalFingerprint(proto: proto,
|
||||
identityData: identityData)
|
||||
return result
|
||||
}
|
||||
|
||||
@objc public override var debugDescription: String {
|
||||
return "\(proto)"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
extension FingerprintProtoLogicalFingerprint {
|
||||
@objc public func serializedDataIgnoringErrors() -> Data? {
|
||||
return try! self.serializedData()
|
||||
}
|
||||
}
|
||||
|
||||
extension FingerprintProtoLogicalFingerprint.FingerprintProtoLogicalFingerprintBuilder {
|
||||
@objc public func buildIgnoringErrors() -> FingerprintProtoLogicalFingerprint? {
|
||||
return try! self.build()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// MARK: - FingerprintProtoLogicalFingerprints
|
||||
|
||||
@objc public class FingerprintProtoLogicalFingerprints: NSObject {
|
||||
|
||||
// MARK: - FingerprintProtoLogicalFingerprintsBuilder
|
||||
|
||||
@objc public class func builder(version: UInt32, localFingerprint: FingerprintProtoLogicalFingerprint, remoteFingerprint: FingerprintProtoLogicalFingerprint) -> FingerprintProtoLogicalFingerprintsBuilder {
|
||||
return FingerprintProtoLogicalFingerprintsBuilder(version: version, localFingerprint: localFingerprint, remoteFingerprint: remoteFingerprint)
|
||||
}
|
||||
|
||||
// asBuilder() constructs a builder that reflects the proto's contents.
|
||||
@objc public func asBuilder() -> FingerprintProtoLogicalFingerprintsBuilder {
|
||||
let builder = FingerprintProtoLogicalFingerprintsBuilder(version: version, localFingerprint: localFingerprint, remoteFingerprint: remoteFingerprint)
|
||||
return builder
|
||||
}
|
||||
|
||||
@objc public class FingerprintProtoLogicalFingerprintsBuilder: NSObject {
|
||||
|
||||
private var proto = FingerprintProtos_LogicalFingerprints()
|
||||
|
||||
@objc fileprivate override init() {}
|
||||
|
||||
@objc fileprivate init(version: UInt32, localFingerprint: FingerprintProtoLogicalFingerprint, remoteFingerprint: FingerprintProtoLogicalFingerprint) {
|
||||
super.init()
|
||||
|
||||
setVersion(version)
|
||||
setLocalFingerprint(localFingerprint)
|
||||
setRemoteFingerprint(remoteFingerprint)
|
||||
}
|
||||
|
||||
@objc public func setVersion(_ valueParam: UInt32) {
|
||||
proto.version = valueParam
|
||||
}
|
||||
|
||||
@objc public func setLocalFingerprint(_ valueParam: FingerprintProtoLogicalFingerprint) {
|
||||
proto.localFingerprint = valueParam.proto
|
||||
}
|
||||
|
||||
@objc public func setRemoteFingerprint(_ valueParam: FingerprintProtoLogicalFingerprint) {
|
||||
proto.remoteFingerprint = valueParam.proto
|
||||
}
|
||||
|
||||
@objc public func build() throws -> FingerprintProtoLogicalFingerprints {
|
||||
return try FingerprintProtoLogicalFingerprints.parseProto(proto)
|
||||
}
|
||||
|
||||
@objc public func buildSerializedData() throws -> Data {
|
||||
return try FingerprintProtoLogicalFingerprints.parseProto(proto).serializedData()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let proto: FingerprintProtos_LogicalFingerprints
|
||||
|
||||
@objc public let version: UInt32
|
||||
|
||||
@objc public let localFingerprint: FingerprintProtoLogicalFingerprint
|
||||
|
||||
@objc public let remoteFingerprint: FingerprintProtoLogicalFingerprint
|
||||
|
||||
private init(proto: FingerprintProtos_LogicalFingerprints,
|
||||
version: UInt32,
|
||||
localFingerprint: FingerprintProtoLogicalFingerprint,
|
||||
remoteFingerprint: FingerprintProtoLogicalFingerprint) {
|
||||
self.proto = proto
|
||||
self.version = version
|
||||
self.localFingerprint = localFingerprint
|
||||
self.remoteFingerprint = remoteFingerprint
|
||||
}
|
||||
|
||||
@objc
|
||||
public func serializedData() throws -> Data {
|
||||
return try self.proto.serializedData()
|
||||
}
|
||||
|
||||
@objc public class func parseData(_ serializedData: Data) throws -> FingerprintProtoLogicalFingerprints {
|
||||
let proto = try FingerprintProtos_LogicalFingerprints(serializedData: serializedData)
|
||||
return try parseProto(proto)
|
||||
}
|
||||
|
||||
fileprivate class func parseProto(_ proto: FingerprintProtos_LogicalFingerprints) throws -> FingerprintProtoLogicalFingerprints {
|
||||
guard proto.hasVersion else {
|
||||
throw FingerprintProtoError.invalidProtobuf(description: "\(logTag) missing required field: version")
|
||||
}
|
||||
let version = proto.version
|
||||
|
||||
guard proto.hasLocalFingerprint else {
|
||||
throw FingerprintProtoError.invalidProtobuf(description: "\(logTag) missing required field: localFingerprint")
|
||||
}
|
||||
let localFingerprint = try FingerprintProtoLogicalFingerprint.parseProto(proto.localFingerprint)
|
||||
|
||||
guard proto.hasRemoteFingerprint else {
|
||||
throw FingerprintProtoError.invalidProtobuf(description: "\(logTag) missing required field: remoteFingerprint")
|
||||
}
|
||||
let remoteFingerprint = try FingerprintProtoLogicalFingerprint.parseProto(proto.remoteFingerprint)
|
||||
|
||||
// MARK: - Begin Validation Logic for FingerprintProtoLogicalFingerprints -
|
||||
|
||||
// MARK: - End Validation Logic for FingerprintProtoLogicalFingerprints -
|
||||
|
||||
let result = FingerprintProtoLogicalFingerprints(proto: proto,
|
||||
version: version,
|
||||
localFingerprint: localFingerprint,
|
||||
remoteFingerprint: remoteFingerprint)
|
||||
return result
|
||||
}
|
||||
|
||||
@objc public override var debugDescription: String {
|
||||
return "\(proto)"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
extension FingerprintProtoLogicalFingerprints {
|
||||
@objc public func serializedDataIgnoringErrors() -> Data? {
|
||||
return try! self.serializedData()
|
||||
}
|
||||
}
|
||||
|
||||
extension FingerprintProtoLogicalFingerprints.FingerprintProtoLogicalFingerprintsBuilder {
|
||||
@objc public func buildIgnoringErrors() -> FingerprintProtoLogicalFingerprints? {
|
||||
return try! self.build()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,274 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import libPhoneNumber_iOS
|
||||
|
||||
// Create a searchable index for objects of type T
|
||||
public class SearchIndexer<T> {
|
||||
|
||||
private let indexBlock: (T, YapDatabaseReadTransaction) -> String
|
||||
|
||||
public init(indexBlock: @escaping (T, YapDatabaseReadTransaction) -> String) {
|
||||
self.indexBlock = indexBlock
|
||||
}
|
||||
|
||||
public func index(_ item: T, transaction: YapDatabaseReadTransaction) -> String {
|
||||
return normalize(indexingText: indexBlock(item, transaction))
|
||||
}
|
||||
|
||||
private func normalize(indexingText: String) -> String {
|
||||
return FullTextSearchFinder.normalize(text: indexingText)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public class FullTextSearchFinder: NSObject {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private static var tsAccountManager: TSAccountManager {
|
||||
return TSAccountManager.sharedInstance()
|
||||
}
|
||||
|
||||
// MARK: - Querying
|
||||
|
||||
// We want to match by prefix for "search as you type" functionality.
|
||||
// SQLite does not support suffix or contains matches.
|
||||
public class func query(searchText: String) -> String {
|
||||
// 1. Normalize the search text.
|
||||
//
|
||||
// TODO: We could arguably convert to lowercase since the search
|
||||
// is case-insensitive.
|
||||
let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText)
|
||||
|
||||
// 2. Split the non-numeric text into query terms (or tokens).
|
||||
let nonNumericText = String(String.UnicodeScalarView(normalizedSearchText.unicodeScalars.lazy.map {
|
||||
if CharacterSet.decimalDigits.contains($0) {
|
||||
return " "
|
||||
} else {
|
||||
return $0
|
||||
}
|
||||
}))
|
||||
var queryTerms = nonNumericText.split(separator: " ")
|
||||
|
||||
// 3. Add an additional numeric-only query term.
|
||||
let digitsOnlyScalars = normalizedSearchText.unicodeScalars.lazy.filter {
|
||||
CharacterSet.decimalDigits.contains($0)
|
||||
}
|
||||
let digitsOnly: Substring = Substring(String(String.UnicodeScalarView(digitsOnlyScalars)))
|
||||
queryTerms.append(digitsOnly)
|
||||
|
||||
// 4. De-duplicate and sort query terms.
|
||||
// Duplicate terms are redundant.
|
||||
// Sorting terms makes the output of this method deterministic and easier to test,
|
||||
// and the order won't affect the search results.
|
||||
queryTerms = Array(Set(queryTerms)).sorted()
|
||||
|
||||
// 5. Filter the query terms.
|
||||
let filteredQueryTerms = queryTerms.filter {
|
||||
// Ignore empty terms.
|
||||
$0.count > 0
|
||||
}.map {
|
||||
// Allow partial match of each term.
|
||||
//
|
||||
// Note that we use double-quotes to enclose each search term.
|
||||
// Quoted search terms can include a few more characters than
|
||||
// "bareword" (non-quoted) search terms. This shouldn't matter,
|
||||
// since we're filtering all of the affected characters, but
|
||||
// quoting protects us from any bugs in that logic.
|
||||
"\"\($0)\"*"
|
||||
}
|
||||
|
||||
// 6. Join terms into query string.
|
||||
let query = filteredQueryTerms.joined(separator: " ")
|
||||
return query
|
||||
}
|
||||
|
||||
public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) {
|
||||
guard let ext: YapDatabaseFullTextSearchTransaction = ext(transaction: transaction) else {
|
||||
owsFailDebug("ext was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
let query = FullTextSearchFinder.query(searchText: searchText)
|
||||
|
||||
Logger.verbose("query: \(query)")
|
||||
|
||||
let maxSearchResults = 500
|
||||
var searchResultCount = 0
|
||||
let snippetOptions = YapDatabaseFullTextSearchSnippetOptions()
|
||||
snippetOptions.startMatchText = ""
|
||||
snippetOptions.endMatchText = ""
|
||||
ext.enumerateKeysAndObjects(matching: query, with: snippetOptions) { (snippet: String, _: String, _: String, object: Any, stop: UnsafeMutablePointer<ObjCBool>) in
|
||||
guard searchResultCount < maxSearchResults else {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
searchResultCount += 1
|
||||
|
||||
block(object, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Normalization
|
||||
|
||||
fileprivate static var charactersToRemove: CharacterSet = {
|
||||
// * We want to strip punctuation - and our definition of "punctuation"
|
||||
// is broader than `CharacterSet.punctuationCharacters`.
|
||||
// * FTS should be robust to (i.e. ignore) illegal and control characters,
|
||||
// but it's safer if we filter them ourselves as well.
|
||||
var charactersToFilter = CharacterSet.punctuationCharacters
|
||||
charactersToFilter.formUnion(CharacterSet.illegalCharacters)
|
||||
charactersToFilter.formUnion(CharacterSet.controlCharacters)
|
||||
|
||||
// We want to strip all ASCII characters except:
|
||||
// * Letters a-z, A-Z
|
||||
// * Numerals 0-9
|
||||
// * Whitespace
|
||||
var asciiToFilter = CharacterSet(charactersIn: UnicodeScalar(0x0)!..<UnicodeScalar(0x80)!)
|
||||
assert(!asciiToFilter.contains(UnicodeScalar(0x80)!))
|
||||
asciiToFilter.subtract(CharacterSet.alphanumerics)
|
||||
asciiToFilter.subtract(CharacterSet.whitespacesAndNewlines)
|
||||
charactersToFilter.formUnion(asciiToFilter)
|
||||
|
||||
return charactersToFilter
|
||||
}()
|
||||
|
||||
// This is a hot method, especially while running large migrations.
|
||||
// Changes to it should go through a profiler to make sure large migrations
|
||||
// aren't adversely affected.
|
||||
@objc
|
||||
public class func normalize(text: String) -> String {
|
||||
// 1. Filter out invalid characters.
|
||||
let filtered = text.removeCharacters(characterSet: charactersToRemove)
|
||||
|
||||
// 2. Simplify whitespace.
|
||||
let simplified = filtered.replaceCharacters(characterSet: .whitespacesAndNewlines,
|
||||
replacement: " ")
|
||||
|
||||
// 3. Strip leading & trailing whitespace last, since we may replace
|
||||
// filtered characters with whitespace.
|
||||
return simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - Index Building
|
||||
|
||||
private class var contactsManager: ContactsManagerProtocol {
|
||||
return SSKEnvironment.shared.contactsManager
|
||||
}
|
||||
|
||||
private static let groupThreadIndexer: SearchIndexer<TSGroupThread> = SearchIndexer { (groupThread: TSGroupThread, transaction: YapDatabaseReadTransaction) in
|
||||
let groupName = groupThread.groupModel.groupName ?? ""
|
||||
|
||||
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in
|
||||
recipientIndexer.index(recipientId, transaction: transaction)
|
||||
}.joined(separator: " ")
|
||||
|
||||
return "\(groupName) \(memberStrings)"
|
||||
}
|
||||
|
||||
private static let contactThreadIndexer: SearchIndexer<TSContactThread> = SearchIndexer { (contactThread: TSContactThread, transaction: YapDatabaseReadTransaction) in
|
||||
let recipientId = contactThread.contactIdentifier()
|
||||
var result = recipientIndexer.index(recipientId, transaction: transaction)
|
||||
|
||||
if IsNoteToSelfEnabled(),
|
||||
let localNumber = tsAccountManager.storedOrCachedLocalNumber(transaction),
|
||||
localNumber == recipientId {
|
||||
|
||||
let noteToSelfLabel = NSLocalizedString("NOTE_TO_SELF", comment: "Label for 1:1 conversation with yourself.")
|
||||
result += " \(noteToSelfLabel)"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static let recipientIndexer: SearchIndexer<String> = SearchIndexer { (recipientId: String, transaction: YapDatabaseReadTransaction) in
|
||||
let displayName = contactsManager.displayName(forPhoneIdentifier: recipientId, transaction: transaction)
|
||||
|
||||
let nationalNumber: String = { (recipientId: String) -> String in
|
||||
|
||||
guard let phoneNumber = PhoneNumber(fromE164: recipientId) else {
|
||||
owsFailDebug("unexpected unparseable recipientId: \(recipientId)")
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let digitScalars = phoneNumber.nationalNumber?.unicodeScalars.filter({ CharacterSet.decimalDigits.contains($0) }) else {
|
||||
owsFailDebug("unexpected unparseable recipientId: \(recipientId)")
|
||||
return ""
|
||||
}
|
||||
|
||||
return String(String.UnicodeScalarView(digitScalars))
|
||||
}(recipientId)
|
||||
|
||||
return "\(recipientId) \(nationalNumber) \(displayName)"
|
||||
}
|
||||
|
||||
private static let messageIndexer: SearchIndexer<TSMessage> = SearchIndexer { (message: TSMessage, transaction: YapDatabaseReadTransaction) in
|
||||
if let bodyText = message.bodyText(with: transaction) {
|
||||
return bodyText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private class func indexContent(object: Any, transaction: YapDatabaseReadTransaction) -> String? {
|
||||
if let groupThread = object as? TSGroupThread {
|
||||
return self.groupThreadIndexer.index(groupThread, transaction: transaction)
|
||||
} else if let contactThread = object as? TSContactThread {
|
||||
guard contactThread.shouldThreadBeVisible && !contactThread.isSlaveThread else {
|
||||
// If we've never sent/received a message in a TSContactThread,
|
||||
// then we want it to appear in the "Other Contacts" section rather
|
||||
// than in the "Conversations" section.
|
||||
return nil
|
||||
}
|
||||
return self.contactThreadIndexer.index(contactThread, transaction: transaction)
|
||||
} else if let message = object as? TSMessage {
|
||||
return self.messageIndexer.index(message, transaction: transaction)
|
||||
} else if let signalAccount = object as? SignalAccount {
|
||||
return self.recipientIndexer.index(signalAccount.recipientId, transaction: transaction)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extension Registration
|
||||
|
||||
private static let dbExtensionName: String = "FullTextSearchFinderExtension"
|
||||
|
||||
private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? {
|
||||
return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func asyncRegisterDatabaseExtension(storage: OWSStorage) {
|
||||
storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName)
|
||||
}
|
||||
|
||||
// Only for testing.
|
||||
public class func ensureDatabaseExtensionRegistered(storage: OWSStorage) {
|
||||
guard storage.registeredExtension(dbExtensionName) == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
storage.register(dbExtensionConfig, withName: dbExtensionName)
|
||||
}
|
||||
|
||||
private class var dbExtensionConfig: YapDatabaseFullTextSearch {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let contentColumnName = "content"
|
||||
|
||||
let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (transaction: YapDatabaseReadTransaction, dict: NSMutableDictionary, _: String, _: String, object: Any) in
|
||||
dict[contentColumnName] = indexContent(object: object, transaction: transaction)
|
||||
}
|
||||
|
||||
// update search index on contact name changes?
|
||||
|
||||
return YapDatabaseFullTextSearch(columnNames: ["content"],
|
||||
options: nil,
|
||||
handler: handler,
|
||||
ftsVersion: YapDatabaseFullTextSearchFTS5Version,
|
||||
versionTag: "1")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NSArray (FunctionalUtil)
|
||||
|
||||
/// Returns true when any of the items in this array match the given predicate.
|
||||
- (bool)any:(int (^)(id item))predicate;
|
||||
|
||||
/// Returns true when all of the items in this array match the given predicate.
|
||||
- (bool)all:(int (^)(id item))predicate;
|
||||
|
||||
/// Returns an array of all the results of passing items from this array through the given projection function.
|
||||
- (NSArray *)map:(id (^)(id item))projection;
|
||||
|
||||
/// Returns an array of all the results of passing items from this array through the given projection function.
|
||||
- (NSArray *)filter:(int (^)(id item))predicate;
|
||||
|
||||
- (NSDictionary *)groupBy:(id (^)(id value))keySelector;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "FunctionalUtil.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface FUBadArgument : NSException
|
||||
|
||||
+ (FUBadArgument *) new:(NSString *)reason;
|
||||
+ (void)raise:(NSString *)message;
|
||||
|
||||
@end
|
||||
|
||||
@implementation FUBadArgument
|
||||
|
||||
+ (FUBadArgument *) new:(NSString *)reason {
|
||||
return [[FUBadArgument alloc] initWithName:@"Invalid Argument" reason:reason userInfo:nil];
|
||||
}
|
||||
+ (void)raise:(NSString *)message {
|
||||
[FUBadArgument raise:@"Invalid Argument" format:@"%@", message];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#define tskit_require(expr) \
|
||||
if (!(expr)) { \
|
||||
NSString *reason = \
|
||||
[NSString stringWithFormat:@"require %@ (in %s at line %d)", (@ #expr), __FILE__, __LINE__]; \
|
||||
OWSLogError(@"%@", reason); \
|
||||
[FUBadArgument raise:reason]; \
|
||||
};
|
||||
|
||||
|
||||
@implementation NSArray (FunctionalUtil)
|
||||
- (bool)any:(int (^)(id item))predicate {
|
||||
tskit_require(predicate != nil);
|
||||
for (id e in self) {
|
||||
if (predicate(e)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
- (bool)all:(int (^)(id item))predicate {
|
||||
tskit_require(predicate != nil);
|
||||
for (id e in self) {
|
||||
if (!predicate(e)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
- (NSArray *)map:(id (^)(id item))projection {
|
||||
tskit_require(projection != nil);
|
||||
|
||||
NSMutableArray *r = [NSMutableArray arrayWithCapacity:self.count];
|
||||
for (id e in self) {
|
||||
[r addObject:projection(e)];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
- (NSArray *)filter:(int (^)(id item))predicate {
|
||||
tskit_require(predicate != nil);
|
||||
|
||||
NSMutableArray *r = [NSMutableArray array];
|
||||
for (id e in self) {
|
||||
if (predicate(e)) {
|
||||
[r addObject:e];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
- (NSDictionary *)groupBy:(id (^)(id value))keySelector {
|
||||
tskit_require(keySelector != nil);
|
||||
|
||||
NSMutableDictionary *result = [NSMutableDictionary dictionary];
|
||||
|
||||
for (id item in self) {
|
||||
id key = keySelector(item);
|
||||
|
||||
NSMutableArray *group = result[key];
|
||||
if (group == nil) {
|
||||
group = [NSMutableArray array];
|
||||
result[key] = group;
|
||||
}
|
||||
[group addObject:item];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
public func getUserHexEncodedPublicKey() -> String {
|
||||
if let keyPair = OWSIdentityManager.shared().identityKeyPair() { // Can be nil under some circumstances
|
||||
return keyPair.hexEncodedPublicKey
|
||||
}
|
||||
return ""
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
public enum GroupUtilities {
|
||||
|
||||
public static func getClosedGroupMembers(_ closedGroup: TSGroupThread) -> [String] {
|
||||
var result: [String]!
|
||||
OWSPrimaryStorage.shared().dbReadConnection.read { transaction in
|
||||
result = getClosedGroupMembers(closedGroup, with: transaction)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public static func getClosedGroupMembers(_ closedGroup: TSGroupThread, with transaction: YapDatabaseReadTransaction) -> [String] {
|
||||
return closedGroup.groupModel.groupMemberIds.filter { member in
|
||||
OWSPrimaryStorage.shared().getMasterHexEncodedPublicKey(for: member, in: transaction) == nil // Don't show slave devices
|
||||
}
|
||||
}
|
||||
|
||||
public static func getClosedGroupMemberCount(_ closedGroup: TSGroupThread) -> Int {
|
||||
return getClosedGroupMembers(closedGroup).count
|
||||
}
|
||||
|
||||
public static func getClosedGroupMemberCount(_ closedGroup: TSGroupThread, with transaction: YapDatabaseReadTransaction) -> Int {
|
||||
return getClosedGroupMembers(closedGroup, with: transaction).count
|
||||
}
|
||||
}
|
|
@ -0,0 +1,411 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// JobQueue - A durable work queue
|
||||
///
|
||||
/// When work needs to be done, add it to the JobQueue.
|
||||
/// The JobQueue will persist a JobRecord to be sure that work can be restarted if the app is killed.
|
||||
///
|
||||
/// The actual work, is carried out in a DurableOperation which the JobQueue spins off, based on the contents
|
||||
/// of a JobRecord.
|
||||
///
|
||||
/// For a concrete example, take message sending.
|
||||
/// Add an outgoing message to the MessageSenderJobQueue, which first records a SSKMessageSenderJobRecord.
|
||||
/// The MessageSenderJobQueue then uses that SSKMessageSenderJobRecord to create a MessageSenderOperation which
|
||||
/// takes care of the actual business of communicating with the service.
|
||||
///
|
||||
/// DurableOperations are retryable - via their `remainingRetries` logic. However, if the operation encounters
|
||||
/// an error where `error.isRetryable == false`, the operation will fail, regardless of available retries.
|
||||
|
||||
public extension Error {
|
||||
var isRetryable: Bool {
|
||||
return (self as NSError).isRetryable
|
||||
}
|
||||
}
|
||||
|
||||
extension SSKJobRecordStatus: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .ready:
|
||||
return "ready"
|
||||
case .unknown:
|
||||
return "unknown"
|
||||
case .running:
|
||||
return "running"
|
||||
case .permanentlyFailed:
|
||||
return "permanentlyFailed"
|
||||
case .obsolete:
|
||||
return "obsolete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum JobError: Error {
|
||||
case assertionFailure(description: String)
|
||||
case obsolete(description: String)
|
||||
}
|
||||
|
||||
public protocol DurableOperation: class {
|
||||
associatedtype JobRecordType: SSKJobRecord
|
||||
associatedtype DurableOperationDelegateType: DurableOperationDelegate
|
||||
|
||||
var jobRecord: JobRecordType { get }
|
||||
var durableOperationDelegate: DurableOperationDelegateType? { get set }
|
||||
var operation: OWSOperation { get }
|
||||
var remainingRetries: UInt { get set }
|
||||
}
|
||||
|
||||
public protocol DurableOperationDelegate: class {
|
||||
associatedtype DurableOperationType: DurableOperation
|
||||
|
||||
func durableOperationDidSucceed(_ operation: DurableOperationType, transaction: YapDatabaseReadWriteTransaction)
|
||||
func durableOperation(_ operation: DurableOperationType, didReportError: Error, transaction: YapDatabaseReadWriteTransaction)
|
||||
func durableOperation(_ operation: DurableOperationType, didFailWithError error: Error, transaction: YapDatabaseReadWriteTransaction)
|
||||
}
|
||||
|
||||
public protocol JobQueue: DurableOperationDelegate {
|
||||
typealias DurableOperationDelegateType = Self
|
||||
typealias JobRecordType = DurableOperationType.JobRecordType
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var dbConnection: YapDatabaseConnection { get }
|
||||
var finder: JobRecordFinder { get }
|
||||
|
||||
// MARK: Default Implementations
|
||||
|
||||
func add(jobRecord: JobRecordType, transaction: YapDatabaseReadWriteTransaction)
|
||||
func restartOldJobs()
|
||||
func workStep()
|
||||
func defaultSetup()
|
||||
|
||||
// MARK: Required
|
||||
|
||||
var runningOperations: [DurableOperationType] { get set }
|
||||
var jobRecordLabel: String { get }
|
||||
|
||||
var isSetup: Bool { get set }
|
||||
func setup()
|
||||
func didMarkAsReady(oldJobRecord: JobRecordType, transaction: YapDatabaseReadWriteTransaction)
|
||||
|
||||
func operationQueue(jobRecord: JobRecordType) -> OperationQueue
|
||||
func buildOperation(jobRecord: JobRecordType, transaction: YapDatabaseReadTransaction) throws -> DurableOperationType
|
||||
|
||||
/// When `requiresInternet` is true, we immediately run any jobs which are waiting for retry upon detecting Reachability.
|
||||
///
|
||||
/// Because `Reachability` isn't 100% reliable, the jobs will be attempted regardless of what we think our current Reachability is.
|
||||
/// However, because these jobs will likely fail many times in succession, their `retryInterval` could be quite long by the time we
|
||||
/// are back online.
|
||||
var requiresInternet: Bool { get }
|
||||
static var maxRetries: UInt { get }
|
||||
}
|
||||
|
||||
public extension JobQueue {
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var dbConnection: YapDatabaseConnection {
|
||||
return SSKEnvironment.shared.primaryStorage.dbReadWriteConnection
|
||||
}
|
||||
|
||||
var finder: JobRecordFinder {
|
||||
return JobRecordFinder()
|
||||
}
|
||||
|
||||
var reachabilityManager: SSKReachabilityManager {
|
||||
return SSKEnvironment.shared.reachabilityManager
|
||||
}
|
||||
|
||||
// MARK:
|
||||
|
||||
func add(jobRecord: JobRecordType, transaction: YapDatabaseReadWriteTransaction) {
|
||||
assert(jobRecord.status == .ready)
|
||||
|
||||
jobRecord.save(with: transaction)
|
||||
|
||||
transaction.addCompletionQueue(DispatchQueue.global()) {
|
||||
self.startWorkWhenAppIsReady()
|
||||
}
|
||||
}
|
||||
|
||||
func startWorkWhenAppIsReady() {
|
||||
guard !CurrentAppContext().isRunningTests else {
|
||||
DispatchQueue.global().async {
|
||||
self.workStep()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
||||
DispatchQueue.global().async {
|
||||
self.workStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func workStep() {
|
||||
Logger.debug("")
|
||||
|
||||
guard isSetup else {
|
||||
if !CurrentAppContext().isRunningTests {
|
||||
owsFailDebug("not setup")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Storage.writeSync { transaction in
|
||||
guard let nextJob: JobRecordType = self.finder.getNextReady(label: self.jobRecordLabel, transaction: transaction) as? JobRecordType else {
|
||||
Logger.verbose("nothing left to enqueue")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try nextJob.saveAsStarted(transaction: transaction)
|
||||
|
||||
let operationQueue = self.operationQueue(jobRecord: nextJob)
|
||||
let durableOperation = try self.buildOperation(jobRecord: nextJob, transaction: transaction)
|
||||
|
||||
durableOperation.durableOperationDelegate = self as? Self.DurableOperationType.DurableOperationDelegateType
|
||||
assert(durableOperation.durableOperationDelegate != nil)
|
||||
|
||||
let remainingRetries = self.remainingRetries(durableOperation: durableOperation)
|
||||
durableOperation.remainingRetries = remainingRetries
|
||||
|
||||
self.runningOperations.append(durableOperation)
|
||||
|
||||
Logger.debug("adding operation: \(durableOperation) with remainingRetries: \(remainingRetries)")
|
||||
operationQueue.addOperation(durableOperation.operation)
|
||||
} catch JobError.assertionFailure(let description) {
|
||||
owsFailDebug("assertion failure: \(description)")
|
||||
nextJob.saveAsPermanentlyFailed(transaction: transaction)
|
||||
} catch JobError.obsolete(let description) {
|
||||
// TODO is this even worthwhile to have obsolete state? Should we just delete the task outright?
|
||||
Logger.verbose("marking obsolete task as such. description:\(description)")
|
||||
nextJob.saveAsObsolete(transaction: transaction)
|
||||
} catch {
|
||||
owsFailDebug("unexpected error")
|
||||
}
|
||||
|
||||
DispatchQueue.global().async {
|
||||
self.workStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func restartOldJobs() {
|
||||
Storage.writeSync { transaction in
|
||||
let runningRecords = self.finder.allRecords(label: self.jobRecordLabel, status: .running, transaction: transaction)
|
||||
Logger.info("marking old `running` JobRecords as ready: \(runningRecords.count)")
|
||||
for record in runningRecords {
|
||||
guard let jobRecord = record as? JobRecordType else {
|
||||
owsFailDebug("unexpectred jobRecord: \(record)")
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try jobRecord.saveRunningAsReady(transaction: transaction)
|
||||
self.didMarkAsReady(oldJobRecord: jobRecord, transaction: transaction)
|
||||
} catch {
|
||||
owsFailDebug("failed to mark old running records as ready error: \(error)")
|
||||
jobRecord.saveAsPermanentlyFailed(transaction: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unless you need special handling, your setup method can be as simple as
|
||||
///
|
||||
/// func setup() {
|
||||
/// defaultSetup()
|
||||
/// }
|
||||
///
|
||||
/// So you might ask, why not just rename this method to `setup`? Because
|
||||
/// `setup` is called from objc, and default implementations from a protocol
|
||||
/// cannot be marked as @objc.
|
||||
func defaultSetup() {
|
||||
guard !isSetup else {
|
||||
owsFailDebug("already ready already")
|
||||
return
|
||||
}
|
||||
self.restartOldJobs()
|
||||
|
||||
if self.requiresInternet {
|
||||
NotificationCenter.default.addObserver(forName: .reachabilityChanged,
|
||||
object: self.reachabilityManager.observationContext,
|
||||
queue: nil) { _ in
|
||||
|
||||
if self.reachabilityManager.isReachable {
|
||||
Logger.verbose("isReachable: true")
|
||||
self.becameReachable()
|
||||
} else {
|
||||
Logger.verbose("isReachable: false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.isSetup = true
|
||||
|
||||
self.startWorkWhenAppIsReady()
|
||||
}
|
||||
|
||||
func remainingRetries(durableOperation: DurableOperationType) -> UInt {
|
||||
let maxRetries = type(of: self).maxRetries
|
||||
let failureCount = durableOperation.jobRecord.failureCount
|
||||
|
||||
guard maxRetries > failureCount else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return maxRetries - failureCount
|
||||
}
|
||||
|
||||
func becameReachable() {
|
||||
guard requiresInternet else {
|
||||
owsFailDebug("should only be called if `requiresInternet` is true")
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.runAnyQueuedRetry()
|
||||
}
|
||||
|
||||
func runAnyQueuedRetry() -> DurableOperationType? {
|
||||
guard let runningDurableOperation = self.runningOperations.first else {
|
||||
return nil
|
||||
}
|
||||
runningDurableOperation.operation.runAnyQueuedRetry()
|
||||
|
||||
return runningDurableOperation
|
||||
}
|
||||
|
||||
// MARK: DurableOperationDelegate
|
||||
|
||||
func durableOperationDidSucceed(_ operation: DurableOperationType, transaction: YapDatabaseReadWriteTransaction) {
|
||||
self.runningOperations = self.runningOperations.filter { $0 !== operation }
|
||||
operation.jobRecord.remove(with: transaction)
|
||||
}
|
||||
|
||||
func durableOperation(_ operation: DurableOperationType, didReportError: Error, transaction: YapDatabaseReadWriteTransaction) {
|
||||
do {
|
||||
try operation.jobRecord.addFailure(transaction: transaction)
|
||||
} catch {
|
||||
owsFailDebug("error while addingFailure: \(error)")
|
||||
operation.jobRecord.saveAsPermanentlyFailed(transaction: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
func durableOperation(_ operation: DurableOperationType, didFailWithError error: Error, transaction: YapDatabaseReadWriteTransaction) {
|
||||
self.runningOperations = self.runningOperations.filter { $0 !== operation }
|
||||
operation.jobRecord.saveAsPermanentlyFailed(transaction: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(SSKJobRecordFinder)
|
||||
public class JobRecordFinder: NSObject, Finder {
|
||||
|
||||
typealias ExtensionType = YapDatabaseSecondaryIndex
|
||||
typealias TransactionType = YapDatabaseSecondaryIndexTransaction
|
||||
|
||||
enum JobRecordField: String {
|
||||
case status, label, sortId
|
||||
}
|
||||
|
||||
func getNextReady(label: String, transaction: YapDatabaseReadTransaction) -> SSKJobRecord? {
|
||||
var result: SSKJobRecord?
|
||||
self.enumerateJobRecords(label: label, status: .ready, transaction: transaction) { jobRecord, stopPointer in
|
||||
result = jobRecord
|
||||
stopPointer.pointee = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func allRecords(label: String, status: SSKJobRecordStatus, transaction: YapDatabaseReadTransaction) -> [SSKJobRecord] {
|
||||
var result: [SSKJobRecord] = []
|
||||
self.enumerateJobRecords(label: label, status: status, transaction: transaction) { jobRecord, _ in
|
||||
result.append(jobRecord)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func enumerateJobRecords(label: String, status: SSKJobRecordStatus, transaction: YapDatabaseReadTransaction, block: @escaping (SSKJobRecord, UnsafeMutablePointer<ObjCBool>) -> Void) {
|
||||
let queryFormat = String(format: "WHERE %@ = ? AND %@ = ? ORDER BY %@", JobRecordField.status.rawValue, JobRecordField.label.rawValue, JobRecordField.sortId.rawValue)
|
||||
let query = YapDatabaseQuery(string: queryFormat, parameters: [status.rawValue, label])
|
||||
|
||||
self.ext(transaction: transaction).enumerateKeysAndObjects(matching: query) { _, _, object, stopPointer in
|
||||
guard let jobRecord = object as? SSKJobRecord else {
|
||||
owsFailDebug("expecting jobRecord but found: \(object)")
|
||||
return
|
||||
}
|
||||
block(jobRecord, stopPointer)
|
||||
}
|
||||
}
|
||||
|
||||
static var dbExtensionName: String {
|
||||
return "SecondaryIndexJobRecord"
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func asyncRegisterDatabaseExtensionObjC(storage: OWSStorage) {
|
||||
asyncRegisterDatabaseExtension(storage: storage)
|
||||
}
|
||||
|
||||
static var dbExtensionConfig: YapDatabaseSecondaryIndex {
|
||||
let setup = YapDatabaseSecondaryIndexSetup()
|
||||
setup.addColumn(JobRecordField.sortId.rawValue, with: .integer)
|
||||
setup.addColumn(JobRecordField.status.rawValue, with: .integer)
|
||||
setup.addColumn(JobRecordField.label.rawValue, with: .text)
|
||||
|
||||
let block: YapDatabaseSecondaryIndexWithObjectBlock = { transaction, dict, collection, key, object in
|
||||
guard let jobRecord = object as? SSKJobRecord else {
|
||||
return
|
||||
}
|
||||
|
||||
dict[JobRecordField.sortId.rawValue] = jobRecord.sortId
|
||||
dict[JobRecordField.status.rawValue] = jobRecord.status.rawValue
|
||||
dict[JobRecordField.label.rawValue] = jobRecord.label
|
||||
}
|
||||
|
||||
let handler = YapDatabaseSecondaryIndexHandler.withObjectBlock(block)
|
||||
|
||||
let options = YapDatabaseSecondaryIndexOptions()
|
||||
let whitelist = YapWhitelistBlacklist(whitelist: Set([SSKJobRecord.collection()]))
|
||||
options.allowedCollections = whitelist
|
||||
|
||||
return YapDatabaseSecondaryIndex.init(setup: setup, handler: handler, versionTag: "2", options: options)
|
||||
}
|
||||
}
|
||||
|
||||
protocol Finder {
|
||||
associatedtype ExtensionType: YapDatabaseExtension
|
||||
associatedtype TransactionType: YapDatabaseExtensionTransaction
|
||||
|
||||
static var dbExtensionName: String { get }
|
||||
static var dbExtensionConfig: ExtensionType { get }
|
||||
|
||||
func ext(transaction: YapDatabaseReadTransaction) -> TransactionType
|
||||
|
||||
static func asyncRegisterDatabaseExtension(storage: OWSStorage)
|
||||
static func testingOnly_ensureDatabaseExtensionRegistered(storage: OWSStorage)
|
||||
}
|
||||
|
||||
extension Finder {
|
||||
|
||||
func ext(transaction: YapDatabaseReadTransaction) -> TransactionType {
|
||||
return transaction.ext(type(of: self).dbExtensionName) as! TransactionType
|
||||
}
|
||||
|
||||
static func asyncRegisterDatabaseExtension(storage: OWSStorage) {
|
||||
storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName)
|
||||
}
|
||||
|
||||
// Only for testing.
|
||||
static func testingOnly_ensureDatabaseExtensionRegistered(storage: OWSStorage) {
|
||||
guard storage.registeredExtension(dbExtensionName) == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
storage.register(dbExtensionConfig, withName: dbExtensionName)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
#import "TSOutgoingMessage.h"
|
||||
|
||||
typedef NS_ENUM(NSUInteger, LKDeviceLinkMessageKind) {
|
||||
LKDeviceLinkMessageKindRequest = 1,
|
||||
LKDeviceLinkMessageKindAuthorization = 2,
|
||||
};
|
||||
|
||||
NS_SWIFT_NAME(DeviceLinkMessage)
|
||||
@interface LKDeviceLinkMessage : TSOutgoingMessage
|
||||
|
||||
@property (nonatomic, readonly) NSString *masterPublicKey;
|
||||
@property (nonatomic, readonly) NSString *slavePublicKey;
|
||||
@property (nonatomic, readonly) NSData *masterSignature; // nil for device linking requests
|
||||
@property (nonatomic, readonly) NSData *slaveSignature;
|
||||
@property (nonatomic, readonly) LKDeviceLinkMessageKind kind;
|
||||
|
||||
- (instancetype)initInThread:(TSThread *)thread masterPublicKey:(NSString *)masterHexEncodedPublicKey slavePublicKey:(NSString *)slaveHexEncodedPublicKey masterSignature:(NSData * _Nullable)masterSignature slaveSignature:(NSData *)slaveSignature;
|
||||
|
||||
@end
|
|
@ -0,0 +1,89 @@
|
|||
#import "LKDeviceLinkMessage.h"
|
||||
#import "OWSIdentityManager.h"
|
||||
#import "OWSPrimaryStorage+Loki.h"
|
||||
#import "ProfileManagerProtocol.h"
|
||||
#import "ProtoUtils.h"
|
||||
#import "SSKEnvironment.h"
|
||||
#import "SignalRecipient.h"
|
||||
#import <SessionProtocolKit/NSData+OWS.h>
|
||||
#import <SessionProtocolKit/NSDate+OWS.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
|
||||
@implementation LKDeviceLinkMessage
|
||||
|
||||
#pragma mark Convenience
|
||||
- (LKDeviceLinkMessageKind)kind {
|
||||
if (self.masterSignature != nil) {
|
||||
return LKDeviceLinkMessageKindAuthorization;
|
||||
} else {
|
||||
return LKDeviceLinkMessageKindRequest;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark Initialization
|
||||
- (instancetype)initInThread:(TSThread *)thread masterPublicKey:(NSString *)masterHexEncodedPublicKey slavePublicKey:(NSString *)slaveHexEncodedPublicKey masterSignature:(NSData * _Nullable)masterSignature slaveSignature:(NSData *)slaveSignature {
|
||||
self = [self initOutgoingMessageWithTimestamp:NSDate.ows_millisecondTimeStamp inThread:thread messageBody:@"" attachmentIds:[NSMutableArray<NSString *> new]
|
||||
expiresInSeconds:0 expireStartedAt:0 isVoiceMessage:NO groupMetaMessage:TSGroupMetaMessageUnspecified quotedMessage:nil contactShare:nil linkPreview:nil];
|
||||
if (self) {
|
||||
_masterPublicKey = masterHexEncodedPublicKey;
|
||||
_slavePublicKey = slaveHexEncodedPublicKey;
|
||||
_masterSignature = masterSignature;
|
||||
_slaveSignature = slaveSignature;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark Building
|
||||
- (nullable id)prepareCustomContentBuilder:(SignalRecipient *)recipient {
|
||||
SSKProtoContentBuilder *contentBuilder = [super prepareCustomContentBuilder:recipient];
|
||||
NSError *error;
|
||||
if (self.kind == LKDeviceLinkMessageKindRequest) {
|
||||
// The slave device attaches a pre key bundle with the request it sends so that a
|
||||
// session can be established with the master device.
|
||||
PreKeyBundle *preKeyBundle = [OWSPrimaryStorage.sharedManager generatePreKeyBundleForContact:recipient.recipientId];
|
||||
SSKProtoPrekeyBundleMessageBuilder *preKeyBundleMessageBuilder = [SSKProtoPrekeyBundleMessage builderFromPreKeyBundle:preKeyBundle];
|
||||
SSKProtoPrekeyBundleMessage *preKeyBundleMessage = [preKeyBundleMessageBuilder buildAndReturnError:&error];
|
||||
if (error || preKeyBundleMessage == nil) {
|
||||
OWSFailDebug(@"Failed to build pre key bundle message for: %@ due to error: %@.", recipient.recipientId, error);
|
||||
return nil;
|
||||
} else {
|
||||
[contentBuilder setPrekeyBundleMessage:preKeyBundleMessage];
|
||||
}
|
||||
} else {
|
||||
// The master device attaches its display name and profile picture URL to the device link
|
||||
// authorization message so that the slave device is in sync with these things as soon
|
||||
// as possible.
|
||||
id<ProfileManagerProtocol> profileManager = SSKEnvironment.shared.profileManager;
|
||||
NSString *displayName = profileManager.localProfileName;
|
||||
NSString *profilePictureURL = profileManager.profilePictureURL;
|
||||
SSKProtoDataMessageLokiProfileBuilder *profileBuilder = [SSKProtoDataMessageLokiProfile builder];
|
||||
[profileBuilder setDisplayName:displayName];
|
||||
[profileBuilder setProfilePicture:profilePictureURL ?: @""];
|
||||
SSKProtoDataMessageBuilder *messageBuilder = [SSKProtoDataMessage builder];
|
||||
[messageBuilder setProfile:[profileBuilder buildAndReturnError:nil]];
|
||||
[ProtoUtils addLocalProfileKeyToDataMessageBuilder:messageBuilder];
|
||||
[contentBuilder setDataMessage:[messageBuilder buildIgnoringErrors]];
|
||||
}
|
||||
// Build the device link message
|
||||
SSKProtoLokiDeviceLinkMessageBuilder *deviceLinkMessageBuilder = [SSKProtoLokiDeviceLinkMessage builder];
|
||||
[deviceLinkMessageBuilder setMasterPublicKey:self.masterPublicKey];
|
||||
[deviceLinkMessageBuilder setSlavePublicKey:self.slavePublicKey];
|
||||
if (self.masterSignature != nil) { [deviceLinkMessageBuilder setMasterSignature:self.masterSignature]; }
|
||||
[deviceLinkMessageBuilder setSlaveSignature:self.slaveSignature];
|
||||
SSKProtoLokiDeviceLinkMessage *deviceLinkMessage = [deviceLinkMessageBuilder buildAndReturnError:&error];
|
||||
if (error || deviceLinkMessage == nil) {
|
||||
OWSFailDebug(@"Failed to build device link message for: %@ due to error: %@.", recipient.recipientId, error);
|
||||
return nil;
|
||||
} else {
|
||||
[contentBuilder setLokiDeviceLinkMessage:deviceLinkMessage];
|
||||
}
|
||||
// Return
|
||||
return contentBuilder;
|
||||
}
|
||||
|
||||
#pragma mark Settings
|
||||
- (uint)ttl { return (uint)[LKTTLUtilities getTTLFor:LKMessageTypeLinkDevice]; }
|
||||
- (BOOL)shouldSyncTranscript { return NO; }
|
||||
- (BOOL)shouldBeSaved { return NO; }
|
||||
|
||||
@end
|
|
@ -0,0 +1,26 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface LKGroupUtilities : NSObject
|
||||
|
||||
+(NSString *)getEncodedOpenGroupID:(NSString *)groupID;
|
||||
+(NSData *)getEncodedOpenGroupIDAsData:(NSString *)groupID;
|
||||
|
||||
+(NSString *)getEncodedRSSFeedID:(NSString *)groupID;
|
||||
+(NSData *)getEncodedRSSFeedIDAsData:(NSString *)groupID;
|
||||
|
||||
+(NSString *)getEncodedClosedGroupID:(NSString *)groupID;
|
||||
+(NSData *)getEncodedClosedGroupIDAsData:(NSString *)groupID;
|
||||
|
||||
+(NSString *)getEncodedMMSGroupID:(NSString *)groupID;
|
||||
+(NSData *)getEncodedMMSGroupIDAsData:(NSString *)groupID;
|
||||
|
||||
+(NSString *)getEncodedGroupID:(NSData *)groupID;
|
||||
|
||||
+(NSString *)getDecodedGroupID:(NSData *)groupID;
|
||||
+(NSData *)getDecodedGroupIDAsData:(NSData *)groupID;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,77 @@
|
|||
#import "LKGroupUtilities.h"
|
||||
#import <SessionProtocolKit/SessionProtocolKit.h>
|
||||
|
||||
@implementation LKGroupUtilities
|
||||
|
||||
#define ClosedGroupPrefix @"__textsecure_group__!" // a.k.a. private group chat
|
||||
#define MMSGroupPrefix @"__signal_mms_group__!"
|
||||
#define OpenGroupPrefix @"__loki_public_chat_group__!" // a.k.a. public group chat
|
||||
#define RSSFeedPrefix @"__loki_rss_feed_group__!"
|
||||
|
||||
+(NSString *)getEncodedOpenGroupID:(NSString *)groupID
|
||||
{
|
||||
return [OpenGroupPrefix stringByAppendingString:groupID];
|
||||
}
|
||||
|
||||
+(NSData *)getEncodedOpenGroupIDAsData:(NSString *)groupID
|
||||
{
|
||||
return [[OpenGroupPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
+(NSString *)getEncodedRSSFeedID:(NSString *)groupID
|
||||
{
|
||||
return [RSSFeedPrefix stringByAppendingString:groupID];
|
||||
}
|
||||
|
||||
+(NSData *)getEncodedRSSFeedIDAsData:(NSString *)groupID
|
||||
{
|
||||
return [[RSSFeedPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
+(NSString *)getEncodedClosedGroupID:(NSString *)groupID
|
||||
{
|
||||
return [ClosedGroupPrefix stringByAppendingString:groupID];
|
||||
}
|
||||
|
||||
+(NSData *)getEncodedClosedGroupIDAsData:(NSString *)groupID
|
||||
{
|
||||
return [[ClosedGroupPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
+(NSString *)getEncodedMMSGroupID:(NSString *)groupID
|
||||
{
|
||||
return [MMSGroupPrefix stringByAppendingString:groupID];
|
||||
}
|
||||
|
||||
+(NSData *)getEncodedMMSGroupIDAsData:(NSString *)groupID
|
||||
{
|
||||
return [[MMSGroupPrefix stringByAppendingString:groupID] dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
+(NSString *)getEncodedGroupID: (NSData *)groupID
|
||||
{
|
||||
return [[NSString alloc] initWithData:groupID encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
+(NSString *)getDecodedGroupID:(NSData *)groupID
|
||||
{
|
||||
OWSAssertDebug(groupID.length > 0);
|
||||
NSString *encodedGroupID = [[NSString alloc] initWithData:groupID encoding:NSUTF8StringEncoding];
|
||||
if ([encodedGroupID componentsSeparatedByString:@"!"].count > 1) {
|
||||
return [encodedGroupID componentsSeparatedByString:@"!"][1];
|
||||
}
|
||||
return [encodedGroupID componentsSeparatedByString:@"!"][0];
|
||||
}
|
||||
|
||||
+(NSData *)getDecodedGroupIDAsData:(NSData *)groupID
|
||||
{
|
||||
OWSAssertDebug(groupID.length > 0);
|
||||
NSString *encodedGroupID = [[NSString alloc]initWithData:groupID encoding:NSUTF8StringEncoding];
|
||||
NSString *decodedGroupID = [encodedGroupID componentsSeparatedByString:@"!"][0];
|
||||
if ([encodedGroupID componentsSeparatedByString:@"!"].count > 1) {
|
||||
decodedGroupID = [encodedGroupID componentsSeparatedByString:@"!"][1];
|
||||
}
|
||||
return [decodedGroupID dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
@end
|
|
@ -0,0 +1,14 @@
|
|||
#import "OWSOutgoingSyncMessage.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NS_SWIFT_NAME(SyncOpenGroupsMessage)
|
||||
@interface LKSyncOpenGroupsMessage : OWSOutgoingSyncMessage
|
||||
|
||||
- (instancetype)init NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,43 @@
|
|||
#import "LKSyncOpenGroupsMessage.h"
|
||||
#import "OWSPrimaryStorage.h"
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@implementation LKSyncOpenGroupsMessage
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
return [super init];
|
||||
}
|
||||
|
||||
- (nullable instancetype)initWithCoder:(NSCoder *)coder
|
||||
{
|
||||
return [super initWithCoder:coder];
|
||||
}
|
||||
|
||||
- (nullable SSKProtoSyncMessageBuilder *)syncMessageBuilder
|
||||
{
|
||||
NSError *error;
|
||||
NSMutableArray<SSKProtoSyncMessageOpenGroupDetails *> *openGroupSyncMessages = @[].mutableCopy;
|
||||
__block NSDictionary<NSString *, SNOpenGroup *> *openGroups;
|
||||
[OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
|
||||
openGroups = [LKDatabaseUtilities getAllPublicChats:transaction];
|
||||
}];
|
||||
for (SNOpenGroup *openGroup in openGroups.allValues) {
|
||||
SSKProtoSyncMessageOpenGroupDetailsBuilder *openGroupSyncMessageBuilder = [SSKProtoSyncMessageOpenGroupDetails builderWithUrl:openGroup.server channelID:openGroup.channel];
|
||||
SSKProtoSyncMessageOpenGroupDetails *_Nullable openGroupSyncMessage = [openGroupSyncMessageBuilder buildAndReturnError:&error];
|
||||
if (error || !openGroupSyncMessage) {
|
||||
OWSFailDebug(@"Couldn't build protobuf due to error: %@.", error);
|
||||
return nil;
|
||||
}
|
||||
[openGroupSyncMessages addObject:openGroupSyncMessage];
|
||||
}
|
||||
SSKProtoSyncMessageBuilder *syncMessageBuilder = [SSKProtoSyncMessage builder];
|
||||
[syncMessageBuilder setOpenGroups:openGroupSyncMessages];
|
||||
return syncMessageBuilder;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,12 @@
|
|||
#import "TSOutgoingMessage.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NS_SWIFT_NAME(UnlinkDeviceMessage)
|
||||
@interface LKUnlinkDeviceMessage : TSOutgoingMessage
|
||||
|
||||
- (instancetype)initWithThread:(TSThread *)thread;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,27 @@
|
|||
#import "LKUnlinkDeviceMessage.h"
|
||||
#import <SessionProtocolKit/NSDate+OWS.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
|
||||
@implementation LKUnlinkDeviceMessage
|
||||
|
||||
#pragma mark Initialization
|
||||
- (instancetype)initWithThread:(TSThread *)thread {
|
||||
return [self initOutgoingMessageWithTimestamp:NSDate.ows_millisecondTimeStamp inThread:thread messageBody:@"" attachmentIds:[NSMutableArray<NSString *> new]
|
||||
expiresInSeconds:0 expireStartedAt:0 isVoiceMessage:NO groupMetaMessage:TSGroupMetaMessageUnspecified quotedMessage:nil contactShare:nil linkPreview:nil];
|
||||
}
|
||||
|
||||
#pragma mark Building
|
||||
- (nullable id)dataMessageBuilder
|
||||
{
|
||||
SSKProtoDataMessageBuilder *builder = super.dataMessageBuilder;
|
||||
if (builder == nil) { return nil; }
|
||||
[builder setFlags:SSKProtoDataMessageFlagsUnlinkDevice];
|
||||
return builder;
|
||||
}
|
||||
|
||||
#pragma mark Settings
|
||||
- (uint)ttl { return (uint)[LKTTLUtilities getTTLFor:LKMessageTypeUnlinkDevice]; }
|
||||
- (BOOL)shouldSyncTranscript { return NO; }
|
||||
- (BOOL)shouldBeSaved { return NO; }
|
||||
|
||||
@end
|
|
@ -0,0 +1,73 @@
|
|||
import Foundation
|
||||
|
||||
public enum LKUserDefaults {
|
||||
|
||||
public enum Bool : Swift.String {
|
||||
case hasLaunchedOnce
|
||||
case hasSeenGIFMetadataWarning
|
||||
case hasViewedSeed
|
||||
case isUsingFullAPNs
|
||||
/// Whether the device was unlinked as a slave device (used to notify the user on the landing screen).
|
||||
case wasUnlinked
|
||||
}
|
||||
|
||||
public enum Date : Swift.String {
|
||||
case lastProfilePictureUpload
|
||||
}
|
||||
|
||||
public enum Double : Swift.String {
|
||||
/// - Note: Deprecated
|
||||
case lastDeviceTokenUpload = "lastDeviceTokenUploadTime"
|
||||
}
|
||||
|
||||
public enum Int: Swift.String {
|
||||
case appMode
|
||||
}
|
||||
|
||||
public enum String {
|
||||
case slaveDeviceName(Swift.String)
|
||||
case deviceToken
|
||||
/// `nil` if this is a master device or if the user hasn't linked a device.
|
||||
case masterHexEncodedPublicKey
|
||||
|
||||
public var key: Swift.String {
|
||||
switch self {
|
||||
case .slaveDeviceName(let hexEncodedPublicKey): return "\(hexEncodedPublicKey)_display_name"
|
||||
case .deviceToken: return "deviceToken"
|
||||
case .masterHexEncodedPublicKey: return "masterDeviceHexEncodedPublicKey"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension UserDefaults {
|
||||
|
||||
public subscript(bool: LKUserDefaults.Bool) -> Bool {
|
||||
get { return self.bool(forKey: bool.rawValue) }
|
||||
set { set(newValue, forKey: bool.rawValue) }
|
||||
}
|
||||
|
||||
public subscript(date: LKUserDefaults.Date) -> Date? {
|
||||
get { return self.object(forKey: date.rawValue) as? Date }
|
||||
set { set(newValue, forKey: date.rawValue) }
|
||||
}
|
||||
|
||||
public subscript(double: LKUserDefaults.Double) -> Double {
|
||||
get { return self.double(forKey: double.rawValue) }
|
||||
set { set(newValue, forKey: double.rawValue) }
|
||||
}
|
||||
|
||||
public subscript(int: LKUserDefaults.Int) -> Int {
|
||||
get { return self.integer(forKey: int.rawValue) }
|
||||
set { set(newValue, forKey: int.rawValue) }
|
||||
}
|
||||
|
||||
public subscript(string: LKUserDefaults.String) -> String? {
|
||||
get { return self.string(forKey: string.key) }
|
||||
set { set(newValue, forKey: string.key) }
|
||||
}
|
||||
|
||||
public var isMasterDevice: Bool {
|
||||
return (self[.masterHexEncodedPublicKey] == nil)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
@objc
|
||||
public class AnyLRUCache: NSObject {
|
||||
|
||||
private let backingCache: LRUCache<NSObject, NSObject>
|
||||
|
||||
@objc
|
||||
public init(maxSize: Int) {
|
||||
backingCache = LRUCache(maxSize: maxSize)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func get(key: NSObject) -> NSObject? {
|
||||
return self.backingCache.get(key: key)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func set(key: NSObject, value: NSObject) {
|
||||
self.backingCache.set(key: key, value: value)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func clear() {
|
||||
self.backingCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// A simple LRU cache bounded by the number of entries.
|
||||
public class LRUCache<KeyType: Hashable & Equatable, ValueType> {
|
||||
|
||||
private var cacheMap: [KeyType: ValueType] = [:]
|
||||
private var cacheOrder: [KeyType] = []
|
||||
private let maxSize: Int
|
||||
|
||||
@objc
|
||||
public init(maxSize: Int) {
|
||||
self.maxSize = maxSize
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(didReceiveMemoryWarning),
|
||||
name: UIApplication.didReceiveMemoryWarningNotification,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(didEnterBackground),
|
||||
name: NSNotification.Name.OWSApplicationDidEnterBackground,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc func didEnterBackground() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
@objc func didReceiveMemoryWarning() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
private func updateCacheOrder(key: KeyType) {
|
||||
cacheOrder = cacheOrder.filter { $0 != key }
|
||||
cacheOrder.append(key)
|
||||
}
|
||||
|
||||
public func get(key: KeyType) -> ValueType? {
|
||||
guard let value = cacheMap[key] else {
|
||||
// Miss
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hit
|
||||
updateCacheOrder(key: key)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
public func set(key: KeyType, value: ValueType) {
|
||||
cacheMap[key] = value
|
||||
|
||||
updateCacheOrder(key: key)
|
||||
|
||||
while cacheOrder.count > maxSize {
|
||||
guard let staleKey = cacheOrder.first else {
|
||||
owsFailDebug("Cache ordering unexpectedly empty")
|
||||
return
|
||||
}
|
||||
cacheOrder.removeFirst()
|
||||
cacheMap.removeValue(forKey: staleKey)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func clear() {
|
||||
cacheMap.removeAll()
|
||||
cacheOrder.removeAll()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
|
||||
@objc(LKDatabaseUtilities)
|
||||
public final class LokiDatabaseUtilities : NSObject {
|
||||
|
||||
private override init() { }
|
||||
|
||||
// MARK: - Quotes
|
||||
@objc(getServerIDForQuoteWithID:quoteeHexEncodedPublicKey:threadID:transaction:)
|
||||
public static func getServerID(quoteID: UInt64, quoteeHexEncodedPublicKey: String, threadID: String, transaction: YapDatabaseReadTransaction) -> UInt64 {
|
||||
guard let message = TSInteraction.interactions(withTimestamp: quoteID, filter: { interaction in
|
||||
let senderHexEncodedPublicKey: String
|
||||
if let message = interaction as? TSIncomingMessage {
|
||||
senderHexEncodedPublicKey = message.authorId
|
||||
} else if let message = interaction as? TSOutgoingMessage {
|
||||
senderHexEncodedPublicKey = getUserHexEncodedPublicKey()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return (senderHexEncodedPublicKey == quoteeHexEncodedPublicKey) && (interaction.uniqueThreadId == threadID)
|
||||
}, with: transaction).first as! TSMessage? else { return 0 }
|
||||
return message.openGroupServerMessageID
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Device Links
|
||||
@objc(getLinkedDeviceHexEncodedPublicKeysFor:in:)
|
||||
public static func getLinkedDeviceHexEncodedPublicKeys(for hexEncodedPublicKey: String, in transaction: YapDatabaseReadTransaction) -> Set<String> {
|
||||
return [ hexEncodedPublicKey ]
|
||||
/*
|
||||
let storage = OWSPrimaryStorage.shared()
|
||||
let masterHexEncodedPublicKey = storage.getMasterHexEncodedPublicKey(for: hexEncodedPublicKey, in: transaction) ?? hexEncodedPublicKey
|
||||
var result = Set(storage.getDeviceLinks(for: masterHexEncodedPublicKey, in: transaction).flatMap { deviceLink in
|
||||
return [ deviceLink.master.publicKey, deviceLink.slave.publicKey ]
|
||||
})
|
||||
result.insert(hexEncodedPublicKey)
|
||||
return result
|
||||
*/
|
||||
}
|
||||
|
||||
@objc(getLinkedDeviceThreadsFor:in:)
|
||||
public static func getLinkedDeviceThreads(for hexEncodedPublicKey: String, in transaction: YapDatabaseReadTransaction) -> Set<TSContactThread> {
|
||||
return Set([ TSContactThread.getWithContactId(hexEncodedPublicKey, transaction: transaction) ].compactMap { $0 })
|
||||
// return Set(getLinkedDeviceHexEncodedPublicKeys(for: hexEncodedPublicKey, in: transaction).compactMap { TSContactThread.getWithContactId($0, transaction: transaction) })
|
||||
}
|
||||
|
||||
@objc(isUserLinkedDevice:in:)
|
||||
public static func isUserLinkedDevice(_ hexEncodedPublicKey: String, transaction: YapDatabaseReadTransaction) -> Bool {
|
||||
return hexEncodedPublicKey == getUserHexEncodedPublicKey()
|
||||
/*
|
||||
let userHexEncodedPublicKey = getUserHexEncodedPublicKey()
|
||||
let userLinkedDeviceHexEncodedPublicKeys = getLinkedDeviceHexEncodedPublicKeys(for: userHexEncodedPublicKey, in: transaction)
|
||||
return userLinkedDeviceHexEncodedPublicKeys.contains(hexEncodedPublicKey)
|
||||
*/
|
||||
}
|
||||
|
||||
@objc(getMasterHexEncodedPublicKeyFor:in:)
|
||||
public static func objc_getMasterHexEncodedPublicKey(for slaveHexEncodedPublicKey: String, in transaction: YapDatabaseReadTransaction) -> String? {
|
||||
return nil
|
||||
// return OWSPrimaryStorage.shared().getMasterHexEncodedPublicKey(for: slaveHexEncodedPublicKey, in: transaction)
|
||||
}
|
||||
|
||||
@objc(getDeviceLinksFor:in:)
|
||||
public static func objc_getDeviceLinks(for masterHexEncodedPublicKey: String, in transaction: YapDatabaseReadTransaction) -> Set<DeviceLink> {
|
||||
return []
|
||||
// return OWSPrimaryStorage.shared().getDeviceLinks(for: masterHexEncodedPublicKey, in: transaction)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Open Groups
|
||||
private static let publicChatCollection = "LokiPublicChatCollection"
|
||||
|
||||
@objc(getAllPublicChats:)
|
||||
public static func getAllPublicChats(in transaction: YapDatabaseReadTransaction) -> [String:OpenGroup] {
|
||||
var result = [String:OpenGroup]()
|
||||
transaction.enumerateKeysAndObjects(inCollection: publicChatCollection) { threadID, object, _ in
|
||||
guard let publicChat = object as? OpenGroup else { return }
|
||||
result[threadID] = publicChat
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@objc(getPublicChatForThreadID:transaction:)
|
||||
public static func getPublicChat(for threadID: String, in transaction: YapDatabaseReadTransaction) -> OpenGroup? {
|
||||
return transaction.object(forKey: threadID, inCollection: publicChatCollection) as? OpenGroup
|
||||
}
|
||||
|
||||
@objc(setPublicChat:threadID:transaction:)
|
||||
public static func setPublicChat(_ publicChat: OpenGroup, for threadID: String, in transaction: YapDatabaseReadWriteTransaction) {
|
||||
transaction.setObject(publicChat, forKey: threadID, inCollection: publicChatCollection)
|
||||
}
|
||||
|
||||
@objc(removePublicChatForThreadID:transaction:)
|
||||
public static func removePublicChat(for threadID: String, in transaction: YapDatabaseReadWriteTransaction) {
|
||||
transaction.removeObject(forKey: threadID, inCollection: publicChatCollection)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import PromiseKit
|
||||
|
||||
public struct LokiMessage {
|
||||
/// The hex encoded public key of the recipient.
|
||||
let recipientPublicKey: String
|
||||
/// The content of the message.
|
||||
let data: LosslessStringConvertible
|
||||
/// The time to live for the message in milliseconds.
|
||||
let ttl: UInt64
|
||||
/// Whether this message is a ping.
|
||||
///
|
||||
/// - Note: The concept of pinging only applies to P2P messaging.
|
||||
let isPing: Bool
|
||||
/// When the proof of work was calculated, if applicable (P2P messages don't require proof of work).
|
||||
///
|
||||
/// - Note: Expressed as milliseconds since 00:00:00 UTC on 1 January 1970.
|
||||
private(set) var timestamp: UInt64? = nil
|
||||
/// The base 64 encoded proof of work, if applicable (P2P messages don't require proof of work).
|
||||
private(set) var nonce: String? = nil
|
||||
|
||||
private init(destination: String, data: LosslessStringConvertible, ttl: UInt64, isPing: Bool) {
|
||||
self.recipientPublicKey = destination
|
||||
self.data = data
|
||||
self.ttl = ttl
|
||||
self.isPing = isPing
|
||||
}
|
||||
|
||||
/// Construct a `LokiMessage` from a `SignalMessage`.
|
||||
///
|
||||
/// - Note: `timestamp` is the original message timestamp (i.e. `TSOutgoingMessage.timestamp`).
|
||||
public static func from(signalMessage: SignalMessage) -> LokiMessage? {
|
||||
// To match the desktop application, we have to wrap the data in an envelope and then wrap that in a websocket object
|
||||
do {
|
||||
let wrappedMessage = try MessageWrapper.wrap(message: signalMessage)
|
||||
let data = wrappedMessage.base64EncodedString()
|
||||
let destination = signalMessage.recipientPublicKey
|
||||
var ttl = TTLUtilities.fallbackMessageTTL
|
||||
if let messageTTL = signalMessage.ttl, messageTTL > 0 { ttl = UInt64(messageTTL) }
|
||||
let isPing = signalMessage.isPing
|
||||
return LokiMessage(destination: destination, data: data, ttl: ttl, isPing: isPing)
|
||||
} catch let error {
|
||||
print("[Loki] Failed to convert Signal message to Loki message: \(signalMessage).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the proof of work for this message.
|
||||
///
|
||||
/// - Returns: The promise of a new message with its `timestamp` and `nonce` set.
|
||||
public func calculatePoW() -> Promise<LokiMessage> {
|
||||
return Promise<LokiMessage> { seal in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let now = NSDate.ows_millisecondTimeStamp()
|
||||
let dataAsString = self.data as! String // Safe because of how from(signalMessage:with:) is implemented
|
||||
if let nonce = ProofOfWork.calculate(data: dataAsString, pubKey: self.recipientPublicKey, timestamp: now, ttl: self.ttl) {
|
||||
var result = self
|
||||
result.timestamp = now
|
||||
result.nonce = nonce
|
||||
seal.fulfill(result)
|
||||
} else {
|
||||
seal.reject(SessionMessagingKit.MessageSender.Error.proofOfWorkCalculationFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func toJSON() -> JSON {
|
||||
var result = [ "pubKey" : recipientPublicKey, "data" : data.description, "ttl" : String(ttl) ]
|
||||
if let timestamp = timestamp, let nonce = nonce {
|
||||
result["timestamp"] = String(timestamp)
|
||||
result["nonce"] = nonce
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
import PromiseKit
|
||||
|
||||
@objc(LKPushNotificationManager)
|
||||
public final class LokiPushNotificationManager : NSObject {
|
||||
|
||||
// MARK: Settings
|
||||
#if DEBUG
|
||||
private static let server = "https://live.apns.getsession.org"
|
||||
#else
|
||||
private static let server = "https://live.apns.getsession.org"
|
||||
#endif
|
||||
internal static let pnServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
|
||||
private static let maxRetryCount: UInt = 4
|
||||
private static let tokenExpirationInterval: TimeInterval = 12 * 60 * 60
|
||||
|
||||
public enum ClosedGroupOperation: String {
|
||||
case subscribe = "subscribe_closed_group"
|
||||
case unsubscribe = "unsubscribe_closed_group"
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
private override init() { }
|
||||
|
||||
// MARK: Registration
|
||||
/// Unregisters the user from push notifications. Only the user's device token is needed for this.
|
||||
static func unregister(with token: Data, isForcedUpdate: Bool) -> Promise<Void> {
|
||||
let hexEncodedToken = token.toHexString()
|
||||
let parameters = [ "token" : hexEncodedToken ]
|
||||
let url = URL(string: "\(server)/unregister")!
|
||||
let request = TSRequest(url: url, method: "POST", parameters: parameters)
|
||||
request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ]
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, using: pnServerPublicKey).map2 { response in
|
||||
guard let json = response["body"] as? JSON else {
|
||||
return print("[Loki] Couldn't unregister from push notifications.")
|
||||
}
|
||||
guard json["code"] as? Int != 0 else {
|
||||
return print("[Loki] Couldn't unregister from push notifications due to error: \(json["message"] as? String ?? "nil").")
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
print("[Loki] Couldn't unregister from push notifications.")
|
||||
}
|
||||
// Unsubscribe from all closed groups
|
||||
Storage.getUserClosedGroupPublicKeys().forEach { closedGroup in
|
||||
performOperation(.unsubscribe, for: closedGroup, publicKey: getUserHexEncodedPublicKey())
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Unregisters the user from push notifications. Only the user's device token is needed for this.
|
||||
@objc(unregisterWithToken:isForcedUpdate:)
|
||||
public static func objc_unregister(with token: Data, isForcedUpdate: Bool) -> AnyPromise {
|
||||
return AnyPromise.from(unregister(with: token, isForcedUpdate: isForcedUpdate))
|
||||
}
|
||||
|
||||
/// Registers the user for push notifications. Requires the user's device
|
||||
/// token and their Session ID.
|
||||
static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise<Void> {
|
||||
let hexEncodedToken = token.toHexString()
|
||||
let userDefaults = UserDefaults.standard
|
||||
let oldToken = userDefaults[.deviceToken]
|
||||
let lastUploadTime = userDefaults[.lastDeviceTokenUpload]
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard isForcedUpdate || hexEncodedToken != oldToken || now - lastUploadTime > tokenExpirationInterval else {
|
||||
print("[Loki] Device token hasn't changed or expired; no need to re-upload.")
|
||||
return Promise<Void> { $0.fulfill(()) }
|
||||
}
|
||||
let parameters = [ "token" : hexEncodedToken, "pubKey" : publicKey]
|
||||
let url = URL(string: "\(server)/register")!
|
||||
let request = TSRequest(url: url, method: "POST", parameters: parameters)
|
||||
request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ]
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, using: pnServerPublicKey).map2 { response in
|
||||
guard let json = response["body"] as? JSON else {
|
||||
return print("[Loki] Couldn't register device token.")
|
||||
}
|
||||
guard json["code"] as? Int != 0 else {
|
||||
return print("[Loki] Couldn't register device token due to error: \(json["message"] as? String ?? "nil").")
|
||||
}
|
||||
userDefaults[.deviceToken] = hexEncodedToken
|
||||
userDefaults[.lastDeviceTokenUpload] = now
|
||||
userDefaults[.isUsingFullAPNs] = true
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
print("[Loki] Couldn't register device token.")
|
||||
}
|
||||
// Subscribe to all closed groups
|
||||
Storage.getUserClosedGroupPublicKeys().forEach { closedGroup in
|
||||
performOperation(.subscribe, for: closedGroup, publicKey: publicKey)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Registers the user for push notifications. Requires the user's device
|
||||
/// token and their Session ID.
|
||||
@objc(registerWithToken:hexEncodedPublicKey:isForcedUpdate:)
|
||||
public static func objc_register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> AnyPromise {
|
||||
return AnyPromise.from(register(with: token, publicKey: publicKey, isForcedUpdate: isForcedUpdate))
|
||||
}
|
||||
|
||||
static func performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> Promise<Void> {
|
||||
let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs]
|
||||
guard isUsingFullAPNs else { return Promise<Void> { $0.fulfill(()) } }
|
||||
let parameters = [ "closedGroupPublicKey" : closedGroupPublicKey, "pubKey" : publicKey]
|
||||
let url = URL(string: "\(server)/\(operation.rawValue)")!
|
||||
let request = TSRequest(url: url, method: "POST", parameters: parameters)
|
||||
request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ]
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, using: pnServerPublicKey).map2 { response in
|
||||
guard let json = response["body"] as? JSON else {
|
||||
return print("[Loki] Couldn't subscribe/unsubscribe closed group: \(closedGroupPublicKey).")
|
||||
}
|
||||
guard json["code"] as? Int != 0 else {
|
||||
return print("[Loki] Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(json["message"] as? String ?? "nil").")
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
print("[Loki] Couldn't subscribe/unsubscribe closed group: \(closedGroupPublicKey).")
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
static func notify(for signalMessage: SignalMessage) -> Promise<Void> {
|
||||
let message = LokiMessage.from(signalMessage: signalMessage)!
|
||||
let parameters = [ "data" : message.data.description, "send_to" : message.recipientPublicKey]
|
||||
let url = URL(string: "\(server)/notify")!
|
||||
let request = TSRequest(url: url, method: "POST", parameters: parameters)
|
||||
request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ]
|
||||
let promise: Promise<Void> = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) {
|
||||
OnionRequestAPI.sendOnionRequest(request, to: server, using: pnServerPublicKey).map2 { response in
|
||||
guard let json = response["body"] as? JSON else {
|
||||
return print("[Loki] Couldn't notify PN server.")
|
||||
}
|
||||
guard json["code"] as? Int != 0 else {
|
||||
return print("[Loki] Couldn't notify PN server due to error: \(json["message"] as? String ?? "nil").")
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.catch2 { error in
|
||||
print("[Loki] Couldn't notify PN server.")
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
@objc(notifyForMessage:)
|
||||
public static func objc_notify(for signalMessage: SignalMessage) -> AnyPromise {
|
||||
return AnyPromise.from(notify(for: signalMessage))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import SessionProtocolKit
|
||||
|
||||
@objc(SNSessionRestorationImplementation)
|
||||
public final class SessionRestorationImplementation : NSObject, SessionRestorationProtocol {
|
||||
|
||||
private var storage: OWSPrimaryStorage {
|
||||
return OWSPrimaryStorage.shared()
|
||||
}
|
||||
|
||||
enum Error : LocalizedError {
|
||||
case missingPreKey
|
||||
case invalidPreKeyID
|
||||
}
|
||||
|
||||
public func validatePreKeyWhisperMessage(for publicKey: String, preKeyWhisperMessage: PreKeyWhisperMessage, using transaction: Any) throws {
|
||||
guard let transaction = transaction as? YapDatabaseReadTransaction else { return }
|
||||
guard let storedPreKey = storage.getPreKeyRecord(forContact: publicKey, transaction: transaction) else {
|
||||
print("[Loki] Missing pre key bundle.")
|
||||
throw Error.missingPreKey
|
||||
}
|
||||
guard storedPreKey.id == preKeyWhisperMessage.prekeyID else {
|
||||
print("[Loki] Received a PreKeyWhisperMessage from an unknown source.")
|
||||
throw Error.invalidPreKeyID
|
||||
}
|
||||
}
|
||||
|
||||
public func getSessionRestorationStatus(for publicKey: String) -> SessionRestorationStatus {
|
||||
var thread: TSContactThread?
|
||||
Storage.read { transaction in
|
||||
thread = TSContactThread.getWithContactId(publicKey, transaction: transaction)
|
||||
}
|
||||
return thread?.sessionResetStatus ?? .none
|
||||
}
|
||||
|
||||
public func handleNewSessionAdopted(for publicKey: String, using transaction: Any) {
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction else { return }
|
||||
guard !publicKey.isEmpty else { return }
|
||||
guard let thread = TSContactThread.getWithContactId(publicKey, transaction: transaction) else {
|
||||
return print("[Loki] A new session was adopted but the thread couldn't be found for: \(publicKey).")
|
||||
}
|
||||
// Notify the user
|
||||
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeLokiSessionResetDone)
|
||||
infoMessage.save(with: transaction)
|
||||
// Update the session reset status
|
||||
thread.sessionResetStatus = .none
|
||||
thread.save(with: transaction)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSString *const OWSMimeTypeApplicationOctetStream;
|
||||
extern NSString *const OWSMimeTypeApplicationZip;
|
||||
extern NSString *const OWSMimeTypeImagePng;
|
||||
extern NSString *const OWSMimeTypeImageJpeg;
|
||||
extern NSString *const OWSMimeTypeImageGif;
|
||||
extern NSString *const OWSMimeTypeImageTiff1;
|
||||
extern NSString *const OWSMimeTypeImageTiff2;
|
||||
extern NSString *const OWSMimeTypeImageBmp1;
|
||||
extern NSString *const OWSMimeTypeImageBmp2;
|
||||
extern NSString *const OWSMimeTypeOversizeTextMessage;
|
||||
extern NSString *const OWSMimeTypeUnknownForTests;
|
||||
|
||||
extern NSString *const kOversizeTextAttachmentUTI;
|
||||
extern NSString *const kOversizeTextAttachmentFileExtension;
|
||||
extern NSString *const kUnknownTestAttachmentUTI;
|
||||
extern NSString *const kSyncMessageFileExtension;
|
||||
|
||||
@interface MIMETypeUtil : NSObject
|
||||
|
||||
+ (BOOL)isSupportedVideoMIMEType:(NSString *)contentType;
|
||||
+ (BOOL)isSupportedAudioMIMEType:(NSString *)contentType;
|
||||
+ (BOOL)isSupportedImageMIMEType:(NSString *)contentType;
|
||||
+ (BOOL)isSupportedAnimatedMIMEType:(NSString *)contentType;
|
||||
|
||||
+ (BOOL)isSupportedVideoFile:(NSString *)filePath;
|
||||
+ (BOOL)isSupportedAudioFile:(NSString *)filePath;
|
||||
+ (BOOL)isSupportedImageFile:(NSString *)filePath;
|
||||
+ (BOOL)isSupportedAnimatedFile:(NSString *)filePath;
|
||||
|
||||
+ (nullable NSString *)getSupportedExtensionFromVideoMIMEType:(NSString *)supportedMIMEType;
|
||||
+ (nullable NSString *)getSupportedExtensionFromAudioMIMEType:(NSString *)supportedMIMEType;
|
||||
+ (nullable NSString *)getSupportedExtensionFromImageMIMEType:(NSString *)supportedMIMEType;
|
||||
+ (nullable NSString *)getSupportedExtensionFromAnimatedMIMEType:(NSString *)supportedMIMEType;
|
||||
|
||||
+ (BOOL)isAnimated:(NSString *)contentType;
|
||||
+ (BOOL)isImage:(NSString *)contentType;
|
||||
+ (BOOL)isVideo:(NSString *)contentType;
|
||||
+ (BOOL)isAudio:(NSString *)contentType;
|
||||
+ (BOOL)isVisualMedia:(NSString *)contentType;
|
||||
|
||||
// filename is optional and should not be trusted.
|
||||
+ (nullable NSString *)filePathForAttachment:(NSString *)uniqueId
|
||||
ofMIMEType:(NSString *)contentType
|
||||
sourceFilename:(nullable NSString *)sourceFilename
|
||||
inFolder:(NSString *)folder;
|
||||
|
||||
+ (NSSet<NSString *> *)supportedVideoUTITypes;
|
||||
+ (NSSet<NSString *> *)supportedAudioUTITypes;
|
||||
+ (NSSet<NSString *> *)supportedImageUTITypes;
|
||||
+ (NSSet<NSString *> *)supportedAnimatedImageUTITypes;
|
||||
|
||||
+ (nullable NSString *)utiTypeForMIMEType:(NSString *)mimeType;
|
||||
+ (nullable NSString *)utiTypeForFileExtension:(NSString *)fileExtension;
|
||||
+ (nullable NSString *)fileExtensionForUTIType:(NSString *)utiType;
|
||||
+ (nullable NSString *)fileExtensionForMIMEType:(NSString *)mimeType;
|
||||
+ (nullable NSString *)mimeTypeForFileExtension:(NSString *)fileExtension;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,15 @@
|
|||
|
||||
@objc(LKMention)
|
||||
public final class Mention : NSObject {
|
||||
@objc public let publicKey: String
|
||||
@objc public let displayName: String
|
||||
|
||||
@objc public init(publicKey: String, displayName: String) {
|
||||
self.publicKey = publicKey
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
@objc public func isContained(in string: String) -> Bool {
|
||||
return string.contains(displayName)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import PromiseKit
|
||||
|
||||
@objc(LKMentionsManager)
|
||||
public final class MentionsManager : NSObject {
|
||||
|
||||
/// A mapping from thread ID to set of user hex encoded public keys.
|
||||
///
|
||||
/// - Note: Should only be accessed from the main queue to avoid race conditions.
|
||||
@objc public static var userPublicKeyCache: [String:Set<String>] = [:]
|
||||
|
||||
internal static var storage: OWSPrimaryStorage { OWSPrimaryStorage.shared() }
|
||||
|
||||
// MARK: Settings
|
||||
private static var userIDScanLimit: UInt = 4096
|
||||
|
||||
// MARK: Initialization
|
||||
private override init() { }
|
||||
|
||||
// MARK: Implementation
|
||||
@objc public static func cache(_ publicKey: String, for threadID: String) {
|
||||
if let cache = userPublicKeyCache[threadID] {
|
||||
userPublicKeyCache[threadID] = cache.union([ publicKey ])
|
||||
} else {
|
||||
userPublicKeyCache[threadID] = [ publicKey ]
|
||||
}
|
||||
}
|
||||
|
||||
@objc public static func getMentionCandidates(for query: String, in threadID: String) -> [Mention] {
|
||||
// Prepare
|
||||
guard let cache = userPublicKeyCache[threadID] else { return [] }
|
||||
var candidates: [Mention] = []
|
||||
// Gather candidates
|
||||
var publicChat: OpenGroup?
|
||||
storage.dbReadConnection.read { transaction in
|
||||
publicChat = LokiDatabaseUtilities.getPublicChat(for: threadID, in: transaction)
|
||||
}
|
||||
storage.dbReadConnection.read { transaction in
|
||||
candidates = cache.flatMap { publicKey in
|
||||
let uncheckedDisplayName: String?
|
||||
if let publicChat = publicChat {
|
||||
uncheckedDisplayName = UserDisplayNameUtilities.getPublicChatDisplayName(for: publicKey, in: publicChat.channel, on: publicChat.server)
|
||||
} else {
|
||||
uncheckedDisplayName = UserDisplayNameUtilities.getPrivateChatDisplayName(for: publicKey)
|
||||
}
|
||||
guard let displayName = uncheckedDisplayName else { return nil }
|
||||
guard !displayName.hasPrefix("Anonymous") else { return nil }
|
||||
return Mention(publicKey: publicKey, displayName: displayName)
|
||||
}
|
||||
}
|
||||
candidates = candidates.filter { $0.publicKey != getUserHexEncodedPublicKey() }
|
||||
// Sort alphabetically first
|
||||
candidates.sort { $0.displayName < $1.displayName }
|
||||
if query.count >= 2 {
|
||||
// Filter out any non-matching candidates
|
||||
candidates = candidates.filter { $0.displayName.lowercased().contains(query.lowercased()) }
|
||||
// Sort based on where in the candidate the query occurs
|
||||
candidates.sort {
|
||||
$0.displayName.lowercased().range(of: query.lowercased())!.lowerBound < $1.displayName.lowercased().range(of: query.lowercased())!.lowerBound
|
||||
}
|
||||
}
|
||||
// Return
|
||||
return candidates
|
||||
}
|
||||
|
||||
@objc public static func populateUserPublicKeyCacheIfNeeded(for threadID: String, in transaction: YapDatabaseReadTransaction? = nil) {
|
||||
var result: Set<String> = []
|
||||
func populate(in transaction: YapDatabaseReadTransaction) {
|
||||
guard let thread = TSThread.fetch(uniqueId: threadID, transaction: transaction) else { return }
|
||||
if let groupThread = thread as? TSGroupThread, groupThread.groupModel.groupType == .closedGroup {
|
||||
result = result.union(groupThread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ])
|
||||
} else {
|
||||
guard userPublicKeyCache[threadID] == nil else { return }
|
||||
let interactions = transaction.ext(TSMessageDatabaseViewExtensionName) as! YapDatabaseViewTransaction
|
||||
interactions.enumerateKeysAndObjects(inGroup: threadID) { _, _, object, index, _ in
|
||||
guard let message = object as? TSIncomingMessage, index < userIDScanLimit else { return }
|
||||
result.insert(message.authorId)
|
||||
}
|
||||
}
|
||||
result.insert(getUserHexEncodedPublicKey())
|
||||
}
|
||||
if let transaction = transaction {
|
||||
populate(in: transaction)
|
||||
} else {
|
||||
storage.dbReadConnection.read { transaction in
|
||||
populate(in: transaction)
|
||||
}
|
||||
}
|
||||
if !result.isEmpty {
|
||||
userPublicKeyCache[threadID] = result
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
|
||||
public extension MessageSender {
|
||||
|
||||
/**
|
||||
* Wrap message sending in a Promise for easier callback chaining.
|
||||
*/
|
||||
func sendPromise(message: TSOutgoingMessage) -> Promise<Void> {
|
||||
let promise: Promise<Void> = Promise { resolver in
|
||||
self.send(message, success: { resolver.fulfill(()) }, failure: resolver.reject)
|
||||
}
|
||||
|
||||
// Ensure sends complete before they're GC'd.
|
||||
// This *should* be redundant, since we should be calling retainUntilComplete
|
||||
// at all call sites where the promise isn't otherwise retained.
|
||||
promise.retainUntilComplete()
|
||||
|
||||
return promise
|
||||
}
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Durably enqueues a message for sending.
|
||||
///
|
||||
/// The queue's operations (`MessageSenderOperation`) uses `MessageSender` to send a message.
|
||||
///
|
||||
/// ## Retry behavior
|
||||
///
|
||||
/// Like all JobQueue's, MessageSenderJobQueue implements retry handling for operation errors.
|
||||
///
|
||||
/// `MessageSender` also includes it's own retry logic necessary to encapsulate business logic around
|
||||
/// a user changing their Registration ID, or adding/removing devices. That is, it is sometimes *normal*
|
||||
/// for MessageSender to have to resend to a recipient multiple times before it is accepted, and doesn't
|
||||
/// represent a "failure" from the application standpoint.
|
||||
///
|
||||
/// So we have an inner non-durable retry (MessageSender) and an outer durable retry (MessageSenderJobQueue).
|
||||
///
|
||||
/// Both respect the `error.isRetryable` convention to be sure we don't keep retrying in some situations
|
||||
/// (e.g. rate limiting)
|
||||
|
||||
@objc(SSKMessageSenderJobQueue)
|
||||
public class MessageSenderJobQueue: NSObject, JobQueue {
|
||||
|
||||
@objc
|
||||
public override init() {
|
||||
super.init()
|
||||
|
||||
AppReadiness.runNowOrWhenAppWillBecomeReady {
|
||||
self.setup()
|
||||
}
|
||||
}
|
||||
|
||||
@objc(addMessage:transaction:)
|
||||
public func add(message: TSOutgoingMessage, transaction: YapDatabaseReadWriteTransaction) {
|
||||
self.add(message: message, removeMessageAfterSending: false, transaction: transaction)
|
||||
}
|
||||
|
||||
@objc(addMediaMessage:dataSource:contentType:sourceFilename:caption:albumMessageId:isTemporaryAttachment:)
|
||||
public func add(mediaMessage: TSOutgoingMessage, dataSource: DataSource, contentType: String, sourceFilename: String?, caption: String?, albumMessageId: String?, isTemporaryAttachment: Bool) {
|
||||
let attachmentInfo = OutgoingAttachmentInfo(dataSource: dataSource, contentType: contentType, sourceFilename: sourceFilename, caption: caption, albumMessageId: albumMessageId)
|
||||
add(mediaMessage: mediaMessage, attachmentInfos: [attachmentInfo], isTemporaryAttachment: isTemporaryAttachment)
|
||||
}
|
||||
|
||||
@objc(addMediaMessage:attachmentInfos:isTemporaryAttachment:)
|
||||
public func add(mediaMessage: TSOutgoingMessage, attachmentInfos: [OutgoingAttachmentInfo], isTemporaryAttachment: Bool) {
|
||||
OutgoingMessagePreparer.prepareAttachments(attachmentInfos,
|
||||
inMessage: mediaMessage,
|
||||
completionHandler: { error in
|
||||
if let error = error {
|
||||
Storage.writeSync { transaction in
|
||||
mediaMessage.update(sendingError: error, transaction: transaction)
|
||||
}
|
||||
} else {
|
||||
Storage.writeSync { transaction in
|
||||
self.add(message: mediaMessage, removeMessageAfterSending: isTemporaryAttachment, transaction: transaction)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func add(message: TSOutgoingMessage, removeMessageAfterSending: Bool, transaction: YapDatabaseReadWriteTransaction) {
|
||||
assert(AppReadiness.isAppReady() || CurrentAppContext().isRunningTests)
|
||||
|
||||
let jobRecord: SSKMessageSenderJobRecord
|
||||
do {
|
||||
jobRecord = try SSKMessageSenderJobRecord(message: message, removeMessageAfterSending: false, label: self.jobRecordLabel)
|
||||
} catch {
|
||||
owsFailDebug("Failed to build job due to error: \(error).")
|
||||
return
|
||||
}
|
||||
self.add(jobRecord: jobRecord, transaction: transaction)
|
||||
}
|
||||
|
||||
// MARK: JobQueue
|
||||
|
||||
public typealias DurableOperationType = MessageSenderOperation
|
||||
public static let jobRecordLabel: String = "MessageSender"
|
||||
public static let maxRetries: UInt = 1 // Loki: We have our own retrying
|
||||
public let requiresInternet: Bool = true
|
||||
public var runningOperations: [MessageSenderOperation] = []
|
||||
|
||||
public var jobRecordLabel: String {
|
||||
return type(of: self).jobRecordLabel
|
||||
}
|
||||
|
||||
@objc
|
||||
public func setup() {
|
||||
defaultSetup()
|
||||
}
|
||||
|
||||
public var isSetup: Bool = false
|
||||
|
||||
/// Used when the user clears their database to cancel any outstanding jobs.
|
||||
@objc public func clearAllJobs() {
|
||||
Storage.writeSync { transaction in
|
||||
let statuses: [SSKJobRecordStatus] = [ .unknown, .ready, .running, .permanentlyFailed ]
|
||||
var records: [SSKJobRecord] = []
|
||||
statuses.forEach {
|
||||
records += self.finder.allRecords(label: self.jobRecordLabel, status: $0, transaction: transaction)
|
||||
}
|
||||
records.forEach { $0.remove(with: transaction) }
|
||||
}
|
||||
}
|
||||
|
||||
public func didMarkAsReady(oldJobRecord: SSKMessageSenderJobRecord, transaction: YapDatabaseReadWriteTransaction) {
|
||||
if let messageId = oldJobRecord.messageId, let message = TSOutgoingMessage.fetch(uniqueId: messageId, transaction: transaction) {
|
||||
message.updateWithMarkingAllUnsentRecipientsAsSending(with: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
public func buildOperation(jobRecord: SSKMessageSenderJobRecord, transaction: YapDatabaseReadTransaction) throws -> MessageSenderOperation {
|
||||
let message: TSOutgoingMessage
|
||||
if let invisibleMessage = jobRecord.invisibleMessage {
|
||||
message = invisibleMessage
|
||||
} else if let messageId = jobRecord.messageId, let fetchedMessage = TSOutgoingMessage.fetch(uniqueId: messageId, transaction: transaction) {
|
||||
message = fetchedMessage
|
||||
} else {
|
||||
assert(jobRecord.messageId != nil)
|
||||
throw JobError.obsolete(description: "Message no longer exists.")
|
||||
}
|
||||
|
||||
return MessageSenderOperation(message: message, jobRecord: jobRecord)
|
||||
}
|
||||
|
||||
var senderQueues: [String: OperationQueue] = [:]
|
||||
let defaultQueue: OperationQueue = {
|
||||
let operationQueue = OperationQueue()
|
||||
operationQueue.name = "DefaultSendingQueue"
|
||||
operationQueue.maxConcurrentOperationCount = 1
|
||||
operationQueue.qualityOfService = .userInitiated
|
||||
|
||||
return operationQueue
|
||||
}()
|
||||
|
||||
// We use a per-thread serial OperationQueue to ensure messages are delivered to the
|
||||
// service in the order the user sent them.
|
||||
public func operationQueue(jobRecord: SSKMessageSenderJobRecord) -> OperationQueue {
|
||||
guard let threadId = jobRecord.threadId else {
|
||||
return defaultQueue
|
||||
}
|
||||
|
||||
guard let existingQueue = senderQueues[threadId] else {
|
||||
let operationQueue = OperationQueue()
|
||||
operationQueue.name = "SendingQueue:\(threadId)"
|
||||
operationQueue.maxConcurrentOperationCount = 1
|
||||
operationQueue.qualityOfService = .userInitiated
|
||||
|
||||
senderQueues[threadId] = operationQueue
|
||||
|
||||
return operationQueue
|
||||
}
|
||||
|
||||
return existingQueue
|
||||
}
|
||||
}
|
||||
|
||||
public class MessageSenderOperation: OWSOperation, DurableOperation {
|
||||
|
||||
// MARK: DurableOperation
|
||||
|
||||
public let jobRecord: SSKMessageSenderJobRecord
|
||||
|
||||
weak public var durableOperationDelegate: MessageSenderJobQueue?
|
||||
|
||||
public var operation: OWSOperation {
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
|
||||
let message: TSOutgoingMessage
|
||||
|
||||
init(message: TSOutgoingMessage, jobRecord: SSKMessageSenderJobRecord) {
|
||||
self.message = message
|
||||
self.jobRecord = jobRecord
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var messageSender: MessageSender {
|
||||
return SSKEnvironment.shared.messageSender
|
||||
}
|
||||
|
||||
// MARK: OWSOperation
|
||||
|
||||
override public func run() {
|
||||
self.messageSender.send(message, success: reportSuccess, failure: reportError)
|
||||
}
|
||||
|
||||
override public func didSucceed() {
|
||||
Storage.writeSync { transaction in
|
||||
self.durableOperationDelegate?.durableOperationDidSucceed(self, transaction: transaction)
|
||||
|
||||
if self.jobRecord.removeMessageAfterSending {
|
||||
self.message.remove(with: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public func didReportError(_ error: Error) {
|
||||
let message = self.message
|
||||
var isFailedSessionRequest = false
|
||||
if message is SessionRequestMessage, let publicKey = message.thread.contactIdentifier() {
|
||||
isFailedSessionRequest = (Storage.getSessionRequestSentTimestamp(for: publicKey) == message.timestamp)
|
||||
}
|
||||
Storage.writeSync { transaction in
|
||||
if isFailedSessionRequest, let publicKey = message.thread.contactIdentifier() {
|
||||
Storage.setSessionRequestSentTimestamp(for: publicKey, to: 0, using: transaction)
|
||||
}
|
||||
|
||||
self.durableOperationDelegate?.durableOperation(self, didReportError: error, transaction: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
override public func retryInterval() -> TimeInterval {
|
||||
// Arbitrary backoff factor...
|
||||
// With backOffFactor of 1.9
|
||||
// try 1 delay: 0.00s
|
||||
// try 2 delay: 0.19s
|
||||
// ...
|
||||
// try 5 delay: 1.30s
|
||||
// ...
|
||||
// try 11 delay: 61.31s
|
||||
let backoffFactor = 1.9
|
||||
let maxBackoff = 15 * kMinuteInterval
|
||||
|
||||
let seconds = 0.1 * min(maxBackoff, pow(backoffFactor, Double(self.jobRecord.failureCount)))
|
||||
return seconds
|
||||
}
|
||||
|
||||
override public func didFail(error: Error) {
|
||||
let message = self.message
|
||||
var isFailedSessionRequest = false
|
||||
if message is SessionRequestMessage, let publicKey = message.thread.contactIdentifier() {
|
||||
isFailedSessionRequest = (Storage.getSessionRequestSentTimestamp(for: publicKey) == message.timestamp)
|
||||
}
|
||||
Storage.writeSync { transaction in
|
||||
if isFailedSessionRequest, let publicKey = message.thread.contactIdentifier() {
|
||||
Storage.setSessionRequestSentTimestamp(for: publicKey, to: 0, using: transaction)
|
||||
}
|
||||
|
||||
self.durableOperationDelegate?.durableOperation(self, didFailWithError: error, transaction: transaction)
|
||||
|
||||
self.message.update(sendingError: error, transaction: transaction)
|
||||
|
||||
if self.jobRecord.removeMessageAfterSending {
|
||||
self.message.remove(with: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
|
||||
public enum MessageWrapper {
|
||||
|
||||
public enum Error : LocalizedError {
|
||||
case failedToWrapData
|
||||
case failedToWrapMessageInEnvelope
|
||||
case failedToWrapEnvelopeInWebSocketMessage
|
||||
case failedToUnwrapData
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToWrapData: return "Failed to wrap data."
|
||||
case .failedToWrapMessageInEnvelope: return "Failed to wrap message in envelope."
|
||||
case .failedToWrapEnvelopeInWebSocketMessage: return "Failed to wrap envelope in web socket message."
|
||||
case .failedToUnwrapData: return "Failed to unwrap data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps `message` in an `SSKProtoEnvelope` and then a `WebSocketProtoWebSocketMessage` to match the desktop application.
|
||||
public static func wrap(message: SignalMessage) throws -> Data {
|
||||
do {
|
||||
let envelope = try createEnvelope(around: message)
|
||||
let webSocketMessage = try createWebSocketMessage(around: envelope)
|
||||
return try webSocketMessage.serializedData()
|
||||
} catch let error {
|
||||
throw error as? Error ?? Error.failedToWrapData
|
||||
}
|
||||
}
|
||||
|
||||
private static func createEnvelope(around message: SignalMessage) throws -> SSKProtoEnvelope {
|
||||
do {
|
||||
let builder = SSKProtoEnvelope.builder(type: message.type, timestamp: message.timestamp)
|
||||
builder.setSource(message.senderPublicKey)
|
||||
builder.setSourceDevice(message.senderDeviceID)
|
||||
if let content = try Data(base64Encoded: message.content, options: .ignoreUnknownCharacters) {
|
||||
builder.setContent(content)
|
||||
} else {
|
||||
throw Error.failedToWrapMessageInEnvelope
|
||||
}
|
||||
return try builder.build()
|
||||
} catch let error {
|
||||
print("[Loki] Failed to wrap message in envelope: \(error).")
|
||||
throw Error.failedToWrapMessageInEnvelope
|
||||
}
|
||||
}
|
||||
|
||||
private static func createWebSocketMessage(around envelope: SSKProtoEnvelope) throws -> WebSocketProtoWebSocketMessage {
|
||||
do {
|
||||
let requestBuilder = WebSocketProtoWebSocketRequestMessage.builder(verb: "PUT", path: "/api/v1/message", requestID: UInt64.random(in: 1..<UInt64.max))
|
||||
requestBuilder.setBody(try envelope.serializedData())
|
||||
let messageBuilder = WebSocketProtoWebSocketMessage.builder(type: .request)
|
||||
messageBuilder.setRequest(try requestBuilder.build())
|
||||
return try messageBuilder.build()
|
||||
} catch let error {
|
||||
print("[Loki] Failed to wrap envelope in web socket message: \(error).")
|
||||
throw Error.failedToWrapEnvelopeInWebSocketMessage
|
||||
}
|
||||
}
|
||||
|
||||
/// - Note: `data` shouldn't be base 64 encoded.
|
||||
public static func unwrap(data: Data) throws -> SSKProtoEnvelope {
|
||||
do {
|
||||
let webSocketMessage = try WebSocketProtoWebSocketMessage.parseData(data)
|
||||
let envelope = webSocketMessage.request!.body!
|
||||
return try SSKProtoEnvelope.parseData(envelope)
|
||||
} catch let error {
|
||||
print("[Loki] Failed to unwrap data: \(error).")
|
||||
throw Error.failedToUnwrapData
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,72 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
|
||||
FOUNDATION_EXPORT double SignalUtilitiesKitVersionNumber;
|
||||
FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[];
|
||||
|
||||
@import SessionMessagingKit;
|
||||
@import SessionProtocolKit;
|
||||
@import SessionSnodeKit;
|
||||
@import SessionUtilitiesKit;
|
||||
|
||||
#import <SignalUtilitiesKit/AppContext.h>
|
||||
#import <SignalUtilitiesKit/AppReadiness.h>
|
||||
#import <SignalUtilitiesKit/ContactDiscoveryService.h>
|
||||
#import <SignalUtilitiesKit/DataSource.h>
|
||||
#import <SignalUtilitiesKit/LKDeviceLinkMessage.h>
|
||||
#import <SignalUtilitiesKit/LKGroupUtilities.h>
|
||||
#import <SignalUtilitiesKit/LKSyncOpenGroupsMessage.h>
|
||||
#import <SignalUtilitiesKit/LKUnlinkDeviceMessage.h>
|
||||
#import <SignalUtilitiesKit/MIMETypeUtil.h>
|
||||
#import <SignalUtilitiesKit/NSData+Image.h>
|
||||
#import <SignalUtilitiesKit/NSError+MessageSending.h>
|
||||
#import <SignalUtilitiesKit/NSNotificationCenter+OWS.h>
|
||||
#import <SignalUtilitiesKit/NSString+SSK.h>
|
||||
#import <SignalUtilitiesKit/NSURLSessionDataTask+StatusCode.h>
|
||||
#import <SignalUtilitiesKit/OWSBlockingManager.h>
|
||||
#import <SignalUtilitiesKit/OWSContact.h>
|
||||
#import <SignalUtilitiesKit/OWSDevice.h>
|
||||
#import <SignalUtilitiesKit/OWSDisappearingMessagesJob.h>
|
||||
#import <SignalUtilitiesKit/OWSDispatch.h>
|
||||
#import <SignalUtilitiesKit/OWSEndSessionMessage.h>
|
||||
#import <SignalUtilitiesKit/OWSError.h>
|
||||
#import <SignalUtilitiesKit/OWSFileSystem.h>
|
||||
#import <SignalUtilitiesKit/OWSHTTPSecurityPolicy.h>
|
||||
#import <SignalUtilitiesKit/OWSIdentityManager.h>
|
||||
#import <SignalUtilitiesKit/OWSIncomingSentMessageTranscript.h>
|
||||
#import <SignalUtilitiesKit/OWSMessageDecrypter.h>
|
||||
#import <SignalUtilitiesKit/OWSMessageManager.h>
|
||||
#import <SignalUtilitiesKit/OWSMessageReceiver.h>
|
||||
#import <SignalUtilitiesKit/OWSMessageSender.h>
|
||||
#import <SignalUtilitiesKit/OWSOperation.h>
|
||||
#import <SignalUtilitiesKit/OWSOutgoingNullMessage.h>
|
||||
#import <SignalUtilitiesKit/OWSOutgoingSyncMessage.h>
|
||||
#import <SignalUtilitiesKit/OWSPrimaryStorage.h>
|
||||
#import <SignalUtilitiesKit/OWSPrimaryStorage+Loki.h>
|
||||
#import <SignalUtilitiesKit/OWSPrimaryStorage+PreKeyStore.h>
|
||||
#import <SignalUtilitiesKit/OWSPrimaryStorage+SessionStore.h>
|
||||
#import <SignalUtilitiesKit/OWSPrimaryStorage+SignedPreKeyStore.h>
|
||||
#import <SignalUtilitiesKit/OWSRequestFactory.h>
|
||||
#import <SignalUtilitiesKit/OWSSyncManagerProtocol.h>
|
||||
#import <SignalUtilitiesKit/PhoneNumber.h>
|
||||
#import <SignalUtilitiesKit/ProfileManagerProtocol.h>
|
||||
#import <SignalUtilitiesKit/SignalAccount.h>
|
||||
#import <SignalUtilitiesKit/SignalRecipient.h>
|
||||
#import <SignalUtilitiesKit/SSKEnvironment.h>
|
||||
#import <SignalUtilitiesKit/SSKMessageSenderJobRecord.h>
|
||||
#import <SignalUtilitiesKit/TSAttachmentPointer.h>
|
||||
#import <SignalUtilitiesKit/TSAttachmentStream.h>
|
||||
#import <SignalUtilitiesKit/TSContactThread.h>
|
||||
#import <SignalUtilitiesKit/TSDatabaseView.h>
|
||||
#import <SignalUtilitiesKit/TSErrorMessage.h>
|
||||
#import <SignalUtilitiesKit/TSGroupThread.h>
|
||||
#import <SignalUtilitiesKit/TSIncomingMessage.h>
|
||||
#import <SignalUtilitiesKit/TSInfoMessage.h>
|
||||
#import <SignalUtilitiesKit/TSNetworkManager.h>
|
||||
#import <SignalUtilitiesKit/TSOutgoingMessage.h>
|
||||
#import <SignalUtilitiesKit/TSPreKeyManager.h>
|
||||
#import <SignalUtilitiesKit/TSQuotedMessage.h>
|
||||
#import <SignalUtilitiesKit/TSSocketManager.h>
|
||||
#import <SignalUtilitiesKit/TSThread.h>
|
||||
#import <SignalUtilitiesKit/UIImage+OWS.h>
|
||||
#import <SignalUtilitiesKit/YapDatabaseConnection+OWS.h>
|
||||
#import <SignalUtilitiesKit/YapDatabaseTransaction+OWS.h>
|
|
@ -0,0 +1,162 @@
|
|||
import CryptoSwift
|
||||
|
||||
/// Based on [mnemonic.js](https://github.com/loki-project/loki-messenger/blob/development/libloki/modules/mnemonic.js) .
|
||||
public enum Mnemonic {
|
||||
|
||||
public struct Language : Hashable {
|
||||
fileprivate let filename: String
|
||||
fileprivate let prefixLength: UInt
|
||||
|
||||
public static let english = Language(filename: "english", prefixLength: 3)
|
||||
public static let japanese = Language(filename: "japanese", prefixLength: 3)
|
||||
public static let portuguese = Language(filename: "portuguese", prefixLength: 4)
|
||||
public static let spanish = Language(filename: "spanish", prefixLength: 4)
|
||||
|
||||
private static var wordSetCache: [Language:[String]] = [:]
|
||||
private static var truncatedWordSetCache: [Language:[String]] = [:]
|
||||
|
||||
private init(filename: String, prefixLength: UInt) {
|
||||
self.filename = filename
|
||||
self.prefixLength = prefixLength
|
||||
}
|
||||
|
||||
fileprivate func loadWordSet() -> [String] {
|
||||
if let cachedResult = Language.wordSetCache[self] {
|
||||
return cachedResult
|
||||
} else {
|
||||
let bundleID = "org.cocoapods.SessionServiceKit"
|
||||
let url = Bundle(identifier: bundleID)!.url(forResource: filename, withExtension: "txt")!
|
||||
let contents = try! String(contentsOf: url)
|
||||
let result = contents.split(separator: ",").map { String($0) }
|
||||
Language.wordSetCache[self] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func loadTruncatedWordSet() -> [String] {
|
||||
if let cachedResult = Language.truncatedWordSetCache[self] {
|
||||
return cachedResult
|
||||
} else {
|
||||
let result = loadWordSet().map { $0.prefix(length: prefixLength) }
|
||||
Language.truncatedWordSetCache[self] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum DecodingError : LocalizedError {
|
||||
case generic, inputTooShort, missingLastWord, invalidWord, verificationFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .generic: return NSLocalizedString("Something went wrong. Please check your recovery phrase and try again.", comment: "")
|
||||
case .inputTooShort: return NSLocalizedString("Looks like you didn't enter enough words. Please check your recovery phrase and try again.", comment: "")
|
||||
case .missingLastWord: return NSLocalizedString("You seem to be missing the last word of your recovery phrase. Please check what you entered and try again.", comment: "")
|
||||
case .invalidWord: return NSLocalizedString("There appears to be an invalid word in your recovery phrase. Please check what you entered and try again.", comment: "")
|
||||
case .verificationFailed: return NSLocalizedString("Your recovery phrase couldn't be verified. Please check what you entered and try again.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func hash(hexEncodedString string: String, language: Language = .english) -> String {
|
||||
return encode(hexEncodedString: string).split(separator: " ")[0..<3].joined(separator: " ")
|
||||
}
|
||||
|
||||
public static func encode(hexEncodedString string: String, language: Language = .english) -> String {
|
||||
var string = string
|
||||
let wordSet = language.loadWordSet()
|
||||
let prefixLength = language.prefixLength
|
||||
var result: [String] = []
|
||||
let n = wordSet.count
|
||||
let characterCount = string.indices.count // Safe for this particular case
|
||||
for chunkStartIndexAsInt in stride(from: 0, to: characterCount, by: 8) {
|
||||
let chunkStartIndex = string.index(string.startIndex, offsetBy: chunkStartIndexAsInt)
|
||||
let chunkEndIndex = string.index(chunkStartIndex, offsetBy: 8)
|
||||
let p1 = string[string.startIndex..<chunkStartIndex]
|
||||
let p2 = swap(String(string[chunkStartIndex..<chunkEndIndex]))
|
||||
let p3 = string[chunkEndIndex..<string.endIndex]
|
||||
string = String(p1 + p2 + p3)
|
||||
}
|
||||
for chunkStartIndexAsInt in stride(from: 0, to: characterCount, by: 8) {
|
||||
let chunkStartIndex = string.index(string.startIndex, offsetBy: chunkStartIndexAsInt)
|
||||
let chunkEndIndex = string.index(chunkStartIndex, offsetBy: 8)
|
||||
let x = Int(string[chunkStartIndex..<chunkEndIndex], radix: 16)!
|
||||
let w1 = x % n
|
||||
let w2 = ((x / n) + w1) % n
|
||||
let w3 = (((x / n) / n) + w2) % n
|
||||
result += [ wordSet[w1], wordSet[w2], wordSet[w3] ]
|
||||
}
|
||||
let checksumIndex = determineChecksumIndex(for: result, prefixLength: prefixLength)
|
||||
let checksumWord = result[checksumIndex]
|
||||
result.append(checksumWord)
|
||||
return result.joined(separator: " ")
|
||||
}
|
||||
|
||||
public static func decode(mnemonic: String, language: Language = .english) throws -> String {
|
||||
var words = mnemonic.split(separator: " ").map { String($0) }
|
||||
let truncatedWordSet = language.loadTruncatedWordSet()
|
||||
let prefixLength = language.prefixLength
|
||||
var result = ""
|
||||
let n = truncatedWordSet.count
|
||||
// Check preconditions
|
||||
guard words.count >= 12 else { throw DecodingError.inputTooShort }
|
||||
guard !words.count.isMultiple(of: 3) else { throw DecodingError.missingLastWord }
|
||||
// Get checksum word
|
||||
let checksumWord = words.popLast()!
|
||||
// Decode
|
||||
for chunkStartIndex in stride(from: 0, to: words.count, by: 3) {
|
||||
guard let w1 = truncatedWordSet.firstIndex(of: words[chunkStartIndex].prefix(length: prefixLength)),
|
||||
let w2 = truncatedWordSet.firstIndex(of: words[chunkStartIndex + 1].prefix(length: prefixLength)),
|
||||
let w3 = truncatedWordSet.firstIndex(of: words[chunkStartIndex + 2].prefix(length: prefixLength)) else { throw DecodingError.invalidWord }
|
||||
let x = w1 + n * ((n - w1 + w2) % n) + n * n * ((n - w2 + w3) % n)
|
||||
guard x % n == w1 else { throw DecodingError.generic }
|
||||
let string = "0000000" + String(x, radix: 16)
|
||||
result += swap(String(string[string.index(string.endIndex, offsetBy: -8)..<string.endIndex]))
|
||||
}
|
||||
// Verify checksum
|
||||
let checksumIndex = determineChecksumIndex(for: words, prefixLength: prefixLength)
|
||||
let expectedChecksumWord = words[checksumIndex]
|
||||
guard expectedChecksumWord.prefix(length: prefixLength) == checksumWord.prefix(length: prefixLength) else { throw DecodingError.verificationFailed }
|
||||
// Return
|
||||
return result
|
||||
}
|
||||
|
||||
private static func swap(_ x: String) -> String {
|
||||
func toStringIndex(_ indexAsInt: Int) -> String.Index {
|
||||
return x.index(x.startIndex, offsetBy: indexAsInt)
|
||||
}
|
||||
let p1 = x[toStringIndex(6)..<toStringIndex(8)]
|
||||
let p2 = x[toStringIndex(4)..<toStringIndex(6)]
|
||||
let p3 = x[toStringIndex(2)..<toStringIndex(4)]
|
||||
let p4 = x[toStringIndex(0)..<toStringIndex(2)]
|
||||
return String(p1 + p2 + p3 + p4)
|
||||
}
|
||||
|
||||
private static func determineChecksumIndex(for x: [String], prefixLength: UInt) -> Int {
|
||||
let checksum = Array(x.map { $0.prefix(length: prefixLength) }.joined().utf8).crc32()
|
||||
return Int(checksum) % x.count
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
|
||||
func prefix(length: UInt) -> String {
|
||||
return String(self[startIndex..<index(startIndex, offsetBy: Int(length))])
|
||||
}
|
||||
}
|
||||
|
||||
@objc(LKMnemonic)
|
||||
public final class ObjCMnemonic : NSObject {
|
||||
|
||||
override private init() { }
|
||||
|
||||
@objc(hashHexEncodedString:)
|
||||
public static func hash(hexEncodedString string: String) -> String {
|
||||
return Mnemonic.hash(hexEncodedString: string)
|
||||
}
|
||||
|
||||
@objc(encodeHexEncodedString:)
|
||||
public static func encode(hexEncodedString string: String) -> String {
|
||||
return Mnemonic.encode(hexEncodedString: string)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,274 @@
|
|||
import PromiseKit
|
||||
|
||||
// A few notes about making changes in this file:
|
||||
//
|
||||
// • Don't use a database transaction if you can avoid it.
|
||||
// • If you do need to use a database transaction, use a read transaction if possible.
|
||||
// • For write transactions, consider making it the caller's responsibility to manage the database transaction (this helps avoid unnecessary transactions).
|
||||
// • Think carefully about adding a function; there might already be one for what you need.
|
||||
// • Document the expected cases in which a function will be used
|
||||
// • Express those cases in tests.
|
||||
|
||||
@objc(LKMultiDeviceProtocol)
|
||||
public final class MultiDeviceProtocol : NSObject {
|
||||
|
||||
/// A mapping from hex encoded public key to date updated.
|
||||
///
|
||||
/// - Note: Should only be accessed from `LokiAPI.workQueue` to avoid race conditions.
|
||||
public static var lastDeviceLinkUpdate: [String:Date] = [:]
|
||||
|
||||
internal static var storage: OWSPrimaryStorage { OWSPrimaryStorage.shared() }
|
||||
|
||||
// MARK: Settings
|
||||
public static let deviceLinkUpdateInterval: TimeInterval = 60
|
||||
|
||||
// MARK: Multi Device Destination
|
||||
public struct MultiDeviceDestination : Hashable {
|
||||
public let publicKey: String
|
||||
public let isMaster: Bool
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
@objc(isUnlinkDeviceMessage:)
|
||||
public static func isUnlinkDeviceMessage(_ dataMessage: SSKProtoDataMessage) -> Bool {
|
||||
let unlinkDeviceFlag = SSKProtoDataMessage.SSKProtoDataMessageFlags.unlinkDevice
|
||||
return dataMessage.flags & UInt32(unlinkDeviceFlag.rawValue) != 0
|
||||
}
|
||||
|
||||
public static func getUserLinkedDevices() -> Set<String> {
|
||||
var result: Set<String> = []
|
||||
storage.dbReadConnection.read { transaction in
|
||||
result = LokiDatabaseUtilities.getLinkedDeviceHexEncodedPublicKeys(for: getUserHexEncodedPublicKey(), in: transaction)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@objc public static func isSlaveThread(_ thread: TSThread) -> Bool {
|
||||
guard let thread = thread as? TSContactThread else { return false }
|
||||
var isSlaveThread = false
|
||||
storage.dbReadConnection.read { transaction in
|
||||
isSlaveThread = storage.getMasterHexEncodedPublicKey(for: thread.contactIdentifier(), in: transaction) != nil
|
||||
}
|
||||
return isSlaveThread
|
||||
}
|
||||
|
||||
// MARK: - Sending (Part 1)
|
||||
|
||||
@objc(isMultiDeviceRequiredForMessage:toPublicKey:)
|
||||
public static func isMultiDeviceRequired(for message: TSOutgoingMessage, to publicKey: String) -> Bool {
|
||||
return !(message is DeviceLinkMessage) && !(message is UnlinkDeviceMessage) && (message.thread as? TSGroupThread)?.groupModel.groupType != .openGroup
|
||||
&& !Storage.getUserClosedGroupPublicKeys().contains(publicKey)
|
||||
}
|
||||
|
||||
private static func copy(_ messageSend: OWSMessageSend, for destination: MultiDeviceDestination, with seal: Resolver<Void>) -> OWSMessageSend {
|
||||
var recipient: SignalRecipient!
|
||||
storage.dbReadConnection.read { transaction in
|
||||
recipient = SignalRecipient.getOrBuildUnsavedRecipient(forRecipientId: destination.publicKey, transaction: transaction)
|
||||
}
|
||||
// TODO: Why is it okay that the thread, sender certificate, etc. don't get changed?
|
||||
return OWSMessageSend(message: messageSend.message, thread: messageSend.thread, recipient: recipient,
|
||||
senderCertificate: messageSend.senderCertificate, udAccess: messageSend.udAccess, localNumber: messageSend.localNumber, success: {
|
||||
seal.fulfill(())
|
||||
}, failure: { error in
|
||||
seal.reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
private static func sendMessage(_ messageSend: OWSMessageSend, to destination: MultiDeviceDestination, in transaction: YapDatabaseReadTransaction) -> Promise<Void> {
|
||||
let (threadPromise, threadPromiseSeal) = Promise<TSThread>.pending()
|
||||
if messageSend.message.thread.isGroupThread() {
|
||||
threadPromiseSeal.fulfill(messageSend.message.thread)
|
||||
} else if let thread = TSContactThread.getWithContactId(destination.publicKey, transaction: transaction) {
|
||||
threadPromiseSeal.fulfill(thread)
|
||||
} else {
|
||||
Storage.write { transaction in
|
||||
let thread = TSContactThread.getOrCreateThread(withContactId: destination.publicKey, transaction: transaction)
|
||||
threadPromiseSeal.fulfill(thread)
|
||||
}
|
||||
}
|
||||
return threadPromise.then2 { thread -> Promise<Void> in
|
||||
let message = messageSend.message
|
||||
let messageSender = SSKEnvironment.shared.messageSender
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let messageSendCopy = copy(messageSend, for: destination, with: seal)
|
||||
OWSDispatch.sendingQueue().async {
|
||||
messageSender.sendMessage(messageSendCopy)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
||||
/// See [Multi Device Message Sending](https://github.com/loki-project/session-protocol-docs/wiki/Multi-Device-Message-Sending) for more information.
|
||||
@objc(sendMessageToDestinationAndLinkedDevices:transaction:)
|
||||
public static func sendMessageToDestinationAndLinkedDevices(_ messageSend: OWSMessageSend, in transaction: YapDatabaseReadTransaction) {
|
||||
// if !messageSend.isUDSend && messageSend.recipient.recipientId() != getUserHexEncodedPublicKey() {
|
||||
// #if DEBUG
|
||||
// preconditionFailure()
|
||||
// #endif
|
||||
// }
|
||||
let message = messageSend.message
|
||||
let messageSender = SSKEnvironment.shared.messageSender
|
||||
if !isMultiDeviceRequired(for: message, to: messageSend.recipient.recipientId()) {
|
||||
print("[Loki] sendMessageToDestinationAndLinkedDevices(_:in:) invoked for a message that doesn't require multi device routing.")
|
||||
OWSDispatch.sendingQueue().async {
|
||||
messageSender.sendMessage(messageSend)
|
||||
}
|
||||
return
|
||||
}
|
||||
print("[Loki] Sending \(type(of: message)) message using multi device routing.")
|
||||
let publicKey = messageSend.recipient.recipientId()
|
||||
getMultiDeviceDestinations(for: publicKey, in: transaction).done2 { destinations in
|
||||
var promises: [Promise<Void>] = []
|
||||
let masterDestination = destinations.first { $0.isMaster }
|
||||
if let masterDestination = masterDestination {
|
||||
storage.dbReadConnection.read { transaction in
|
||||
promises.append(sendMessage(messageSend, to: masterDestination, in: transaction))
|
||||
}
|
||||
}
|
||||
let slaveDestinations = destinations.filter { !$0.isMaster }
|
||||
slaveDestinations.forEach { slaveDestination in
|
||||
storage.dbReadConnection.read { transaction in
|
||||
promises.append(sendMessage(messageSend, to: slaveDestination, in: transaction))
|
||||
}
|
||||
}
|
||||
when(resolved: promises).done(on: OWSDispatch.sendingQueue()) { results in
|
||||
let errors = results.compactMap { result -> Error? in
|
||||
if case Result.rejected(let error) = result {
|
||||
return error
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if errors.isEmpty {
|
||||
messageSend.success()
|
||||
} else {
|
||||
messageSend.failure(errors.first!)
|
||||
}
|
||||
}
|
||||
}.catch2 { error in
|
||||
// Proceed even if updating the recipient's device links failed, so that message sending
|
||||
// is independent of whether the file server is online
|
||||
OWSDispatch.sendingQueue().async {
|
||||
messageSender.sendMessage(messageSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(updateDeviceLinksIfNeededForPublicKey:transaction:)
|
||||
public static func updateDeviceLinksIfNeeded(for publicKey: String, in transaction: YapDatabaseReadTransaction) -> AnyPromise {
|
||||
return AnyPromise.from(getMultiDeviceDestinations(for: publicKey, in: transaction))
|
||||
}
|
||||
|
||||
// MARK: - Receiving
|
||||
|
||||
@objc(handleDeviceLinkMessageIfNeeded:wrappedIn:transaction:)
|
||||
public static func handleDeviceLinkMessageIfNeeded(_ protoContent: SSKProtoContent, wrappedIn envelope: SSKProtoEnvelope, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
let publicKey = envelope.source! // Set during UD decryption
|
||||
guard let deviceLinkMessage = protoContent.lokiDeviceLinkMessage, let master = deviceLinkMessage.masterPublicKey,
|
||||
let slave = deviceLinkMessage.slavePublicKey, let slaveSignature = deviceLinkMessage.slaveSignature else {
|
||||
return print("[Loki] Received an invalid device link message.")
|
||||
}
|
||||
let deviceLinkingSession = DeviceLinkingSession.current
|
||||
if let masterSignature = deviceLinkMessage.masterSignature { // Authorization
|
||||
print("[Loki] Received a device link authorization from: \(publicKey).") // Intentionally not `master`
|
||||
if let deviceLinkingSession = deviceLinkingSession {
|
||||
deviceLinkingSession.processLinkingAuthorization(from: master, for: slave, masterSignature: masterSignature, slaveSignature: slaveSignature)
|
||||
} else {
|
||||
print("[Loki] Received a device link authorization without a session; ignoring.")
|
||||
}
|
||||
// Set any profile info (the device link authorization also includes the master device's profile info)
|
||||
if let dataMessage = protoContent.dataMessage {
|
||||
SessionMetaProtocol.updateDisplayNameIfNeeded(for: master, using: dataMessage, in: transaction)
|
||||
SessionMetaProtocol.updateProfileKeyIfNeeded(for: master, using: dataMessage)
|
||||
}
|
||||
} else { // Request
|
||||
print("[Loki] Received a device link request from: \(publicKey).") // Intentionally not `slave`
|
||||
if let deviceLinkingSession = deviceLinkingSession {
|
||||
deviceLinkingSession.processLinkingRequest(from: slave, to: master, with: slaveSignature)
|
||||
} else {
|
||||
NotificationCenter.default.post(name: .unexpectedDeviceLinkRequestReceived, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(handleUnlinkDeviceMessage:wrappedIn:transaction:)
|
||||
public static func handleUnlinkDeviceMessage(_ dataMessage: SSKProtoDataMessage, wrappedIn envelope: SSKProtoEnvelope, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
let publicKey = envelope.source! // Set during UD decryption
|
||||
// Check that the request was sent by our master device
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
guard let userMasterPublicKey = storage.getMasterHexEncodedPublicKey(for: userPublicKey, in: transaction) else { return }
|
||||
let wasSentByMasterDevice = (userMasterPublicKey == publicKey)
|
||||
guard wasSentByMasterDevice else { return }
|
||||
// Ignore the request if we don't know about the device link in question
|
||||
let masterDeviceLinks = storage.getDeviceLinks(for: userMasterPublicKey, in: transaction)
|
||||
if !masterDeviceLinks.contains(where: {
|
||||
$0.master.publicKey == userMasterPublicKey && $0.slave.publicKey == userPublicKey
|
||||
}) {
|
||||
return
|
||||
}
|
||||
FileServerAPI.getDeviceLinks(associatedWith: userPublicKey).done2 { slaveDeviceLinks in
|
||||
// Check that the device link IS present on the file server.
|
||||
// Note that the device link as seen from the master device's perspective has been deleted at this point, but the
|
||||
// device link as seen from the slave perspective hasn't.
|
||||
if slaveDeviceLinks.contains(where: {
|
||||
$0.master.publicKey == userMasterPublicKey && $0.slave.publicKey == userPublicKey
|
||||
}) {
|
||||
for deviceLink in slaveDeviceLinks { // In theory there should only be one
|
||||
FileServerAPI.removeDeviceLink(deviceLink) // Attempt to clean up on the file server
|
||||
}
|
||||
UserDefaults.standard[.wasUnlinked] = true
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .dataNukeRequested, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sending (Part 2)
|
||||
|
||||
// Here (in a non-@objc extension) because it doesn't interoperate well with Obj-C
|
||||
public extension MultiDeviceProtocol {
|
||||
|
||||
fileprivate static func getMultiDeviceDestinations(for publicKey: String, in transaction: YapDatabaseReadTransaction) -> Promise<Set<MultiDeviceDestination>> {
|
||||
let (promise, seal) = Promise<Set<MultiDeviceDestination>>.pending()
|
||||
func getDestinations(in transaction: YapDatabaseReadTransaction? = nil) {
|
||||
storage.dbReadConnection.read { transaction in
|
||||
var destinations: Set<MultiDeviceDestination> = []
|
||||
let masterPublicKey = storage.getMasterHexEncodedPublicKey(for: publicKey, in: transaction) ?? publicKey
|
||||
let masterDestination = MultiDeviceDestination(publicKey: masterPublicKey, isMaster: true)
|
||||
destinations.insert(masterDestination)
|
||||
let deviceLinks = storage.getDeviceLinks(for: masterPublicKey, in: transaction)
|
||||
let slaveDestinations = deviceLinks.map { MultiDeviceDestination(publicKey: $0.slave.publicKey, isMaster: false) }
|
||||
destinations.formUnion(slaveDestinations)
|
||||
seal.fulfill(destinations)
|
||||
}
|
||||
}
|
||||
let timeSinceLastUpdate: TimeInterval
|
||||
if let lastDeviceLinkUpdate = lastDeviceLinkUpdate[publicKey] {
|
||||
timeSinceLastUpdate = Date().timeIntervalSince(lastDeviceLinkUpdate)
|
||||
} else {
|
||||
timeSinceLastUpdate = .infinity
|
||||
}
|
||||
if timeSinceLastUpdate > deviceLinkUpdateInterval {
|
||||
let masterPublicKey = storage.getMasterHexEncodedPublicKey(for: publicKey, in: transaction) ?? publicKey
|
||||
FileServerAPI.getDeviceLinks(associatedWith: masterPublicKey).done2 { _ in
|
||||
getDestinations()
|
||||
lastDeviceLinkUpdate[publicKey] = Date()
|
||||
}.catch2 { error in
|
||||
if (error as? DotNetAPI.Error) == DotNetAPI.Error.parsingFailed {
|
||||
// Don't immediately re-fetch in case of failure due to a parsing error
|
||||
lastDeviceLinkUpdate[publicKey] = Date()
|
||||
getDestinations()
|
||||
} else {
|
||||
print("[Loki] Failed to get device links due to error: \(error).")
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getDestinations()
|
||||
}
|
||||
return promise
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface NSArray (Functional)
|
||||
|
||||
- (BOOL)contains:(BOOL (^)(id))predicate;
|
||||
- (NSArray *)filtered:(BOOL (^)(id))isIncluded;
|
||||
- (NSArray *)map:(id (^)(id))transform;
|
||||
|
||||
@end
|
|
@ -0,0 +1,32 @@
|
|||
#import "NSArray+Functional.h"
|
||||
|
||||
@implementation NSArray (Functional)
|
||||
|
||||
- (BOOL)contains:(BOOL (^)(id))predicate {
|
||||
for (id object in self) {
|
||||
BOOL isPredicateSatisfied = predicate(object);
|
||||
if (isPredicateSatisfied) { return YES; }
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (NSArray *)filtered:(BOOL (^)(id))isIncluded {
|
||||
NSMutableArray *result = [NSMutableArray new];
|
||||
for (id object in self) {
|
||||
if (isIncluded(object)) {
|
||||
[result addObject:object];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSArray *)map:(id (^)(id))transform {
|
||||
NSMutableArray *result = [NSMutableArray new];
|
||||
for (id object in self) {
|
||||
id transformedObject = transform(object);
|
||||
[result addObject:transformedObject];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@end
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue