diff --git a/app/actions/server.js b/app/actions/server.js index a5441c7b..a3356d89 100644 --- a/app/actions/server.js +++ b/app/actions/server.js @@ -23,10 +23,11 @@ export function selectServerFailure() { }; } -export function serverRequest(server) { +export function serverRequest(server, certificate = null) { return { type: SERVER.REQUEST, - server + server, + certificate }; } diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index cf180375..a8f814da 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -94,6 +94,7 @@ export default { and: 'and', announcement: 'announcement', Announcement: 'Announcement', + Apply_Your_Certificate: 'Apply Your Certificate', ARCHIVE: 'ARCHIVE', archive: 'archive', are_typing: 'are typing', @@ -137,6 +138,8 @@ export default { Copied_to_clipboard: 'Copied to clipboard!', Copy: 'Copy', Permalink: 'Permalink', + Certificate_password: 'Certificate Password', + Whats_the_password_for_your_certificate: 'What\'s the password for your certificate?', Create_account: 'Create an account', Create_Channel: 'Create Channel', Created_snippet: 'Created a snippet', @@ -155,6 +158,7 @@ export default { Disable_notifications: 'Disable notifications', Discussions: 'Discussions', Dont_Have_An_Account: 'Don\'t have an account?', + Do_you_have_a_certificate: 'Do you have a certificate?', Do_you_really_want_to_key_this_room_question_mark: 'Do you really want to {{key}} this room?', edit: 'edit', edited: 'edited', @@ -427,6 +431,7 @@ export default { you: 'you', You: 'You', You_need_to_access_at_least_one_RocketChat_server_to_share_something: 'You need to access at least one Rocket.Chat server to share something.', + Your_certificate: 'Your Certificate', Version_no: 'Version: {{version}}', You_will_not_be_able_to_recover_this_message: 'You will not be able to recover this message!', Change_Language: 'Change Language', diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 552a474b..05a1f6c6 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -2,6 +2,7 @@ import { AsyncStorage, InteractionManager } from 'react-native'; import semver from 'semver'; import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; import RNUserDefaults from 'rn-user-defaults'; +import * as FileSystem from 'expo-file-system'; import reduxStore from './createStore'; import defaultSettings from '../constants/settings'; @@ -9,6 +10,7 @@ import messagesStatus from '../constants/messagesStatus'; import database from './realm'; import log from '../utils/log'; import { isIOS, getBundleId } from '../utils/deviceInfo'; +import { extractHostname } from '../utils/server'; import { setUser, setLoginServices, loginRequest, loginFailure, logout @@ -364,6 +366,12 @@ const RocketChat = { try { const servers = await RNUserDefaults.objectForKey(SERVERS); await RNUserDefaults.setObjectForKey(SERVERS, servers && servers.filter(srv => srv[SERVER_URL] !== server)); + // clear certificate for server - SSL Pinning + const certificate = await RNUserDefaults.objectForKey(extractHostname(server)); + if (certificate && certificate.path) { + await RNUserDefaults.clear(extractHostname(server)); + await FileSystem.deleteAsync(certificate.path); + } } catch (error) { console.log('logout_rn_user_defaults', error); } diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index ef5d83af..04e851fa 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -14,6 +14,7 @@ import { setUser } from '../actions/login'; import RocketChat from '../lib/rocketchat'; import database from '../lib/realm'; import log from '../utils/log'; +import { extractHostname } from '../utils/server'; import I18n from '../i18n'; import { SERVERS, TOKEN, SERVER_URL } from '../constants/userDefaults'; @@ -77,8 +78,12 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch } }; -const handleServerRequest = function* handleServerRequest({ server }) { +const handleServerRequest = function* handleServerRequest({ server, certificate }) { try { + if (certificate) { + yield RNUserDefaults.setObjectForKey(extractHostname(server), certificate); + } + const serverInfo = yield getServerInfo({ server }); const loginServicesLength = yield RocketChat.getLoginServices(server); diff --git a/app/utils/server.js b/app/utils/server.js new file mode 100644 index 00000000..0dcc1054 --- /dev/null +++ b/app/utils/server.js @@ -0,0 +1,18 @@ +/* + Extract hostname from url + url = 'https://open.rocket.chat/method' + hostname = 'open.rocket.chat' +*/ +export const extractHostname = (url) => { + let hostname; + + if (url.indexOf('//') > -1) { + [,, hostname] = url.split('/'); + } else { + [hostname] = url.split('/'); + } + [hostname] = hostname.split(':'); + [hostname] = hostname.split('?'); + + return hostname; +}; diff --git a/app/views/NewServerView.js b/app/views/NewServerView.js index 463aeb4c..5d03cb0e 100644 --- a/app/views/NewServerView.js +++ b/app/views/NewServerView.js @@ -1,10 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { - Text, ScrollView, Keyboard, Image, StyleSheet, TouchableOpacity + Text, ScrollView, Keyboard, Image, StyleSheet, TouchableOpacity, View, Alert, LayoutAnimation } from 'react-native'; import { connect } from 'react-redux'; import { SafeAreaView } from 'react-navigation'; +import * as FileSystem from 'expo-file-system'; +import DocumentPicker from 'react-native-document-picker'; +import ActionSheet from 'react-native-action-sheet'; +import isEqual from 'deep-equal'; import { serverRequest } from '../actions/server'; import sharedStyles from './Styles'; @@ -18,6 +22,7 @@ import { isIOS, isNotch } from '../utils/deviceInfo'; import { CustomIcon } from '../lib/Icons'; import StatusBar from '../containers/StatusBar'; import { COLOR_PRIMARY } from '../constants/colors'; +import log from '../utils/log'; const styles = StyleSheet.create({ image: { @@ -41,6 +46,22 @@ const styles = StyleSheet.create({ position: 'absolute', paddingHorizontal: 9, left: 15 + }, + certificatePicker: { + flex: 1, + marginTop: 40, + alignItems: 'center', + justifyContent: 'center' + }, + chooseCertificateTitle: { + fontSize: 15, + ...sharedStyles.textRegular, + ...sharedStyles.textColorDescription + }, + chooseCertificate: { + fontSize: 15, + ...sharedStyles.textSemibold, + ...sharedStyles.textColorHeaderBack } }); @@ -61,9 +82,19 @@ class NewServerView extends React.Component { constructor(props) { super(props); const server = props.navigation.getParam('server'); + + // Cancel + this.options = [I18n.t('Cancel')]; + this.CANCEL_INDEX = 0; + + // Delete + this.options.push(I18n.t('Delete')); + this.DELETE_INDEX = 1; + this.state = { text: server || '', - autoFocus: !server + autoFocus: !server, + certificate: null }; } @@ -76,11 +107,14 @@ class NewServerView extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - const { text } = this.state; + const { text, certificate } = this.state; const { connecting } = this.props; if (nextState.text !== text) { return true; } + if (!isEqual(nextState.certificate, certificate)) { + return true; + } if (nextProps.connecting !== connecting) { return true; } @@ -91,13 +125,51 @@ class NewServerView extends React.Component { this.setState({ text }); } - submit = () => { - const { text } = this.state; + submit = async() => { + const { text, certificate } = this.state; const { connectServer } = this.props; + let cert = null; + + if (certificate) { + const certificatePath = `${ FileSystem.documentDirectory }/${ certificate.name }`; + try { + await FileSystem.copyAsync({ from: certificate.path, to: certificatePath }); + } catch (error) { + log('err_save_certificate', error); + } + cert = { + path: this.uriToPath(certificatePath), // file:// isn't allowed by obj-C + password: certificate.password + }; + } if (text) { Keyboard.dismiss(); - connectServer(this.completeUrl(text)); + connectServer(this.completeUrl(text), cert); + } + } + + chooseCertificate = async() => { + try { + const res = await DocumentPicker.pick({ + type: ['com.rsa.pkcs-12'] + }); + const { uri: path, name } = res; + Alert.prompt( + I18n.t('Certificate_password'), + I18n.t('Whats_the_password_for_your_certificate'), + [ + { + text: 'OK', + onPress: password => this.saveCertificate({ path, name, password }) + } + ], + 'secure-text', + ); + } catch (error) { + if (!DocumentPicker.isCancel(error)) { + log('err_choose_certificate', error); + } } } @@ -120,6 +192,25 @@ class NewServerView extends React.Component { return url.replace(/\/+$/, ''); } + uriToPath = uri => uri.replace('file://', ''); + + saveCertificate = (certificate) => { + LayoutAnimation.easeInEaseOut(); + this.setState({ certificate }); + } + + handleDelete = () => this.setState({ certificate: null }); // We not need delete file from DocumentPicker because it is a temp file + + showActionSheet = () => { + ActionSheet.showActionSheetWithOptions({ + options: this.options, + cancelButtonIndex: this.CANCEL_INDEX, + destructiveButtonIndex: this.DELETE_INDEX + }, (actionIndex) => { + if (actionIndex === this.DELETE_INDEX) { this.handleDelete(); } + }); + } + renderBack = () => { const { navigation } = this.props; @@ -142,6 +233,18 @@ class NewServerView extends React.Component { ); } + renderCertificatePicker = () => { + const { certificate } = this.state; + return ( + + {certificate ? I18n.t('Your_certificate') : I18n.t('Do_you_have_a_certificate')} + + {certificate ? certificate.name : I18n.t('Apply_Your_Certificate')} + + + ); + } + render() { const { connecting } = this.props; const { text, autoFocus } = this.state; @@ -175,6 +278,7 @@ class NewServerView extends React.Component { loading={connecting} testID='new-server-view-button' /> + { isIOS ? this.renderCertificatePicker() : null } {this.renderBack()} @@ -188,7 +292,7 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - connectServer: server => dispatch(serverRequest(server)) + connectServer: (server, certificate) => dispatch(serverRequest(server, certificate)) }); export default connect(mapStateToProps, mapDispatchToProps)(NewServerView); diff --git a/package.json b/package.json index f9216c95..4e905a93 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "commonmark-react-renderer": "git+https://github.com/RocketChat/commonmark-react-renderer.git", "deep-equal": "^1.0.1", "ejson": "2.2.0", + "expo-file-system": "^6.0.2", "expo-av": "^6.0.0", "expo-haptics": "6.0.0", "expo-web-browser": "^6.0.0", diff --git a/patches/react-native+0.60.4.patch b/patches/react-native+0.60.4.patch index 0785c91e..16af07e4 100644 --- a/patches/react-native+0.60.4.patch +++ b/patches/react-native+0.60.4.patch @@ -1,8 +1,140 @@ +diff --git a/node_modules/react-native/Libraries/Network/RCTHTTPRequestHandler.mm b/node_modules/react-native/Libraries/Network/RCTHTTPRequestHandler.mm +index 76131fa..59804aa 100644 +--- a/node_modules/react-native/Libraries/Network/RCTHTTPRequestHandler.mm ++++ b/node_modules/react-native/Libraries/Network/RCTHTTPRequestHandler.mm +@@ -55,6 +55,59 @@ - (BOOL)canHandleRequest:(NSURLRequest *)request + return [schemes containsObject:request.URL.scheme.lowercaseString]; + } + ++-(NSURLCredential *)getUrlCredential:(NSURLAuthenticationChallenge *)challenge path:(NSString *)path password:(NSString *)password ++{ ++ NSString *authMethod = [[challenge protectionSpace] authenticationMethod]; ++ SecTrustRef serverTrust = challenge.protectionSpace.serverTrust; ++ ++ if ([authMethod isEqualToString:NSURLAuthenticationMethodServerTrust] || path == nil || password == nil) { ++ return [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; ++ } else if (path && password) { ++ NSMutableArray *policies = [NSMutableArray array]; ++ [policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)challenge.protectionSpace.host)]; ++ SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies); ++ ++ SecTrustResultType result; ++ SecTrustEvaluate(serverTrust, &result); ++ ++ if (![[NSFileManager defaultManager] fileExistsAtPath:path]) ++ { ++ return [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; ++ } ++ ++ NSData *p12data = [NSData dataWithContentsOfFile:path]; ++ NSDictionary* options = @{ (id)kSecImportExportPassphrase:password }; ++ CFArrayRef rawItems = NULL; ++ OSStatus status = SecPKCS12Import((__bridge CFDataRef)p12data, ++ (__bridge CFDictionaryRef)options, ++ &rawItems); ++ ++ if (status != noErr) { ++ return [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; ++ } ++ ++ NSArray* items = (NSArray*)CFBridgingRelease(rawItems); ++ NSDictionary* firstItem = nil; ++ if ((status == errSecSuccess) && ([items count]>0)) { ++ firstItem = items[0]; ++ } ++ ++ SecIdentityRef identity = (SecIdentityRef)CFBridgingRetain(firstItem[(id)kSecImportItemIdentity]); ++ SecCertificateRef certificate = NULL; ++ if (identity) { ++ SecIdentityCopyCertificate(identity, &certificate); ++ if (certificate) { CFRelease(certificate); } ++ } ++ ++ NSMutableArray *certificates = [[NSMutableArray alloc] init]; ++ [certificates addObject:CFBridgingRelease(certificate)]; ++ ++ return [NSURLCredential credentialWithIdentity:identity certificates:certificates persistence:NSURLCredentialPersistenceNone]; ++ } ++ ++ return [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; ++} ++ + - (NSURLSessionDataTask *)sendRequest:(NSURLRequest *)request + withDelegate:(id)delegate + { +@@ -177,4 +230,21 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didComp + [delegate URLRequest:task didCompleteWithError:error]; + } + ++-(void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler { ++ ++ NSString *host = challenge.protectionSpace.host; ++ NSDictionary *clientSSL = [[[NSUserDefaults alloc] initWithSuiteName:@"group.ios.chat.rocket"] objectForKey:host]; ++ ++ NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; ++ ++ if (clientSSL != (id)[NSNull null]) { ++ NSString *path = [clientSSL objectForKey:@"path"]; ++ NSString *password = [clientSSL objectForKey:@"password"]; ++ credential = [self getUrlCredential:challenge path:path password:password]; ++ } ++ ++ completionHandler(NSURLSessionAuthChallengeUseCredential, credential); ++} ++ ++ + @end diff --git a/node_modules/react-native/Libraries/WebSocket/RCTSRWebSocket.m b/node_modules/react-native/Libraries/WebSocket/RCTSRWebSocket.m -index 6f1e5e8..b835657 100644 +index 6f1e5e8..f268d93 100644 --- a/node_modules/react-native/Libraries/WebSocket/RCTSRWebSocket.m +++ b/node_modules/react-native/Libraries/WebSocket/RCTSRWebSocket.m -@@ -595,6 +595,7 @@ - (void)closeWithCode:(NSInteger)code reason:(NSString *)reason; +@@ -479,6 +479,29 @@ - (void)didConnect + [self _readHTTPHeader]; + } + ++- (void)setClientSSL:(NSString *)path password:(NSString *)password options:(NSMutableDictionary *)options; ++{ ++ if ([[NSFileManager defaultManager] fileExistsAtPath:path]) ++ { ++ NSData *pkcs12data = [[NSData alloc] initWithContentsOfFile:path]; ++ NSDictionary* certOptions = @{ (id)kSecImportExportPassphrase:password }; ++ CFArrayRef keyref = NULL; ++ OSStatus sanityChesk = SecPKCS12Import((__bridge CFDataRef)pkcs12data, ++ (__bridge CFDictionaryRef)certOptions, ++ &keyref); ++ if (sanityChesk == noErr) { ++ CFDictionaryRef identityDict = CFArrayGetValueAtIndex(keyref, 0); ++ SecIdentityRef identityRef = (SecIdentityRef)CFDictionaryGetValue(identityDict, kSecImportItemIdentity); ++ SecCertificateRef cert = NULL; ++ OSStatus status = SecIdentityCopyCertificate(identityRef, &cert); ++ if (!status) { ++ NSArray *certificates = [[NSArray alloc] initWithObjects:(__bridge id)identityRef, (__bridge id)cert, nil]; ++ [options setObject:certificates forKey:(NSString *)kCFStreamSSLCertificates]; ++ } ++ } ++ } ++} ++ + - (void)_initializeStreams; + { + assert(_url.port.unsignedIntValue <= UINT32_MAX); +@@ -516,6 +539,15 @@ - (void)_initializeStreams; + RCTLogInfo(@"SocketRocket: In debug mode. Allowing connection to any root cert"); + #endif + ++ // SSL Pinning ++ NSDictionary *clientSSL = [[[NSUserDefaults alloc] initWithSuiteName:@"group.ios.chat.rocket"] objectForKey:host]; ++ if (clientSSL != (id)[NSNull null]) { ++ NSString *path = [clientSSL objectForKey:@"path"]; ++ NSString *password = [clientSSL objectForKey:@"password"]; ++ ++ [self setClientSSL:path password:password options:SSLOptions]; ++ } ++ + [_outputStream setProperty:SSLOptions + forKey:(__bridge id)kCFStreamPropertySSLSettings]; + } +@@ -595,6 +627,7 @@ - (void)closeWithCode:(NSInteger)code reason:(NSString *)reason; } } @@ -22,4 +154,17 @@ index 7ed4900..bb85402 100644 + return RCTSharedApplication().delegate.window.safeAreaInsets.bottom; } else { return 0; - } \ No newline at end of file + } +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoModule.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoModule.java +index cf5ca40..262f22a 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoModule.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/AndroidInfoModule.java +@@ -91,7 +91,7 @@ public class AndroidInfoModule extends ReactContextBaseJavaModule { + + private Boolean isRunningScreenshotTest() { + try { +- Class.forName("android.support.test.rule.ActivityTestRule"); ++ Class.forName("androidx.test.rule.ActivityTestRule"); + return true; + } catch (ClassNotFoundException ignored) { + return false; diff --git a/yarn.lock b/yarn.lock index a54a5551..cc1a18bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4433,7 +4433,7 @@ expo-constants@~6.0.0: dependencies: ua-parser-js "^0.7.19" -expo-file-system@~6.0.1: +expo-file-system@^6.0.2, expo-file-system@~6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-6.0.2.tgz#e65f30eb6a7213e07933df9688116eaf4e25bbf8" integrity sha512-s+6oQpLhcT7MQp7fcoj1E+zttMr0WX6c0FrddzqB4dUfhIggV+nb35nQMASIiTHAj8VPUanTFliY5rETHRMHRA==