From 21304c18a6e701e23af3ab0ed822584d89c6f1f6 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 4 Aug 2017 12:52:34 -0400 Subject: [PATCH 1/6] Once we've shared our profile key with a user (perhaps due to being a member of a whitelisted group), make sure they're whitelisted. // FREEBIE --- SignalServiceKit/src/Protocols/ProtoBuf+OWS.m | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/SignalServiceKit/src/Protocols/ProtoBuf+OWS.m b/SignalServiceKit/src/Protocols/ProtoBuf+OWS.m index ac7ca54f7..98d91db72 100644 --- a/SignalServiceKit/src/Protocols/ProtoBuf+OWS.m +++ b/SignalServiceKit/src/Protocols/ProtoBuf+OWS.m @@ -50,6 +50,13 @@ NS_ASSUME_NONNULL_BEGIN if ([self shouldMessageHaveLocalProfileKey:thread recipientId:recipientId]) { [self setProfileKey:self.localProfileKey]; + + if (recipientId.length > 0) { + // Once we've shared our profile key with a user (perhaps due to being + // a member of a whitelisted group), make sure they're whitelisted. + id profileManager = [TextSecureKitEnv sharedEnv].profileManager; + [profileManager addUserToProfileWhitelist:recipientId]; + } } } @@ -66,6 +73,11 @@ NS_ASSUME_NONNULL_BEGIN if ([self shouldMessageHaveLocalProfileKey:thread recipientId:recipientId]) { [self setProfileKey:self.localProfileKey]; + + // Once we've shared our profile key with a user (perhaps due to being + // a member of a whitelisted group), make sure they're whitelisted. + id profileManager = [TextSecureKitEnv sharedEnv].profileManager; + [profileManager addUserToProfileWhitelist:recipientId]; } } From 8b9749202d1c7489a7d11bd93e535b5af83088e3 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 4 Aug 2017 12:55:40 -0400 Subject: [PATCH 2/6] Load local user profile avatar if necessary. // FREEBIE --- Signal/src/Profiles/OWSProfileManager.m | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Signal/src/Profiles/OWSProfileManager.m b/Signal/src/Profiles/OWSProfileManager.m index f432caec1..d54ca16d6 100644 --- a/Signal/src/Profiles/OWSProfileManager.m +++ b/Signal/src/Profiles/OWSProfileManager.m @@ -272,6 +272,12 @@ static const NSInteger kProfileKeyLength = 16; { OWSAssert([NSThread isMainThread]); + if (!self.localCachedAvatarImage) { + if (self.localUserProfile.avatarFileName) { + self.localCachedAvatarImage = [self loadProfileAvatarWithFilename:self.localUserProfile.avatarFileName]; + } + } + return self.localCachedAvatarImage; } From 9266c3a4f9599946cab45322c8d2280abe302baf Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 4 Aug 2017 12:57:38 -0400 Subject: [PATCH 3/6] =?UTF-8?q?Clear=20profile=20state=20when=20a=20user?= =?UTF-8?q?=E2=80=99s=20profile=20key=20changes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // FREEBIE --- Signal/src/Profiles/OWSProfileManager.m | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Signal/src/Profiles/OWSProfileManager.m b/Signal/src/Profiles/OWSProfileManager.m index d54ca16d6..e59eaa2fe 100644 --- a/Signal/src/Profiles/OWSProfileManager.m +++ b/Signal/src/Profiles/OWSProfileManager.m @@ -581,6 +581,12 @@ static const NSInteger kProfileKeyLength = 16; userProfile.profileKey = profileKey; + // Clear profile state. + userProfile.profileName = nil; + userProfile.avatarUrl = nil; + userProfile.avatarDigest = nil; + userProfile.avatarFileName = nil; + [self saveUserProfile:userProfile]; [self refreshProfileForRecipientId:recipientId ignoreThrottling:YES]; From f6668d24c1d4b51890f6c70e02b21a7c3f92b85d Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 4 Aug 2017 15:20:33 -0400 Subject: [PATCH 4/6] Download profile avatars. // FREEBIE --- Signal/src/Profiles/OWSProfileManager.m | 104 ++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/Signal/src/Profiles/OWSProfileManager.m b/Signal/src/Profiles/OWSProfileManager.m index e59eaa2fe..347de0a66 100644 --- a/Signal/src/Profiles/OWSProfileManager.m +++ b/Signal/src/Profiles/OWSProfileManager.m @@ -112,6 +112,9 @@ static const NSInteger kProfileKeyLength = 16; // This property should only be mutated on the main thread, @property (nonatomic, readonly) NSCache *otherUsersProfileAvatarImageCache; +// This property should only be mutated on the main thread, +@property (atomic, readonly) NSMutableSet *currentAvatarDownloads; + @end #pragma mark - @@ -159,6 +162,7 @@ static const NSInteger kProfileKeyLength = 16; _userProfileWhitelistCache = [NSMutableDictionary new]; _groupProfileWhitelistCache = [NSMutableDictionary new]; _otherUsersProfileAvatarImageCache = [NSCache new]; + _currentAvatarDownloads = [NSMutableSet new]; OWSSingletonAssert(); @@ -632,18 +636,106 @@ static const NSInteger kProfileKeyLength = 16; [self.otherUsersProfileAvatarImageCache setObject:image forKey:recipientId]; } } else if (userProfile.avatarUrl) { - [self downloadProfileAvatarWithUrl:userProfile.avatarUrl recipientId:recipientId]; + [self downloadAvatarForUserProfile:userProfile]; } return image; } -- (void)downloadProfileAvatarWithUrl:(NSString *)avatarUrl recipientId:(NSString *)recipientId +- (void)downloadAvatarForUserProfile:(UserProfile *)userProfile { - OWSAssert(avatarUrl.length > 0); - OWSAssert(recipientId.length > 0); + OWSAssert([NSThread isMainThread]); + OWSAssert(userProfile); - // TODO: + if (userProfile.profileKey.length < 1 || userProfile.avatarUrl.length < 1) { + return; + } + + NSData *profileKeyAtStart = userProfile.profileKey; + + NSURL *url = [NSURL URLWithString:userProfile.avatarUrl]; + if (!url) { + OWSFail(@"%@ Malformed avatar URL: %@", self.tag, userProfile.avatarUrl); + return; + } + + NSString *_Nullable fileExtension = [[[url lastPathComponent] pathExtension] lowercaseString]; + NSSet *validFileExtensions = [NSSet setWithArray:@[ + @"jpg", + @"jpeg", + @"png", + @"gif", + ]]; + if (![validFileExtensions containsObject:fileExtension]) { + DDLogWarn(@"Ignoring avatar with invalid file extension: %@", userProfile.avatarUrl); + } + NSString *fileName = [[NSUUID UUID].UUIDString stringByAppendingPathExtension:fileExtension]; + NSString *filePath = [self.profileAvatarsDirPath stringByAppendingPathComponent:fileName]; + + if ([self.currentAvatarDownloads containsObject:userProfile.recipientId]) { + // Download already in flight; ignore. + return; + } + [self.currentAvatarDownloads addObject:userProfile.recipientId]; + + NSString *tempDirectory = NSTemporaryDirectory(); + NSString *tempFilePath = [tempDirectory stringByAppendingPathComponent:fileName]; + + // TODO: Should we use a special configuration as we do in TSNetworkManager? + // TODO: How does censorship circumvention fit in? + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration]; + NSURLRequest *request = [NSURLRequest requestWithURL:url]; + NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request + progress:nil + destination:^NSURL *(NSURL *targetPath, NSURLResponse *response) { + return [NSURL fileURLWithPath:tempFilePath]; + } + completionHandler:^(NSURLResponse *response, NSURL *filePathParam, NSError *error) { + OWSAssert([[NSURL fileURLWithPath:tempFilePath] isEqual:filePathParam]); + + // Ensure disk IO and decryption occurs off the main thread. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + + NSData *_Nullable encryptedData = (error ? nil : [NSData dataWithContentsOfFile:tempFilePath]); + NSData *_Nullable decryptedData = + [OWSProfileManager decryptProfileData:encryptedData profileKey:profileKeyAtStart]; + UIImage *_Nullable image = nil; + if (decryptedData) { + // TODO: Verify avatar digest. + BOOL success = [decryptedData writeToFile:filePath atomically:YES]; + if (success) { + image = [UIImage imageWithContentsOfFile:filePath]; + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [self.currentAvatarDownloads removeObject:userProfile.recipientId]; + + UserProfile *currentUserProfile = + [self getOrBuildUserProfileForRecipientId:userProfile.recipientId]; + if (currentUserProfile.profileKey.length < 1 + || ![currentUserProfile.profileKey isEqual:userProfile.profileKey]) { + DDLogWarn(@"%@ Ignoring avatar download for obsolete user profile.", self.tag); + } else if (error) { + DDLogError(@"%@ avatar download failed: %@", self.tag, error); + } else if (!encryptedData) { + DDLogError(@"%@ avatar encrypted data could not be read.", self.tag); + } else if (!decryptedData) { + DDLogError(@"%@ avatar data could not be decrypted.", self.tag); + } else if (!image) { + DDLogError(@"%@ avatar image could not be loaded: %@", self.tag, error); + } else { + [self.otherUsersProfileAvatarImageCache setObject:image forKey:userProfile.recipientId]; + + userProfile.avatarFileName = fileName; + + [self saveUserProfile:userProfile]; + } + }); + }); + }]; + [downloadTask resume]; } - (void)refreshProfileForRecipientId:(NSString *)recipientId @@ -721,7 +813,7 @@ static const NSInteger kProfileKeyLength = 16; [self.otherUsersProfileAvatarImageCache removeObjectForKey:recipientId]; if (avatarUrl) { - [self downloadProfileAvatarWithUrl:avatarUrl recipientId:recipientId]; + [self downloadAvatarForUserProfile:userProfile]; } } From 09e65a674b4e672b7fb094c26758e629f7278ca6 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 4 Aug 2017 17:29:48 -0400 Subject: [PATCH 5/6] Incomplete work to upload avatars. // FREEBIE --- Signal/src/Profiles/OWSProfileManager.m | 164 +++++++++++++++++++++++- 1 file changed, 159 insertions(+), 5 deletions(-) diff --git a/Signal/src/Profiles/OWSProfileManager.m b/Signal/src/Profiles/OWSProfileManager.m index 347de0a66..6ff382e63 100644 --- a/Signal/src/Profiles/OWSProfileManager.m +++ b/Signal/src/Profiles/OWSProfileManager.m @@ -10,6 +10,7 @@ #import #import #import +#import #import #import #import @@ -414,11 +415,164 @@ static const NSInteger kProfileKeyLength = 16; // TODO: NSString *avatarUrl = @"avatarUrl"; NSData *avatarDigest = [@"avatarDigest" dataUsingEncoding:NSUTF8StringEncoding]; - if (YES) { - successBlock(avatarUrl, avatarDigest); - return; - } - failureBlock(); + + // See: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-UsingHTTPPOST.html + TSProfileAvatarUploadFormRequest *formRequest = [TSProfileAvatarUploadFormRequest new]; + + [self.networkManager makeRequest:formRequest + success:^(NSURLSessionDataTask *task, id formResponseObject) { + + if (![formResponseObject isKindOfClass:[NSDictionary class]]) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_response"); + failureBlock(); + return; + } + NSDictionary *responseMap = formResponseObject; + DDLogError(@"responseObject: %@", formResponseObject); + // acl = private; + // algorithm = "AWS4-HMAC-SHA256"; + // credential = + // "AKIAINTYCHN42UH3LGRA/20170804/us-east-1/s3/aws4_request"; date = + // 20170804T193927Z; key = PtRO3iSkY6twBA; policy = + // eyAiZXhwaXJhdGlvbiI6ICIyMDE3LTA4LTA0VDIwOjA5OjI3LjMwMFoiLAogICJjb25kaXRpb25zIjogWwogICAgeyJidWNrZXQiOiAic2lnbmFsLXByb2ZpbGVzLXN0YWdpbmcifSwKICAgIHsia2V5IjogIlB0Uk8zaVNrWTZ0d0JBIn0sCiAgICB7ImFjbCI6ICJwcml2YXRlIn0sCiAgICBbInN0YXJ0cy13aXRoIiwgIiRDb250ZW50LVR5cGUiLCAiIl0sCgogICAgeyJ4LWFtei1jcmVkZW50aWFsIjogIkFLSUFJTlRZQ0hONDJVSDNMR1JBLzIwMTcwODA0L3VzLWVhc3QtMS9zMy9hd3M0X3JlcXVlc3QifSwKICAgIHsieC1hbXotYWxnb3JpdGhtIjogIkFXUzQtSE1BQy1TSEEyNTYifSwKICAgIHsieC1hbXotZGF0ZSI6ICIyMDE3MDgwNFQxOTM5MjdaIiB9CiAgXQp9; + // signature = + // 3608fdc9af8ca0d13c754c34eb37014c9995b058c2e0166550468de47b00f316; + // url = "profiles-staging.signal.org"; + + NSString *formUrl = responseMap[@"url"]; + if (![formUrl isKindOfClass:[NSString class]] || formUrl.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_url"); + failureBlock(); + return; + } + NSString *formAcl = responseMap[@"acl"]; + if (![formAcl isKindOfClass:[NSString class]] || formAcl.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_acl"); + failureBlock(); + return; + } + NSString *formKey = responseMap[@"key"]; + if (![formKey isKindOfClass:[NSString class]] || formKey.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_key"); + failureBlock(); + return; + } + NSString *formPolicy = responseMap[@"policy"]; + if (![formPolicy isKindOfClass:[NSString class]] || formPolicy.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_policy"); + failureBlock(); + return; + } + NSString *formAlgorithm = responseMap[@"algorithm"]; + if (![formAlgorithm isKindOfClass:[NSString class]] || formAlgorithm.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_algorithm"); + failureBlock(); + return; + } + NSString *formCredential = responseMap[@"credential"]; + if (![formCredential isKindOfClass:[NSString class]] || formCredential.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_credential"); + failureBlock(); + return; + } + NSString *formDate = responseMap[@"date"]; + if (![formDate isKindOfClass:[NSString class]] || formDate.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_date"); + failureBlock(); + return; + } + NSString *formSignature = responseMap[@"signature"]; + if (![formSignature isKindOfClass:[NSString class]] || formSignature.length < 1) { + OWSProdFail(@"profile_manager_error_avatar_upload_form_invalid_signature"); + failureBlock(); + return; + } + NSDictionary *parameters = @{ + @"acl" : formAcl, + @"x-amz-algorithm" : formAlgorithm, + @"x-amz-credential" : formCredential, + @"x-amz-date" : formDate, + @"key" : formKey, + @"policy" : formPolicy, + @"x-amz-signature" : formSignature, + + }; + + NSString *filePath = [self.profileAvatarsDirPath stringByAppendingPathComponent:fileName]; + NSInputStream *fileInputStream = [[NSInputStream alloc] initWithFileAtPath:filePath]; + if (!fileInputStream) { + OWSProdFail(@"profile_manager_error_avatar_upload_invalid_file_stream"); + failureBlock(); + return; + } + + NSError *error; + long long fileSize = + [[[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:&error][NSFileSize] + longLongValue]; + if (error || fileSize <= 0) { + OWSProdFail(@"profile_manager_error_avatar_upload_invalid_file_size"); + failureBlock(); + return; + } + + NSMutableURLRequest *uploadRequest = [[AFHTTPRequestSerializer serializer] + multipartFormRequestWithMethod:@"post" + URLString:[@"https://" stringByAppendingString:formUrl] + parameters:parameters + constructingBodyWithBlock:^(id formData) { + // [formData appendPartWithFormData:<#(nonnull NSData *)#> name:@"acl"]; + + [formData appendPartWithInputStream:fileInputStream + name:@"file" + fileName:fileName + length:fileSize + mimeType:@"image/jpeg"]; + } + error:&error]; + + if (error || !uploadRequest) { + OWSProdFail(@"profile_manager_error_avatar_upload_invalid_upload_request"); + failureBlock(); + return; + } + + [uploadRequest setValue:@"Content-Type: text/html; charset=UTF-8" forHTTPHeaderField:@"Content-Type"]; + + + // [uploadRequest setAllHTTPHeaderFields:headerDictionary]; + + // TODO: Should we use a special configuration as we do in TSNetworkManager? + // TODO: How does censorship circumvention fit in? + AFURLSessionManager *manager = [[AFURLSessionManager alloc] + initWithSessionConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + manager.responseSerializer = [AFXMLParserResponseSerializer new]; + NSURLSessionUploadTask *uploadTask; + uploadTask = [manager uploadTaskWithStreamedRequest:uploadRequest + progress:^(NSProgress *_Nonnull uploadProgress) { + // This is not called back on the main queue. + // You are responsible for dispatching to the main queue for UI updates + + DDLogVerbose(@"%@ Avatar upload progress: %f", self.tag, uploadProgress.fractionCompleted); + } + completionHandler:^(NSURLResponse *_Nonnull response, + id _Nullable uploadResponseObject, + NSError *_Nullable uploadError) { + + if (uploadError) { + DDLogError(@"%@ Avatar upload failed: %@", self.tag, uploadError); + failureBlock(); + } else { + DDLogVerbose(@"%@ Avatar upload succeeded", self.tag); + successBlock(avatarUrl, avatarDigest); + } + }]; + [uploadTask resume]; + } + failure:^(NSURLSessionDataTask *task, NSError *error) { + DDLogError(@"%@ Failed to get profile avatar upload form: %@", self.tag, error); + failureBlock(); + }]; }); } From e01fbc247ebfab39f6e90a3ce9a8d16dd05d274f Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 4 Aug 2017 17:30:06 -0400 Subject: [PATCH 6/6] Refine profile logic. // FREEBIE --- Signal/src/util/ThreadUtil.m | 15 ++++++++++++ .../src/Messages/TSMessagesManager.m | 3 +++ .../TSProfileAvatarUploadFormRequest.h | 17 ++++++++++++++ .../TSProfileAvatarUploadFormRequest.m | 23 +++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.h create mode 100644 SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.m diff --git a/Signal/src/util/ThreadUtil.m b/Signal/src/util/ThreadUtil.m index 8083ecc20..37ca8e2f6 100644 --- a/Signal/src/util/ThreadUtil.m +++ b/Signal/src/util/ThreadUtil.m @@ -340,6 +340,8 @@ NS_ASSUME_NONNULL_BEGIN shouldHaveAddToContactsOffer = NO; // Only create block offers for users which are not already blocked. shouldHaveBlockOffer = NO; + // Don't create profile whitelist offers for users which are not already blocked. + shouldHaveAddToProfileWhitelistOffer = NO; } SignalAccount *signalAccount = contactsManager.signalAccountMap[recipientId]; @@ -374,6 +376,19 @@ NS_ASSUME_NONNULL_BEGIN // Don't show offer if thread is local user hasn't configured their profile. // Don't show offer if thread is already in profile whitelist. shouldHaveAddToProfileWhitelistOffer = NO; + } else if (thread.isGroupThread) { + BOOL hasUnwhitelistedMember = NO; + for (NSString *recipientId in thread.recipientIdentifiers) { + if (![OWSProfileManager.sharedManager isUserInProfileWhitelist:recipientId]) { + hasUnwhitelistedMember = YES; + break; + } + } + if (!hasUnwhitelistedMember) { + // Don't show offer in group thread if all members are already individually + // whitelisted. + hasUnwhitelistedMember = YES; + } } // We use these offset to control the ordering of the offers and indicators. diff --git a/SignalServiceKit/src/Messages/TSMessagesManager.m b/SignalServiceKit/src/Messages/TSMessagesManager.m index 7b53a0938..334b43f5d 100644 --- a/SignalServiceKit/src/Messages/TSMessagesManager.m +++ b/SignalServiceKit/src/Messages/TSMessagesManager.m @@ -702,6 +702,9 @@ NS_ASSUME_NONNULL_BEGIN // user, we can infer that that user belongs in our profile whitelist. id profileManager = [TextSecureKitEnv sharedEnv].profileManager; [profileManager addUserToProfileWhitelist:destination]; + + // TODO: Can we also infer when groups are added to the whitelist + // from sent messages to groups? } if ([self isDataMessageGroupAvatarUpdate:syncMessage.sent.message]) { diff --git a/SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.h b/SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.h new file mode 100644 index 000000000..73b36c9ba --- /dev/null +++ b/SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.h @@ -0,0 +1,17 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +#import "TSRequest.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface TSProfileAvatarUploadFormRequest : TSRequest + +- (nullable instancetype)init; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.m b/SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.m new file mode 100644 index 000000000..bb820c210 --- /dev/null +++ b/SignalServiceKit/src/Network/API/Requests/TSProfileAvatarUploadFormRequest.m @@ -0,0 +1,23 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +#import "TSProfileAvatarUploadFormRequest.h" +#import "TSConstants.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation TSProfileAvatarUploadFormRequest + +- (nullable instancetype)init +{ + self = [super initWithURL:[NSURL URLWithString:textSecureProfileAvatarFormAPI]]; + + self.HTTPMethod = @"GET"; + + return self; +} + +@end + +NS_ASSUME_NONNULL_END