1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 #import "remoting/host/mac/me2me_preference_pane.h"
7 #import <Cocoa/Cocoa.h>
8 #include <CommonCrypto/CommonHMAC.h>
11 #import <PreferencePanes/PreferencePanes.h>
12 #import <SecurityInterface/SFAuthorizationView.h>
18 #include "base/mac/scoped_launch_data.h"
19 #include "base/memory/scoped_ptr.h"
20 #include "base/posix/eintr_wrapper.h"
21 #include "remoting/host/constants_mac.h"
22 #include "remoting/host/host_config.h"
23 #import "remoting/host/mac/me2me_preference_pane_confirm_pin.h"
24 #import "remoting/host/mac/me2me_preference_pane_disable.h"
25 #include "third_party/jsoncpp/source/include/json/reader.h"
26 #include "third_party/jsoncpp/source/include/json/writer.h"
27 #include "third_party/modp_b64/modp_b64.h"
31 bool GetTemporaryConfigFilePath(std::string* path) {
32 NSString* filename = NSTemporaryDirectory();
36 *path = [[NSString stringWithFormat:@"%@/%s",
37 filename, remoting::kHostConfigFileName] UTF8String];
41 bool IsConfigValid(const remoting::JsonHostConfig* config) {
43 return (config->GetString(remoting::kHostIdConfigPath, &value) &&
44 config->GetString(remoting::kHostSecretHashConfigPath, &value) &&
45 config->GetString(remoting::kXmppLoginConfigPath, &value));
48 bool IsPinValid(const std::string& pin, const std::string& host_id,
49 const std::string& host_secret_hash) {
50 // TODO(lambroslambrou): Once the "base" target supports building for 64-bit
51 // on Mac OS X, remove this code and replace it with |VerifyHostPinHash()|
52 // from host/pin_hash.h.
53 size_t separator = host_secret_hash.find(':');
54 if (separator == std::string::npos)
57 std::string method = host_secret_hash.substr(0, separator);
58 if (method != "hmac") {
59 NSLog(@"Authentication method '%s' not supported", method.c_str());
63 std::string hash_base64 = host_secret_hash.substr(separator + 1);
65 // Convert |hash_base64| to |hash|, based on code from base/base64.cc.
66 int hash_base64_size = static_cast<int>(hash_base64.size());
68 hash.resize(modp_b64_decode_len(hash_base64_size));
70 // modp_b64_decode_len() returns at least 1, so hash[0] is safe here.
71 int hash_size = modp_b64_decode(&(hash[0]), hash_base64.data(),
74 NSLog(@"Failed to parse host_secret_hash");
77 hash.resize(hash_size);
79 std::string computed_hash;
80 computed_hash.resize(CC_SHA256_DIGEST_LENGTH);
82 CCHmac(kCCHmacAlgSHA256,
83 host_id.data(), host_id.size(),
84 pin.data(), pin.size(),
87 // Normally, a constant-time comparison function would be used, but it is
88 // unnecessary here as the "secret" is already readable by the user
89 // supplying input to this routine.
90 return computed_hash == hash;
95 // These methods are copied from base/mac, but with the logging changed to use
98 // TODO(lambroslambrou): Once the "base" target supports building for 64-bit
99 // on Mac OS X, remove these implementations and use the ones in base/mac.
103 // MessageForJob sends a single message to launchd with a simple dictionary
104 // mapping |operation| to |job_label|, and returns the result of calling
105 // launch_msg to send that message. On failure, returns nullptr. The caller
106 // assumes ownership of the returned launch_data_t object.
107 launch_data_t MessageForJob(const std::string& job_label,
108 const char* operation) {
109 // launch_data_alloc returns something that needs to be freed.
110 ScopedLaunchData message(launch_data_alloc(LAUNCH_DATA_DICTIONARY));
112 NSLog(@"launch_data_alloc");
116 // launch_data_new_string returns something that needs to be freed, but
117 // the dictionary will assume ownership when launch_data_dict_insert is
118 // called, so put it in a scoper and .release() it when given to the
120 ScopedLaunchData job_label_launchd(launch_data_new_string(job_label.c_str()));
121 if (!job_label_launchd) {
122 NSLog(@"launch_data_new_string");
126 if (!launch_data_dict_insert(message,
127 job_label_launchd.release(),
132 return launch_msg(message);
135 pid_t PIDForJob(const std::string& job_label) {
136 ScopedLaunchData response(MessageForJob(job_label, LAUNCH_KEY_GETJOB));
141 launch_data_type_t response_type = launch_data_get_type(response);
142 if (response_type != LAUNCH_DATA_DICTIONARY) {
143 if (response_type == LAUNCH_DATA_ERRNO) {
144 NSLog(@"PIDForJob: error %d", launch_data_get_errno(response));
146 NSLog(@"PIDForJob: expected dictionary, got %d", response_type);
151 launch_data_t pid_data = launch_data_dict_lookup(response,
156 if (launch_data_get_type(pid_data) != LAUNCH_DATA_INTEGER) {
157 NSLog(@"PIDForJob: expected integer");
161 return launch_data_get_integer(pid_data);
164 OSStatus ExecuteWithPrivilegesAndGetPID(AuthorizationRef authorization,
165 const char* tool_path,
166 AuthorizationFlags options,
167 const char** arguments,
170 // pipe may be nullptr, but this function needs one. In that case, use a
177 pipe_pointer = &local_pipe;
180 // AuthorizationExecuteWithPrivileges wants |char* const*| for |arguments|,
181 // but it doesn't actually modify the arguments, and that type is kind of
182 // silly and callers probably aren't dealing with that. Put the cast here
183 // to make things a little easier on callers.
184 OSStatus status = AuthorizationExecuteWithPrivileges(authorization,
187 (char* const*)arguments,
189 if (status != errAuthorizationSuccess) {
194 size_t line_length = 0;
195 char* line_c = fgetln(*pipe_pointer, &line_length);
197 if (line_length > 0 && line_c[line_length - 1] == '\n') {
198 // line_c + line_length is the start of the next line if there is one.
199 // Back up one character.
202 std::string line(line_c, line_length);
204 // The version in base/mac used base::StringToInt() here.
205 line_pid = strtol(line.c_str(), nullptr, 10);
207 NSLog(@"ExecuteWithPrivilegesAndGetPid: funny line: %s", line.c_str());
211 NSLog(@"ExecuteWithPrivilegesAndGetPid: no line");
215 fclose(*pipe_pointer);
230 JsonHostConfig::JsonHostConfig(const std::string& filename)
231 : filename_(filename) {
234 JsonHostConfig::~JsonHostConfig() {
237 bool JsonHostConfig::Read() {
238 std::ifstream file(filename_.c_str());
240 return reader.parse(file, config_, false /* ignore comments */);
243 bool JsonHostConfig::GetString(const std::string& path,
244 std::string* out_value) const {
245 if (!config_.isObject())
248 if (!config_.isMember(path))
251 Json::Value value = config_[path];
252 if (!value.isString())
255 *out_value = value.asString();
259 std::string JsonHostConfig::GetSerializedData() const {
260 Json::FastWriter writer;
261 return writer.write(config_);
264 } // namespace remoting
266 @implementation Me2MePreferencePane
268 - (void)mainViewDidLoad {
269 [authorization_view_ setDelegate:self];
270 [authorization_view_ setString:kAuthorizationRightExecute];
271 [authorization_view_ setAutoupdate:YES
273 confirm_pin_view_ = [[Me2MePreferencePaneConfirmPin alloc] init];
274 [confirm_pin_view_ setDelegate:self];
275 disable_view_ = [[Me2MePreferencePaneDisable alloc] init];
276 [disable_view_ setDelegate:self];
280 have_new_config_ = NO;
281 awaiting_service_stop_ = NO;
283 NSDistributedNotificationCenter* center =
284 [NSDistributedNotificationCenter defaultCenter];
285 [center addObserver:self
286 selector:@selector(onNewConfigFile:)
287 name:[NSString stringWithUTF8String:remoting::kServiceName]
290 service_status_timer_ =
291 [[NSTimer scheduledTimerWithTimeInterval:2.0
293 selector:@selector(refreshServiceStatus:)
295 repeats:YES] retain];
296 [self updateServiceStatus];
297 [self updateAuthorizationStatus];
299 [self checkInstalledVersion];
300 if (!restart_pending_or_canceled_)
301 [self readNewConfig];
307 [self checkInstalledVersion];
310 - (void)willUnselect {
311 NSDistributedNotificationCenter* center =
312 [NSDistributedNotificationCenter defaultCenter];
313 [center removeObserver:self];
315 [service_status_timer_ invalidate];
316 [service_status_timer_ release];
317 service_status_timer_ = nil;
319 [self notifyPlugin:UPDATE_FAILED_NOTIFICATION_NAME];
322 - (void)applyConfiguration:(id)sender
324 if (!have_new_config_) {
325 // It shouldn't be possible to hit the button if there is no config to
326 // apply, but check anyway just in case it happens somehow.
330 // Ensure the authorization token is up-to-date before using it.
331 [self updateAuthorizationStatus];
334 std::string pin_utf8 = [pin UTF8String];
335 std::string host_id, host_secret_hash;
336 bool result = (config_->GetString(remoting::kHostIdConfigPath, &host_id) &&
337 config_->GetString(remoting::kHostSecretHashConfigPath,
343 if (!IsPinValid(pin_utf8, host_id, host_secret_hash)) {
344 [self showIncorrectPinMessage];
348 [self applyNewServiceConfig];
352 - (void)onDisable:(id)sender {
353 // Ensure the authorization token is up-to-date before using it.
354 [self updateAuthorizationStatus];
356 if (!is_pane_unlocked_)
359 if (![self runHelperAsRootWithCommand:"--disable"
361 NSLog(@"Failed to run the helper tool");
363 [self notifyPlugin:UPDATE_FAILED_NOTIFICATION_NAME];
367 // Stop the launchd job. This cannot easily be done by the helper tool,
368 // since the launchd job runs in the current user's context.
369 [self sendJobControlMessage:LAUNCH_KEY_STOPJOB];
370 awaiting_service_stop_ = YES;
373 - (void)onNewConfigFile:(NSNotification*)notification {
374 [self checkInstalledVersion];
375 if (!restart_pending_or_canceled_)
376 [self readNewConfig];
381 - (void)refreshServiceStatus:(NSTimer*)timer {
382 BOOL was_running = is_service_running_;
383 [self updateServiceStatus];
384 if (awaiting_service_stop_ && !is_service_running_) {
385 awaiting_service_stop_ = NO;
386 [self notifyPlugin:UPDATE_SUCCEEDED_NOTIFICATION_NAME];
389 if (was_running != is_service_running_)
393 - (void)authorizationViewDidAuthorize:(SFAuthorizationView*)view {
394 [self updateAuthorizationStatus];
398 - (void)authorizationViewDidDeauthorize:(SFAuthorizationView*)view {
399 [self updateAuthorizationStatus];
403 - (void)updateServiceStatus {
404 pid_t job_pid = base::mac::PIDForJob(remoting::kServiceName);
405 is_service_running_ = (job_pid > 0);
408 - (void)updateAuthorizationStatus {
409 is_pane_unlocked_ = [authorization_view_ updateStatus:authorization_view_];
412 - (void)readNewConfig {
414 if (!GetTemporaryConfigFilePath(&file)) {
415 NSLog(@"Failed to get path of configuration data.");
419 if (access(file.c_str(), F_OK) != 0)
422 scoped_ptr<remoting::JsonHostConfig> new_config_(
423 new remoting::JsonHostConfig(file));
424 if (!new_config_->Read()) {
425 // Report the error, because the file exists but couldn't be read. The
426 // case of non-existence is normal and expected.
427 NSLog(@"Error reading configuration data from %s", file.c_str());
431 remove(file.c_str());
432 if (!IsConfigValid(new_config_.get())) {
433 NSLog(@"Invalid configuration data read.");
438 config_.swap(new_config_);
439 have_new_config_ = YES;
441 [confirm_pin_view_ resetPin];
445 if (have_new_config_) {
446 [box_ setContentView:[confirm_pin_view_ view]];
448 [box_ setContentView:[disable_view_ view]];
451 // TODO(lambroslambrou): Show "enabled" and "disabled" in bold font.
453 if (is_service_running_) {
454 if (have_new_config_) {
455 message = @"Please confirm your new PIN.";
457 message = @"Remote connections to this computer are enabled.";
460 if (have_new_config_) {
461 message = @"Remote connections to this computer are disabled. To enable "
462 "remote connections you must confirm your PIN.";
464 message = @"Remote connections to this computer are disabled.";
467 [status_message_ setStringValue:message];
472 config_->GetString(remoting::kHostOwnerEmailConfigPath, &email);
474 result = config_->GetString(remoting::kHostOwnerConfigPath, &email);
476 result = config_->GetString(remoting::kXmppLoginConfigPath, &email);
478 // The config has already been checked by |IsConfigValid|.
486 [disable_view_ setEnabled:(is_pane_unlocked_ && is_service_running_ &&
487 !restart_pending_or_canceled_)];
488 [confirm_pin_view_ setEnabled:(is_pane_unlocked_ &&
489 !restart_pending_or_canceled_)];
490 [confirm_pin_view_ setEmail:[NSString stringWithUTF8String:email.c_str()]];
491 NSString* applyButtonText = is_service_running_ ? @"Confirm" : @"Enable";
492 [confirm_pin_view_ setButtonText:applyButtonText];
494 if (restart_pending_or_canceled_)
495 [authorization_view_ setEnabled:NO];
499 NSAlert* alert = [[NSAlert alloc] init];
500 [alert setMessageText:@"An unexpected error occurred."];
501 [alert setInformativeText:@"Check the system log for more information."];
502 [alert setAlertStyle:NSWarningAlertStyle];
503 [alert beginSheetModalForWindow:[[self mainView] window]
510 - (void)showIncorrectPinMessage {
511 NSAlert* alert = [[NSAlert alloc] init];
512 [alert setMessageText:@"Incorrect PIN entered."];
513 [alert setAlertStyle:NSWarningAlertStyle];
514 [alert beginSheetModalForWindow:[[self mainView] window]
521 - (void)applyNewServiceConfig {
522 [self updateServiceStatus];
523 std::string serialized_config = config_->GetSerializedData();
524 const char* command = is_service_running_ ? "--save-config" : "--enable";
525 if (![self runHelperAsRootWithCommand:command
526 inputData:serialized_config]) {
527 NSLog(@"Failed to run the helper tool");
532 have_new_config_ = NO;
534 // Ensure the service is started.
535 if (!is_service_running_) {
536 [self sendJobControlMessage:LAUNCH_KEY_STARTJOB];
539 // Broadcast a distributed notification to inform the plugin that the
540 // configuration has been applied.
541 [self notifyPlugin:UPDATE_SUCCEEDED_NOTIFICATION_NAME];
544 - (BOOL)runHelperAsRootWithCommand:(const char*)command
545 inputData:(const std::string&)input_data {
546 AuthorizationRef authorization =
547 [[authorization_view_ authorization] authorizationRef];
548 if (!authorization) {
549 NSLog(@"Failed to obtain authorizationRef");
553 // TODO(lambroslambrou): Replace the deprecated ExecuteWithPrivileges
554 // call with a launchd-based helper tool, which is more secure.
555 // http://crbug.com/120903
556 const char* arguments[] = { command, nullptr };
557 FILE* pipe = nullptr;
559 OSStatus status = base::mac::ExecuteWithPrivilegesAndGetPID(
561 remoting::kHostHelperScriptPath,
562 kAuthorizationFlagDefaults,
566 if (status != errAuthorizationSuccess) {
567 NSLog(@"AuthorizationExecuteWithPrivileges: %s (%d)",
568 GetMacOSStatusErrorString(status), static_cast<int>(status));
572 NSLog(@"Failed to get child PID");
579 NSLog(@"Unexpected nullptr pipe");
583 // Some cleanup is needed (closing the pipe and waiting for the child
584 // process), so flag any errors before returning.
587 if (!input_data.empty()) {
588 size_t bytes_written = fwrite(input_data.data(), sizeof(char),
589 input_data.size(), pipe);
590 // According to the fwrite manpage, a partial count is returned only if a
591 // write error has occurred.
592 if (bytes_written != input_data.size()) {
593 NSLog(@"Failed to write data to child process");
598 // In all cases, fclose() should be called with the returned FILE*. In the
599 // case of sending data to the child, this needs to be done before calling
600 // waitpid(), since the child reads until EOF on its stdin, so calling
601 // waitpid() first would result in deadlock.
602 if (fclose(pipe) != 0) {
603 NSLog(@"fclose failed with error %d", errno);
608 pid_t wait_result = HANDLE_EINTR(waitpid(pid, &exit_status, 0));
609 if (wait_result != pid) {
610 NSLog(@"waitpid failed with error %d", errno);
614 // No more cleanup needed.
618 if (WIFEXITED(exit_status) && WEXITSTATUS(exit_status) == 0) {
621 NSLog(@"%s failed with exit status %d", remoting::kHostHelperScriptPath,
627 - (BOOL)sendJobControlMessage:(const char*)launch_key {
628 base::mac::ScopedLaunchData response(
629 base::mac::MessageForJob(remoting::kServiceName, launch_key));
631 NSLog(@"Failed to send message to launchd");
636 // Expect a response of type LAUNCH_DATA_ERRNO.
637 launch_data_type_t type = launch_data_get_type(response.get());
638 if (type != LAUNCH_DATA_ERRNO) {
639 NSLog(@"launchd returned unexpected type: %d", type);
644 int error = launch_data_get_errno(response.get());
646 NSLog(@"launchd returned error: %d", error);
653 - (void)notifyPlugin:(const char*)message {
654 NSDistributedNotificationCenter* center =
655 [NSDistributedNotificationCenter defaultCenter];
656 NSString* name = [NSString stringWithUTF8String:message];
657 [center postNotificationName:name
662 - (void)checkInstalledVersion {
663 // There's no point repeating the check if the pane has already been disabled
664 // from a previous call to this method. The pane only gets disabled when a
665 // version-mismatch has been detected here, so skip the check, but continue to
666 // handle the version-mismatch case.
667 if (!restart_pending_or_canceled_) {
668 NSBundle* this_bundle = [NSBundle bundleForClass:[self class]];
669 NSDictionary* this_plist = [this_bundle infoDictionary];
670 NSString* this_version = [this_plist objectForKey:@"CFBundleVersion"];
672 NSString* bundle_path = [this_bundle bundlePath];
673 NSString* plist_path =
674 [bundle_path stringByAppendingString:@"/Contents/Info.plist"];
675 NSDictionary* disk_plist =
676 [NSDictionary dictionaryWithContentsOfFile:plist_path];
677 NSString* disk_version = [disk_plist objectForKey:@"CFBundleVersion"];
679 if (disk_version == nil) {
680 NSLog(@"Failed to get installed version information");
685 if ([this_version isEqualToString:disk_version])
688 restart_pending_or_canceled_ = YES;
692 NSWindow* window = [[self mainView] window];
694 // Defer the alert until |didSelect| is called, which happens just after
695 // the window is created.
699 // This alert appears as a sheet over the top of the Chromoting pref-pane,
700 // underneath the title, so it's OK to refer to "this preference pane" rather
701 // than repeat the title "Chromoting" here.
702 NSAlert* alert = [[NSAlert alloc] init];
703 [alert setMessageText:@"System update detected"];
704 [alert setInformativeText:@"To use this preference pane, System Preferences "
705 "needs to be restarted"];
706 [alert addButtonWithTitle:@"OK"];
707 NSButton* cancel_button = [alert addButtonWithTitle:@"Cancel"];
708 [cancel_button setKeyEquivalent:@"\e"];
709 [alert setAlertStyle:NSWarningAlertStyle];
710 [alert beginSheetModalForWindow:window
712 didEndSelector:@selector(
713 mismatchAlertDidEnd:returnCode:contextInfo:)
718 - (void)mismatchAlertDidEnd:(NSAlert*)alert
719 returnCode:(NSInteger)returnCode
720 contextInfo:(void*)contextInfo {
721 if (returnCode == NSAlertFirstButtonReturn) {
724 // Dismiss the alert window here, so that the application will respond to
725 // the NSApp terminate: message.
726 [[alert window] orderOut:nil];
727 [self restartSystemPreferences];
729 // Cancel was pressed.
731 // If there is a new config file, delete it and notify the web-app of
732 // failure to apply the config. Otherwise, the web-app will remain in a
733 // spinning state until System Preferences eventually gets restarted and
734 // the user visits this pane again.
736 if (!GetTemporaryConfigFilePath(&file)) {
737 // There's no point in alerting the user here. The same error would
738 // happen when the pane is eventually restarted, so the user would be
739 // alerted at that time.
740 NSLog(@"Failed to get path of configuration data.");
744 remove(file.c_str());
745 [self notifyPlugin:UPDATE_FAILED_NOTIFICATION_NAME];
749 - (void)restartSystemPreferences {
750 NSTask* task = [[NSTask alloc] init];
752 [NSString stringWithUTF8String:remoting::kHostHelperScriptPath];
753 NSArray* arguments = [NSArray arrayWithObjects:@"--relaunch-prefpane", nil];
754 [task setLaunchPath:command];
755 [task setArguments:arguments];
756 [task setStandardInput:[NSPipe pipe]];
759 [NSApp terminate:nil];