From 9c0f94f1c095bddb3a153c3191a6087209280da5 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 3 Aug 2017 11:13:40 -0400 Subject: [PATCH] Fetch profiles from profile manager. Update profile manager with profile fetch results. // FREEBIE --- Signal/src/Profiles/OWSProfileManager.h | 5 + Signal/src/Profiles/OWSProfileManager.m | 218 ++++++++++++++++++-- Signal/src/Profiles/ProfileFetcherJob.swift | 71 +++++-- Signal/src/Signal-Bridging-Header.h | 1 + 4 files changed, 265 insertions(+), 30 deletions(-) diff --git a/Signal/src/Profiles/OWSProfileManager.h b/Signal/src/Profiles/OWSProfileManager.h index 5503f80a6..2d2530a2a 100644 --- a/Signal/src/Profiles/OWSProfileManager.h +++ b/Signal/src/Profiles/OWSProfileManager.h @@ -59,6 +59,11 @@ extern NSString *const kNSNotificationName_OtherUsersProfileDidChange; - (void)refreshProfileForRecipientId:(NSString *)recipientId; +- (void)updateProfileForRecipientId:(NSString *)recipientId + profileNameEncrypted:(NSData *_Nullable)profileNameEncrypted + avatarUrlEncrypted:(NSData *_Nullable)avatarUrlEncrypted + avatarDigestEncrypted:(NSData *_Nullable)avatarDigestEncrypted; + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/Profiles/OWSProfileManager.m b/Signal/src/Profiles/OWSProfileManager.m index 2ff9c417f..14a0083d1 100644 --- a/Signal/src/Profiles/OWSProfileManager.m +++ b/Signal/src/Profiles/OWSProfileManager.m @@ -4,6 +4,7 @@ #import "OWSProfileManager.h" #import "Environment.h" +#import "Signal-Swift.h" #import #import #import @@ -27,7 +28,7 @@ NS_ASSUME_NONNULL_BEGIN // These properties may be accessed only from the main thread. @property (nonatomic, nullable) NSString *profileName; @property (nonatomic, nullable) NSString *avatarUrl; -@property (nonatomic, nullable) NSString *avatarDigest; +@property (nonatomic, nullable) NSData *avatarDigest; // This filename is relative to OWSProfileManager.profileAvatarsDirPath. @property (nonatomic, nullable) NSString *avatarFileName; @@ -68,8 +69,7 @@ NS_ASSUME_NONNULL_BEGIN { return ([other isKindOfClass:[UserProfile class]] && [self.recipientId isEqualToString:other.recipientId] && [self.profileName isEqualToString:other.profileName] && [self.avatarUrl isEqualToString:other.avatarUrl] && - [self.avatarDigest isEqualToString:other.avatarDigest] && - [self.avatarFileName isEqualToString:other.avatarFileName]); + [self.avatarDigest isEqual:other.avatarDigest] && [self.avatarFileName isEqualToString:other.avatarFileName]); } - (NSUInteger)hash @@ -97,6 +97,7 @@ static const NSInteger kProfileKeyLength = 16; @property (nonatomic, readonly) OWSMessageSender *messageSender; @property (nonatomic, readonly) YapDatabaseConnection *dbConnection; +@property (nonatomic, readonly) TSNetworkManager *networkManager; @property (atomic, nullable) UserProfile *localUserProfile; // This property should only be mutated on the main thread, @@ -131,12 +132,14 @@ static const NSInteger kProfileKeyLength = 16; { TSStorageManager *storageManager = [TSStorageManager sharedManager]; OWSMessageSender *messageSender = [Environment getCurrent].messageSender; + TSNetworkManager *networkManager = [Environment getCurrent].networkManager; - return [self initWithStorageManager:storageManager messageSender:messageSender]; + return [self initWithStorageManager:storageManager messageSender:messageSender networkManager:networkManager]; } - (instancetype)initWithStorageManager:(TSStorageManager *)storageManager messageSender:(OWSMessageSender *)messageSender + networkManager:(TSNetworkManager *)networkManager { self = [super init]; @@ -147,9 +150,12 @@ static const NSInteger kProfileKeyLength = 16; OWSAssert([NSThread isMainThread]); OWSAssert(storageManager); OWSAssert(messageSender); + OWSAssert(messageSender); _messageSender = messageSender; _dbConnection = storageManager.newDatabaseConnection; + _networkManager = networkManager; + _userProfileWhitelistCache = [NSMutableDictionary new]; _groupProfileWhitelistCache = [NSMutableDictionary new]; _otherUsersProfileAvatarImageCache = [NSCache new]; @@ -217,6 +223,16 @@ static const NSInteger kProfileKeyLength = 16; [self.dbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [userProfile saveWithTransaction:transaction]; }]; + + if (userProfile == self.localUserProfile) { + [[NSNotificationCenter defaultCenter] postNotificationName:kNSNotificationName_LocalProfileDidChange + object:nil + userInfo:nil]; + } else { + [[NSNotificationCenter defaultCenter] postNotificationName:kNSNotificationName_OtherUsersProfileDidChange + object:nil + userInfo:nil]; + } } #pragma mark - Local Profile Key @@ -271,8 +287,8 @@ static const NSInteger kProfileKeyLength = 16; // // * Try to update the service. // * Update client state on success. - void (^tryToUpdateService)(NSString *_Nullable, NSString *_Nullable, NSString *_Nullable) = ^( - NSString *_Nullable avatarUrl, NSString *_Nullable avatarDigest, NSString *_Nullable avatarFileName) { + void (^tryToUpdateService)(NSString *_Nullable, NSData *_Nullable, NSString *_Nullable) = ^( + NSString *_Nullable avatarUrl, NSData *_Nullable avatarDigest, NSString *_Nullable avatarFileName) { [self updateProfileOnService:profileName avatarUrl:avatarUrl avatarDigest:avatarDigest @@ -291,10 +307,6 @@ static const NSInteger kProfileKeyLength = 16; self.localCachedAvatarImage = avatarImage; successBlock(); - - [[NSNotificationCenter defaultCenter] postNotificationName:kNSNotificationName_LocalProfileDidChange - object:nil - userInfo:nil]; }); } failure:^{ @@ -325,7 +337,7 @@ static const NSInteger kProfileKeyLength = 16; success:^(NSData *data, NSString *fileName) { [self uploadAvatarToService:data fileName:fileName - success:^(NSString *avatarUrl, NSString *avatarDigest) { + success:^(NSString *avatarUrl, NSData *avatarDigest) { tryToUpdateService(avatarUrl, avatarDigest, fileName); } failure:^{ @@ -372,7 +384,7 @@ static const NSInteger kProfileKeyLength = 16; // TODO: The exact API & encryption scheme for avatars is not yet settled. - (void)uploadAvatarToService:(NSData *)data fileName:(NSString *)fileName - success:(void (^)(NSString *avatarUrl, NSString *avatarDigest))successBlock + success:(void (^)(NSString *avatarUrl, NSData *avatarDigest))successBlock failure:(void (^)())failureBlock { OWSAssert(data.length > 0); @@ -383,7 +395,7 @@ static const NSInteger kProfileKeyLength = 16; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ // TODO: NSString *avatarUrl = @"avatarUrl"; - NSString *avatarDigest = @"avatarDigest"; + NSData *avatarDigest = [@"avatarDigest" dataUsingEncoding:NSUTF8StringEncoding]; if (YES) { successBlock(avatarUrl, avatarDigest); return; @@ -395,7 +407,7 @@ static const NSInteger kProfileKeyLength = 16; // TODO: The exact API & encryption scheme for profiles is not yet settled. - (void)updateProfileOnService:(nullable NSString *)localProfileName avatarUrl:(nullable NSString *)avatarUrl - avatarDigest:(nullable NSString *)avatarDigest + avatarDigest:(nullable NSData *)avatarDigest success:(void (^)())successBlock failure:(void (^)())failureBlock { @@ -403,6 +415,12 @@ static const NSInteger kProfileKeyLength = 16; OWSAssert(failureBlock); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // TODO: Do we need to use NSDataBase64EncodingOptions? + NSString *_Nullable localProfileNameEncrypted = + [[self encryptProfileString:localProfileName] base64EncodedString]; + NSString *_Nullable avatarUrlEncrypted = [[self encryptProfileString:avatarUrl] base64EncodedString]; + NSString *_Nullable avatarDigestEncrypted = [[self encryptProfileData:avatarDigest] base64EncodedString]; + // TODO: if (YES) { successBlock(); @@ -534,6 +552,8 @@ static const NSInteger kProfileKeyLength = 16; userProfile.profileKey = profileKey; [self saveUserProfile:userProfile]; + + [self refreshProfileForRecipientId:recipientId ignoreThrottling:YES]; }); } @@ -575,18 +595,40 @@ static const NSInteger kProfileKeyLength = 16; if (image) { [self.otherUsersProfileAvatarImageCache setObject:image forKey:recipientId]; } + } else if (userProfile.avatarUrl) { + [self downloadProfileAvatarWithUrl:userProfile.avatarUrl recipientId:recipientId]; } return image; } +- (void)downloadProfileAvatarWithUrl:(NSString *)avatarUrl recipientId:(NSString *)recipientId +{ + OWSAssert(avatarUrl.length > 0); + OWSAssert(recipientId.length > 0); + + // TODO: +} + - (void)refreshProfileForRecipientId:(NSString *)recipientId +{ + [self refreshProfileForRecipientId:recipientId ignoreThrottling:NO]; +} + +- (void)refreshProfileForRecipientId:(NSString *)recipientId ignoreThrottling:(BOOL)ignoreThrottling { OWSAssert([NSThread isMainThread]); OWSAssert(recipientId.length > 0); UserProfile *userProfile = [self getOrCreateUserProfileForRecipientId:recipientId]; + if (!userProfile.profileKey) { + // There's no point in fetching the profile for a user + // if we don't have their profile key; we won't be able + // to decrypt it. + return; + } + // Throttle and debounce the updates. const NSTimeInterval kMaxRefreshFrequency = 5 * kMinuteInterval; if (userProfile.lastUpdateDate && fabs([userProfile.lastUpdateDate timeIntervalSinceNow]) < kMaxRefreshFrequency) { @@ -598,7 +640,153 @@ static const NSInteger kProfileKeyLength = 16; [self saveUserProfile:userProfile]; - // TODO: Actually update the profile. + [ProfileFetcherJob runWithRecipientId:recipientId + networkManager:self.networkManager + ignoreThrottling:ignoreThrottling]; +} + +- (void)updateProfileForRecipientId:(NSString *)recipientId + profileNameEncrypted:(NSData *_Nullable)profileNameEncrypted + avatarUrlEncrypted:(NSData *_Nullable)avatarUrlEncrypted + avatarDigestEncrypted:(NSData *_Nullable)avatarDigestEncrypted +{ + OWSAssert(recipientId.length > 0); + + UserProfile *userProfile = [self getOrCreateUserProfileForRecipientId:recipientId]; + if (!userProfile.profileKey) { + return; + } + + NSString *_Nullable profileName = + [self decryptProfileString:profileNameEncrypted profileKey:userProfile.profileKey]; + NSString *_Nullable avatarUrl = [self decryptProfileString:avatarUrlEncrypted profileKey:userProfile.profileKey]; + NSData *_Nullable avatarDigest = [self decryptProfileData:avatarDigestEncrypted profileKey:userProfile.profileKey]; + + if (!avatarUrl || !avatarDigest) { + // If either avatar url or digest is missing, skip both. + avatarUrl = nil; + avatarDigest = nil; + } + + BOOL isProfileNameSame = [self isNullableStringEqual:userProfile.profileName toString:profileName]; + BOOL isAvatarSame = ([self isNullableStringEqual:userProfile.avatarUrl toString:avatarUrl] && + [self isNullableDataEqual:userProfile.avatarDigest toData:avatarDigest]); + if (isProfileNameSame && isAvatarSame) { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + userProfile.profileName = profileName; + userProfile.avatarUrl = avatarUrl; + userProfile.avatarDigest = avatarDigest; + + if (!isAvatarSame) { + // Evacuate avatar image cache. + [self.otherUsersProfileAvatarImageCache removeObjectForKey:recipientId]; + } + + if (avatarUrl) { + [self downloadProfileAvatarWithUrl:avatarUrl recipientId:recipientId]; + } + + [self saveUserProfile:userProfile]; + }); +} + +- (BOOL)isNullableDataEqual:(NSData *_Nullable)left toData:(NSData *_Nullable)right +{ + if (left == nil && right == nil) { + return YES; + } else if (left == nil || right == nil) { + return YES; + } else { + return [left isEqual:right]; + } +} + +- (BOOL)isNullableStringEqual:(NSString *_Nullable)left toString:(NSString *_Nullable)right +{ + if (left == nil && right == nil) { + return YES; + } else if (left == nil || right == nil) { + return YES; + } else { + return [left isEqualToString:right]; + } +} + +#pragma mark - Profile Encryption + ++ (NSData *_Nullable)decryptProfileData:(NSData *_Nullable)encryptedData profileKey:(NSData *)profileKey +{ + OWSAssert(profileKey.length == kProfileKeyLength); + + if (!encryptedData) { + return nil; + } + + // TODO: Decrypt. + return nil; +} + ++ (NSString *_Nullable)decryptProfileString:(NSData *_Nullable)encryptedData profileKey:(NSData *)profileKey +{ + OWSAssert(profileKey.length == kProfileKeyLength); + + NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKey]; + + if (decryptedData) { + return [[NSString alloc] initWithData:decryptedData encoding:NSUTF8StringEncoding]; + } else { + return nil; + } +} + ++ (NSData *_Nullable)encryptProfileData:(NSData *_Nullable)data profileKey:(NSData *)profileKey +{ + OWSAssert(profileKey.length == kProfileKeyLength); + + if (!data) { + return nil; + } + + // TODO: Encrypt. + return nil; +} + ++ (NSData *_Nullable)encryptProfileString:(NSString *_Nullable)value profileKey:(NSData *)profileKey +{ + OWSAssert(profileKey.length == kProfileKeyLength); + + if (value) { + NSData *_Nullable data = [value dataUsingEncoding:NSUTF8StringEncoding]; + if (data) { + NSData *_Nullable encryptedData = [self encryptProfileData:data profileKey:profileKey]; + return encryptedData; + } + } + + return nil; +} + +- (NSData *_Nullable)decryptProfileData:(NSData *_Nullable)encryptedData profileKey:(NSData *)profileKey +{ + return [OWSProfileManager decryptProfileData:encryptedData profileKey:profileKey]; +} + +- (NSString *_Nullable)decryptProfileString:(NSData *_Nullable)encryptedData profileKey:(NSData *)profileKey +{ + return [OWSProfileManager decryptProfileString:encryptedData profileKey:profileKey]; +} + +- (NSData *_Nullable)encryptProfileData:(NSData *_Nullable)data +{ + return [OWSProfileManager encryptProfileData:data profileKey:self.localProfileKey]; +} + +- (NSData *_Nullable)encryptProfileString:(NSString *_Nullable)value +{ + return [OWSProfileManager encryptProfileString:value profileKey:self.localProfileKey]; } #pragma mark - Avatar Disk Cache diff --git a/Signal/src/Profiles/ProfileFetcherJob.swift b/Signal/src/Profiles/ProfileFetcherJob.swift index d86fa6eac..ffda36563 100644 --- a/Signal/src/Profiles/ProfileFetcherJob.swift +++ b/Signal/src/Profiles/ProfileFetcherJob.swift @@ -16,17 +16,20 @@ class ProfileFetcherJob: NSObject { // This property is only accessed on the main queue. static var fetchDateMap = [String: Date]() + let ignoreThrottling: Bool + public class func run(thread: TSThread, networkManager: TSNetworkManager) { ProfileFetcherJob(networkManager: networkManager).run(recipientIds: thread.recipientIdentifiers) } - public class func run(recipientId: String, networkManager: TSNetworkManager) { - ProfileFetcherJob(networkManager: networkManager).run(recipientIds: [recipientId]) + public class func run(recipientId: String, networkManager: TSNetworkManager, ignoreThrottling: Bool) { + ProfileFetcherJob(networkManager: networkManager, ignoreThrottling:ignoreThrottling).run(recipientIds: [recipientId]) } - init(networkManager: TSNetworkManager) { + init(networkManager: TSNetworkManager, ignoreThrottling: Bool = false) { self.networkManager = networkManager self.storageManager = TSStorageManager.shared() + self.ignoreThrottling = ignoreThrottling } public func run(recipientIds: [String]) { @@ -65,15 +68,17 @@ class ProfileFetcherJob: NSObject { public func getProfile(recipientId: String) -> Promise { AssertIsOnMainThread() - if let lastDate = ProfileFetcherJob.fetchDateMap[recipientId] { - let lastTimeInterval = fabs(lastDate.timeIntervalSinceNow) - // Don't check a profile more often than every N minutes. - // - // Only throttle profile fetch in production builds in order to - // facilitate debugging. - let kGetProfileMaxFrequencySeconds = _isDebugAssertConfiguration() ? 0 : 60.0 * 5.0 - guard lastTimeInterval > kGetProfileMaxFrequencySeconds else { - return Promise(error: ProfileFetcherJobError.throttled(lastTimeInterval: lastTimeInterval)) + if !ignoreThrottling { + if let lastDate = ProfileFetcherJob.fetchDateMap[recipientId] { + let lastTimeInterval = fabs(lastDate.timeIntervalSinceNow) + // Don't check a profile more often than every N minutes. + // + // Only throttle profile fetch in production builds in order to + // facilitate debugging. + let kGetProfileMaxFrequencySeconds = _isDebugAssertConfiguration() ? 0 : 60.0 * 5.0 + guard lastTimeInterval > kGetProfileMaxFrequencySeconds else { + return Promise(error: ProfileFetcherJobError.throttled(lastTimeInterval: lastTimeInterval)) + } } } ProfileFetcherJob.fetchDateMap[recipientId] = Date() @@ -109,7 +114,10 @@ class ProfileFetcherJob: NSObject { private func updateProfile(signalServiceProfile: SignalServiceProfile) { verifyIdentityUpToDateAsync(recipientId: signalServiceProfile.recipientId, latestIdentityKey: signalServiceProfile.identityKey) - // Eventually we'll want to do more things with new SignalServiceProfile fields here. + OWSProfileManager.shared().updateProfile(forRecipientId : signalServiceProfile.recipientId, + profileNameEncrypted : signalServiceProfile.profileNameEncrypted, + avatarUrlEncrypted : signalServiceProfile.avatarUrlEncrypted, + avatarDigestEncrypted : signalServiceProfile.avatarDigestEncrypted) } private func verifyIdentityUpToDateAsync(recipientId: String, latestIdentityKey: Data) { @@ -130,14 +138,22 @@ struct SignalServiceProfile { enum ValidationError: Error { case invalid(description: String) case invalidIdentityKey(description: String) + case invalidProfileName(description: String) + case invalidAvatarUrl(description: String) + case invalidAvatarDigest(description: String) } public let recipientId: String public let identityKey: Data + public let profileNameEncrypted: Data? + public let avatarUrlEncrypted: Data? + public let avatarDigestEncrypted: Data? init(recipientId: String, rawResponse: Any?) throws { self.recipientId = recipientId + Logger.info("rawResponse: \(rawResponse)") + guard let responseDict = rawResponse as? [String: Any?] else { throw ValidationError.invalid(description: "\(TAG) unexpected type: \(String(describing: rawResponse))") } @@ -145,17 +161,42 @@ struct SignalServiceProfile { guard let identityKeyString = responseDict["identityKey"] as? String else { throw ValidationError.invalidIdentityKey(description: "\(TAG) missing identity key: \(String(describing: rawResponse))") } - guard let identityKeyWithType = Data(base64Encoded: identityKeyString) else { throw ValidationError.invalidIdentityKey(description: "\(TAG) unable to parse identity key: \(identityKeyString)") } - let kIdentityKeyLength = 33 guard identityKeyWithType.count == kIdentityKeyLength else { throw ValidationError.invalidIdentityKey(description: "\(TAG) malformed key \(identityKeyString) with decoded length: \(identityKeyWithType.count)") } + var profileNameEncrypted: Data? = nil + if let profileNameString = responseDict["name"] as? String { + guard let data = Data(base64Encoded: profileNameString) else { + throw ValidationError.invalidProfileName(description: "\(TAG) unable to parse profile name: \(profileNameString)") + } + profileNameEncrypted = data + } + + var avatarUrlEncrypted: Data? = nil + if let avatarUrlString = responseDict["avatar"] as? String { + guard let data = Data(base64Encoded: avatarUrlString) else { + throw ValidationError.invalidAvatarUrl(description: "\(TAG) unable to parse avatar URL: \(avatarUrlString)") + } + avatarUrlEncrypted = data + } + + var avatarDigestEncrypted: Data? = nil + if let avatarDigestString = responseDict["avatarDigest"] as? String { + guard let data = Data(base64Encoded: avatarDigestString) else { + throw ValidationError.invalidAvatarDigest(description: "\(TAG) unable to parse avatar digest: \(avatarDigestString)") + } + avatarDigestEncrypted = data + } + // `removeKeyType` is an objc category method only on NSData, so temporarily cast. self.identityKey = (identityKeyWithType as NSData).removeKeyType() as Data + self.profileNameEncrypted = profileNameEncrypted + self.avatarUrlEncrypted = avatarUrlEncrypted + self.avatarDigestEncrypted = avatarDigestEncrypted } } diff --git a/Signal/src/Signal-Bridging-Header.h b/Signal/src/Signal-Bridging-Header.h index 960986d75..51393b431 100644 --- a/Signal/src/Signal-Bridging-Header.h +++ b/Signal/src/Signal-Bridging-Header.h @@ -18,6 +18,7 @@ #import "OWSDatabaseMigration.h" #import "OWSLogger.h" #import "OWSMessageEditing.h" +#import "OWSProfileManager.h" #import "OWSProgressView.h" #import "OWSViewController.h" #import "OWSWebRTCDataProtos.pb.h"