Create SignalUtilitiesKit

This commit is contained in:
nielsandriesse 2020-11-11 10:58:56 +11:00
parent 82127bfe4d
commit c475f895e8
439 changed files with 77334 additions and 114 deletions

20
Podfile
View File

@ -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

View File

@ -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

@ -1 +1 @@
Subproject commit 0c79ca436b633fdf1b0daf90e86fd323dcc60c55
Subproject commit e28da414f77b9cba508c92e90b16b815847cde7e

View File

@ -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,

View File

@ -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."

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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> {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}
}

130
SignalUtilitiesKit/AppContext.h Executable file
View File

@ -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

61
SignalUtilitiesKit/AppContext.m Executable file
View File

@ -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

View File

@ -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

144
SignalUtilitiesKit/AppReadiness.m Executable file
View File

@ -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

32
SignalUtilitiesKit/AppVersion.h Executable file
View File

@ -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

133
SignalUtilitiesKit/AppVersion.m Executable file
View File

@ -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

View File

@ -0,0 +1,7 @@
public extension Array where Element : CustomStringConvertible {
public var prettifiedDescription: String {
return "[ " + map { $0.description }.joined(separator: ", ") + " ]"
}
}

View File

@ -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 }
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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 }
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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()
* ================
*/
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}
}

67
SignalUtilitiesKit/DataSource.h Executable file
View File

@ -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

401
SignalUtilitiesKit/DataSource.m Executable file
View File

@ -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

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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)" }
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,6 @@
public protocol DeviceLinkingSessionDelegate {
func requestUserAuthorization(for deviceLink: DeviceLink)
func handleDeviceLinkAuthorized(_ deviceLink: DeviceLink)
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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: ", ") + " ]"
}
}

View File

@ -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: ", ")
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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
}
}

View File

@ -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))
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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")
}
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
public func getUserHexEncodedPublicKey() -> String {
if let keyPair = OWSIdentityManager.shared().identityKeyPair() { // Can be nil under some circumstances
return keyPair.hexEncodedPublicKey
}
return ""
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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