From 222a50545708dcce9478cfae10242af434025614 Mon Sep 17 00:00:00 2001 From: MatteoPologruto <109663225+MatteoPologruto@users.noreply.github.com> Date: Wed, 8 May 2024 15:43:01 +0200 Subject: [PATCH] Manage the HTTPS certificate from the menu and ask Safari users to install it at startup (#941) * Add function to retrieve certificates expiration date * Check the certificate expiration date * Obtain certificates info using the systray icon * Manage errors that may occur retrieving certificates expiration date * Obtain default browser name on macOS * Prompt Safari users to install HTTPS certificates and check if they are outdated when the Agent is started * Skip some tests on macOS because the user is prompted to install certificates * Set installCerts value in config.ini if certicates are manually installed * Always set installCerts if the certificates exist * Add "Arduino Agent" to the title of dialogs * Fix check for pressed buttons * Move osascript execution function to Utilities to avoid code duplication * Modify certificate management from the systray menu * Install certificates if they are missing and the flag inside the config is set to true * Avoid code duplication * Fix button order and title * Do not restart the Agent if no action is performed on the certificate * Do not modify the config if the default browser is not Safari * Small messages/titles fixes --------- Co-authored-by: Xayton <30591904+Xayton@users.noreply.github.com> --- certificates/certificates.go | 48 +++++++++++++++++ certificates/install_darwin.go | 94 ++++++++++++++++++++++++++++++--- certificates/install_default.go | 12 +++++ config/config.go | 18 +++++++ main.go | 63 +++++++++++++++++++--- systray/systray_real.go | 59 +++++++++++++-------- tests/test_info.py | 6 +++ tests/test_v2.py | 6 +++ tests/test_ws.py | 9 ++++ utilities/utilities.go | 7 +++ 10 files changed, 285 insertions(+), 37 deletions(-) diff --git a/certificates/certificates.go b/certificates/certificates.go index 990fa2e01..baac7c337 100644 --- a/certificates/certificates.go +++ b/certificates/certificates.go @@ -30,8 +30,10 @@ import ( "math/big" "net" "os" + "strings" "time" + "github.com/arduino/arduino-create-agent/utilities" "github.com/arduino/go-paths-helper" log "github.com/sirupsen/logrus" ) @@ -267,3 +269,49 @@ func DeleteCertificates(certDir *paths.Path) { certDir.Join("cert.pem").Remove() certDir.Join("cert.cer").Remove() } + +// isExpired checks if a certificate is expired or about to expire (less than 1 month) +func isExpired() (bool, error) { + bound := time.Now().AddDate(0, 1, 0) + dateS, err := GetExpirationDate() + if err != nil { + return false, err + } + date, _ := time.Parse(time.DateTime, dateS) + return date.Before(bound), nil +} + +// PromptInstallCertsSafari prompts the user to install the HTTPS certificates if they are using Safari +func PromptInstallCertsSafari() bool { + buttonPressed := utilities.UserPrompt("display dialog \"The Arduino Agent needs a local HTTPS certificate to work correctly with Safari.\nIf you use Safari, you need to install it.\" buttons {\"Do not install\", \"Install the certificate for Safari\"} default button 2 with title \"Arduino Agent: Install certificate\"") + return strings.Contains(string(buttonPressed), "button returned:Install the certificate for Safari") +} + +// PromptExpiredCerts prompts the user to update the HTTPS certificates if they are using Safari +func PromptExpiredCerts(certDir *paths.Path) { + if expired, err := isExpired(); err != nil { + log.Errorf("cannot check if certificates are expired something went wrong: %s", err) + } else if expired { + buttonPressed := utilities.UserPrompt("display dialog \"The Arduino Agent needs a local HTTPS certificate to work correctly with Safari.\nYour certificate is expired or close to expiration. Do you want to update it?\" buttons {\"Do not update\", \"Update the certificate for Safari\"} default button 2 with title \"Arduino Agent: Update certificate\"") + if strings.Contains(string(buttonPressed), "button returned:Update the certificate for Safari") { + err := UninstallCertificates() + if err != nil { + log.Errorf("cannot uninstall certificates something went wrong: %s", err) + } else { + DeleteCertificates(certDir) + GenerateAndInstallCertificates(certDir) + } + } + } +} + +// GenerateAndInstallCertificates generates and installs the certificates +func GenerateAndInstallCertificates(certDir *paths.Path) { + GenerateCertificates(certDir) + err := InstallCertificate(certDir.Join("ca.cert.cer")) + // if something goes wrong during the cert install we remove them, so the user is able to retry + if err != nil { + log.Errorf("cannot install certificates something went wrong: %s", err) + DeleteCertificates(certDir) + } +} diff --git a/certificates/install_darwin.go b/certificates/install_darwin.go index 2c84d7dcb..892c390b0 100644 --- a/certificates/install_darwin.go +++ b/certificates/install_darwin.go @@ -89,15 +89,77 @@ const char *uninstallCert() { } return ""; } + +const char *getExpirationDate(char *expirationDate){ + // Create a key-value dictionary used to query the Keychain and look for the "Arduino" root certificate. + NSDictionary *getquery = @{ + (id)kSecClass: (id)kSecClassCertificate, + (id)kSecAttrLabel: @"Arduino", + (id)kSecReturnRef: @YES, + }; + + OSStatus err = noErr; + SecCertificateRef cert = NULL; + + // Use this function to check for errors + err = SecItemCopyMatching((CFDictionaryRef)getquery, (CFTypeRef *)&cert); + + if (err != noErr){ + NSString *errString = [@"Error: " stringByAppendingFormat:@"%d", err]; + NSLog(@"%@", errString); + return [errString cStringUsingEncoding:[NSString defaultCStringEncoding]]; + } + + // Get data from the certificate. We just need the "invalidity date" property. + CFDictionaryRef valuesDict = SecCertificateCopyValues(cert, (__bridge CFArrayRef)@[(__bridge id)kSecOIDInvalidityDate], NULL); + + id expirationDateValue; + if(valuesDict){ + CFDictionaryRef invalidityDateDictionaryRef = CFDictionaryGetValue(valuesDict, kSecOIDInvalidityDate); + if(invalidityDateDictionaryRef){ + CFTypeRef invalidityRef = CFDictionaryGetValue(invalidityDateDictionaryRef, kSecPropertyKeyValue); + if(invalidityRef){ + expirationDateValue = CFBridgingRelease(invalidityRef); + } + } + CFRelease(valuesDict); + } + + NSString *outputString = [@"" stringByAppendingFormat:@"%@", expirationDateValue]; + if([outputString isEqualToString:@""]){ + NSString *errString = @"Error: the expiration date of the certificate could not be found"; + NSLog(@"%@", errString); + return [errString cStringUsingEncoding:[NSString defaultCStringEncoding]]; + } + + // This workaround allows to obtain the expiration date alongside the error message + strncpy(expirationDate, [outputString cStringUsingEncoding:[NSString defaultCStringEncoding]], 32); + expirationDate[32-1] = 0; + + return ""; +} + +const char *getDefaultBrowserName() { + NSURL *defaultBrowserURL = [[NSWorkspace sharedWorkspace] URLForApplicationToOpenURL:[NSURL URLWithString:@"http://"]]; + if (defaultBrowserURL) { + NSBundle *defaultBrowserBundle = [NSBundle bundleWithURL:defaultBrowserURL]; + NSString *defaultBrowser = [defaultBrowserBundle objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + + return [defaultBrowser cStringUsingEncoding:[NSString defaultCStringEncoding]]; + } + + return ""; +} */ import "C" import ( "errors" - "os/exec" + "strings" "unsafe" log "github.com/sirupsen/logrus" + "github.com/arduino/arduino-create-agent/utilities" "github.com/arduino/go-paths-helper" ) @@ -110,9 +172,8 @@ func InstallCertificate(cert *paths.Path) error { p := C.installCert(ccert) s := C.GoString(p) if len(s) != 0 { - oscmd := exec.Command("osascript", "-e", "display dialog \""+s+"\" buttons \"OK\" with title \"Arduino Agent: Error installing certificates\"") - _ = oscmd.Run() - _ = UninstallCertificates() + utilities.UserPrompt("display dialog \"" + s + "\" buttons \"OK\" with title \"Arduino Agent: Error installing certificates\"") + UninstallCertificates() return errors.New(s) } return nil @@ -125,9 +186,30 @@ func UninstallCertificates() error { p := C.uninstallCert() s := C.GoString(p) if len(s) != 0 { - oscmd := exec.Command("osascript", "-e", "display dialog \""+s+"\" buttons \"OK\" with title \"Arduino Agent: Error uninstalling certificates\"") - _ = oscmd.Run() + utilities.UserPrompt("display dialog \"" + s + "\" buttons \"OK\" with title \"Arduino Agent: Error uninstalling certificates\"") return errors.New(s) } return nil } + +// GetExpirationDate returns the expiration date of a certificate stored in the keychain +func GetExpirationDate() (string, error) { + log.Infof("Retrieving certificate's expiration date") + dateString := C.CString("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") // 32 characters string + defer C.free(unsafe.Pointer(dateString)) + p := C.getExpirationDate(dateString) + s := C.GoString(p) + if len(s) != 0 { + utilities.UserPrompt("display dialog \"" + s + "\" buttons \"OK\" with title \"Arduino Agent: Error retrieving expiration date\"") + return "", errors.New(s) + } + date := C.GoString(dateString) + return strings.ReplaceAll(date, " +0000", ""), nil +} + +// GetDefaultBrowserName returns the name of the default browser +func GetDefaultBrowserName() string { + log.Infof("Retrieving default browser name") + p := C.getDefaultBrowserName() + return C.GoString(p) +} diff --git a/certificates/install_default.go b/certificates/install_default.go index 1b7f24bb9..8013c018d 100644 --- a/certificates/install_default.go +++ b/certificates/install_default.go @@ -36,3 +36,15 @@ func UninstallCertificates() error { log.Warn("platform not supported for the certificates uninstall") return errors.New("platform not supported for the certificates uninstall") } + +// GetExpirationDate won't do anything on unsupported Operative Systems +func GetExpirationDate() (string, error) { + log.Warn("platform not supported for retrieving certificates expiration date") + return "", errors.New("platform not supported for retrieving certificates expiration date") +} + +// GetDefaultBrowserName won't do anything on unsupported Operative Systems +func GetDefaultBrowserName() string { + log.Warn("platform not supported for retrieving default browser name") + return "" +} diff --git a/config/config.go b/config/config.go index 437437e59..69d29eeee 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ import ( "os" "github.com/arduino/go-paths-helper" + "github.com/go-ini/ini" log "github.com/sirupsen/logrus" ) @@ -124,3 +125,20 @@ func GenerateConfig(destDir *paths.Path) *paths.Path { log.Infof("generated config in %s", configPath) return configPath } + +// SetInstallCertsIni sets installCerts value to true in the config +func SetInstallCertsIni(filename string, value string) error { + cfg, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: false, AllowPythonMultilineValues: true}, filename) + if err != nil { + return err + } + _, err = cfg.Section("").NewKey("installCerts", value) + if err != nil { + return err + } + err = cfg.SaveTo(filename) + if err != nil { + return err + } + return nil +} diff --git a/main.go b/main.go index 45ae5259c..0231548d4 100755 --- a/main.go +++ b/main.go @@ -25,7 +25,6 @@ import ( "html/template" "io" "os" - "os/exec" "regexp" "runtime" "runtime/debug" @@ -40,6 +39,7 @@ import ( "github.com/arduino/arduino-create-agent/systray" "github.com/arduino/arduino-create-agent/tools" "github.com/arduino/arduino-create-agent/updater" + "github.com/arduino/arduino-create-agent/utilities" v2 "github.com/arduino/arduino-create-agent/v2" paths "github.com/arduino/go-paths-helper" cors "github.com/gin-contrib/cors" @@ -86,6 +86,7 @@ var ( verbose = iniConf.Bool("v", true, "show debug logging") crashreport = iniConf.Bool("crashreport", false, "enable crashreport logging") autostartMacOS = iniConf.Bool("autostartMacOS", true, "the Arduino Create Agent is able to start automatically after login on macOS (launchd agent)") + installCerts = iniConf.Bool("installCerts", false, "install the HTTPS certificate for Safari and keep it updated") ) // the ports filter provided by the user via the -regex flag, if any @@ -177,7 +178,7 @@ func loop() { // If we are updating manually from 1.2.7 to 1.3.0 we have to uninstall the old agent manually first. // This check will inform the user if he needs to run the uninstall first if runtime.GOOS == "darwin" && oldInstallExists() { - printDialog("Old agent installation of the Arduino Create Agent found, please uninstall it before launching the new one") + utilities.UserPrompt("display dialog \"Old agent installation of the Arduino Create Agent found, please uninstall it before launching the new one\" buttons \"OK\" with title \"Error\"") os.Exit(0) } @@ -220,6 +221,32 @@ func loop() { configPath = config.GenerateConfig(configDir) } + // if the default browser is Safari, prompt the user to install HTTPS certificates + // and eventually install them + if runtime.GOOS == "darwin" && cert.GetDefaultBrowserName() == "Safari" { + if exist, err := installCertsKeyExists(configPath.String()); err != nil { + log.Panicf("config.ini cannot be parsed: %s", err) + } else if !exist { + if config.CertsExist() { + err = config.SetInstallCertsIni(configPath.String(), "true") + if err != nil { + log.Panicf("config.ini cannot be parsed: %s", err) + } + } else if cert.PromptInstallCertsSafari() { + err = config.SetInstallCertsIni(configPath.String(), "true") + if err != nil { + log.Panicf("config.ini cannot be parsed: %s", err) + } + cert.GenerateAndInstallCertificates(config.GetCertificatesDir()) + } else { + err = config.SetInstallCertsIni(configPath.String(), "false") + if err != nil { + log.Panicf("config.ini cannot be parsed: %s", err) + } + } + } + } + // Parse the config.ini args, err := parseIni(configPath.String()) if err != nil { @@ -342,6 +369,24 @@ func loop() { } } + // check if the HTTPS certificates are expired and prompt the user to update them on macOS + if runtime.GOOS == "darwin" && cert.GetDefaultBrowserName() == "Safari" { + if *installCerts { + if config.CertsExist() { + cert.PromptExpiredCerts(config.GetCertificatesDir()) + } else if cert.PromptInstallCertsSafari() { + // installing the certificates from scratch at this point should only happen if + // something went wrong during previous installation attempts + cert.GenerateAndInstallCertificates(config.GetCertificatesDir()) + } else { + err = config.SetInstallCertsIni(configPath.String(), "false") + if err != nil { + log.Panicf("config.ini cannot be parsed: %s", err) + } + } + } + } + // launch the discoveries for the running system go serialPorts.Run() // launch the hub routine which is the singleton for the websocket server @@ -457,12 +502,6 @@ func oldInstallExists() bool { return oldAgentPath.Join("ArduinoCreateAgent.app").Exist() } -// printDialog will print a GUI error dialog on macos -func printDialog(dialogText string) { - oscmd := exec.Command("osascript", "-e", "display dialog \""+dialogText+"\" buttons \"OK\" with title \"Error\"") - _ = oscmd.Run() -} - func parseIni(filename string) (args []string, err error) { cfg, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: false, AllowPythonMultilineValues: true}, filename) if err != nil { @@ -487,3 +526,11 @@ func parseIni(filename string) (args []string, err error) { return args, nil } + +func installCertsKeyExists(filename string) (bool, error) { + cfg, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: false, AllowPythonMultilineValues: true}, filename) + if err != nil { + return false, err + } + return cfg.Section("").HasKey("installCerts"), nil +} diff --git a/systray/systray_real.go b/systray/systray_real.go index 62e52e21d..503373d82 100644 --- a/systray/systray_real.go +++ b/systray/systray_real.go @@ -22,11 +22,13 @@ package systray import ( "os" "runtime" + "strings" "fyne.io/systray" cert "github.com/arduino/arduino-create-agent/certificates" "github.com/arduino/arduino-create-agent/config" "github.com/arduino/arduino-create-agent/icon" + "github.com/arduino/arduino-create-agent/utilities" "github.com/go-ini/ini" log "github.com/sirupsen/logrus" "github.com/skratchdot/open-golang/open" @@ -63,16 +65,11 @@ func (s *Systray) start() { mRmCrashes := systray.AddMenuItem("Remove crash reports", "") s.updateMenuItem(mRmCrashes, config.LogsIsEmpty()) - mGenCerts := systray.AddMenuItem("Generate and Install HTTPS certificates", "HTTPS Certs") - mRemoveCerts := systray.AddMenuItem("Remove HTTPS certificates", "") + mManageCerts := systray.AddMenuItem("Manage HTTPS certificate", "HTTPS Certs") // On linux/windows chrome/firefox/edge(chromium) the agent works without problems on plain HTTP, // so we disable the menuItem to generate/install the certificates if runtime.GOOS != "darwin" { - s.updateMenuItem(mGenCerts, true) - s.updateMenuItem(mRemoveCerts, true) - } else { - s.updateMenuItem(mGenCerts, config.CertsExist()) - s.updateMenuItem(mRemoveCerts, !config.CertsExist()) + s.updateMenuItem(mManageCerts, true) } // Add pause/quit @@ -96,25 +93,41 @@ func (s *Systray) start() { case <-mRmCrashes.ClickedCh: RemoveCrashes() s.updateMenuItem(mRmCrashes, config.LogsIsEmpty()) - case <-mGenCerts.ClickedCh: + case <-mManageCerts.ClickedCh: + infoMsg := "The Arduino Agent needs a local HTTPS certificate to work correctly with Safari.\n\nYour HTTPS certificate status:\n" + buttons := "{\"Install the certificate for Safari\", \"OK\"} default button \"OK\"" certDir := config.GetCertificatesDir() - cert.GenerateCertificates(certDir) - err := cert.InstallCertificate(certDir.Join("ca.cert.cer")) - // if something goes wrong during the cert install we remove them, so the user is able to retry - if err != nil { - log.Errorf("cannot install certificates something went wrong: %s", err) - cert.DeleteCertificates(certDir) - } - s.Restart() - case <-mRemoveCerts.ClickedCh: - err := cert.UninstallCertificates() - if err != nil { - log.Errorf("cannot uninstall certificates something went wrong: %s", err) + if config.CertsExist() { + expDate, err := cert.GetExpirationDate() + if err != nil { + log.Errorf("cannot get certificates expiration date, something went wrong: %s", err) + } + infoMsg = infoMsg + "- Certificate installed: Yes\n- Certificate trusted: Yes\n- Certificate expiration date: " + expDate + buttons = "{\"Uninstall the certificate for Safari\", \"OK\"} default button \"OK\"" } else { - certDir := config.GetCertificatesDir() - cert.DeleteCertificates(certDir) + infoMsg = infoMsg + "- Certificate installed: No\n- Certificate trusted: N/A\n- Certificate expiration date: N/A" + } + pressedButton := utilities.UserPrompt("display dialog \"" + infoMsg + "\" buttons " + buttons + " with title \"Arduino Agent: Manage HTTPS certificate\"") + if strings.Contains(pressedButton, "Install certificate for Safari") { + cert.GenerateAndInstallCertificates(certDir) + err := config.SetInstallCertsIni(s.currentConfigFilePath.String(), "true") + if err != nil { + log.Errorf("cannot set installCerts value in config.ini: %s", err) + } + s.Restart() + } else if strings.Contains(pressedButton, "Uninstall certificate for Safari") { + err := cert.UninstallCertificates() + if err != nil { + log.Errorf("cannot uninstall certificates something went wrong: %s", err) + } else { + cert.DeleteCertificates(certDir) + err = config.SetInstallCertsIni(s.currentConfigFilePath.String(), "false") + if err != nil { + log.Errorf("cannot set installCerts value in config.ini: %s", err) + } + } + s.Restart() } - s.Restart() case <-mPause.ClickedCh: s.Pause() case <-mQuit.ClickedCh: diff --git a/tests/test_info.py b/tests/test_info.py index 6982ca352..efda3bce8 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -15,8 +15,14 @@ import re import requests +import pytest +from sys import platform +@pytest.mark.skipif( + platform == "darwin", + reason="on macOS the user is prompted to install certificates", +) def test_version(base_url, agent): resp = requests.get(f"{base_url}/info") diff --git a/tests/test_v2.py b/tests/test_v2.py index 9a3778027..5fa44034e 100644 --- a/tests/test_v2.py +++ b/tests/test_v2.py @@ -14,8 +14,14 @@ # along with this program. If not, see . import requests +import pytest +from sys import platform +@pytest.mark.skipif( + platform == "darwin", + reason="on macOS the user is prompted to install certificates", +) def test_get_tools(base_url, agent): resp = requests.get(f"{base_url}/v2/pkgs/tools/installed") diff --git a/tests/test_ws.py b/tests/test_ws.py index c2623da56..b8004649d 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -17,16 +17,25 @@ import json import base64 import pytest +from sys import platform from common import running_on_ci message = [] +@pytest.mark.skipif( + platform == "darwin", + reason="on macOS the user is prompted to install certificates", +) def test_ws_connection(socketio): print('my sid is', socketio.sid) assert socketio.sid is not None +@pytest.mark.skipif( + platform == "darwin", + reason="on macOS the user is prompted to install certificates", +) def test_list(socketio, message): socketio.emit('command', 'list') time.sleep(.2) diff --git a/utilities/utilities.go b/utilities/utilities.go index 4f40aaf73..63f09103e 100644 --- a/utilities/utilities.go +++ b/utilities/utilities.go @@ -149,3 +149,10 @@ func VerifyInput(input string, signature string) error { d := h.Sum(nil) return rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, d, sign) } + +// UserPrompt executes an osascript and returns the pressed button +func UserPrompt(dialog string) string { + oscmd := exec.Command("osascript", "-e", dialog) + pressedButton, _ := oscmd.Output() + return string(pressedButton) +}