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