Browse Source

feat: add `electron.safeStorage` encryption API (#30020) (#30430)

* feat: add SafeStorage api; first commit

* chore: rename files to fit semantically

* chore: add linkedBindings

* chore: fix function signatures

* chore: refactor eisCookieEncryptionEnabled() fuse

* chore: create test file

* chore: add tests and documentation

* chore: add copyright and lint

* chore: add additional tests

* chore: fix constructor

* chore: commit for pair programming

* wip: commit for keeley pairing

* chore: docs change and code cleanup

* chore: add linux import

* chore: add description to documentation

* chore: fixing tests

* chore: modify behaviour to not allow unencrypted strings as decyption input

* fix add patch for enabling default v11 encryption on Linux

* chore: remove file after each test

* chore: fix patch

* chore: remove chromium patch

* chore: add linux specific tests

* chore: fix path

* chore: add checker for linuux file deletion

* chore: add dcheck back

* chore: remove reference to headless mode

* chore: remove tests for linux

* chore: edit commit message

* chore: refactor safeStorage to not be a class

* chore: remove static variable from header

* chore:  spec file remove settimeout

Co-authored-by: VerteDinde <[email protected]>

Co-authored-by: George Xu <[email protected]>
Co-authored-by: VerteDinde <[email protected]>
Co-authored-by: John Kleinschmidt <[email protected]>
trop[bot] 3 years ago
parent
commit
24b3ee8444

+ 40 - 0
docs/api/safe-storage.md

@@ -0,0 +1,40 @@
+# safeStorage
+
+> Allows access to simple encryption and decryption of strings for storage on the local machine.
+
+Process: [Main](../glossary.md#main-process)
+
+This module protects data stored on disk from being accessed by other applications or users with full disk access.
+
+Note that on Mac, access to the system Keychain is required and
+these calls can block the current thread to collect user input.
+The same is true for Linux, if a password management tool is available.
+
+## Methods
+
+The `safeStorage` module has the following methods:
+
+### `safeStorage.isEncryptionAvailable()`
+
+Returns `Boolean` - Whether encryption is available.
+
+On Linux, returns true if the secret key is
+available. On MacOS, returns true if Keychain is available.
+On Windows, returns true with no other preconditions.
+
+### `safeStorage.encryptString(plainText)`
+
+* `plainText` String
+
+Returns `Buffer` -  An array of bytes representing the encrypted string.
+
+This function will throw an error if encryption fails.
+
+### `safeStorage.decryptString(encrypted)`
+
+* `encrypted` Buffer
+
+Returns `String` - the decrypted string. Decrypts the encrypted buffer
+obtained  with `safeStorage.encryptString` back into a string.
+
+This function will throw an error if decryption fails.

+ 2 - 0
filenames.auto.gni

@@ -42,6 +42,7 @@ auto_filenames = {
     "docs/api/power-save-blocker.md",
     "docs/api/process.md",
     "docs/api/protocol.md",
+    "docs/api/safe-storage.md",
     "docs/api/screen.md",
     "docs/api/service-workers.md",
     "docs/api/session.md",
@@ -213,6 +214,7 @@ auto_filenames = {
     "lib/browser/api/power-monitor.ts",
     "lib/browser/api/power-save-blocker.ts",
     "lib/browser/api/protocol.ts",
+    "lib/browser/api/safe-storage.ts",
     "lib/browser/api/screen.ts",
     "lib/browser/api/session.ts",
     "lib/browser/api/share-menu.ts",

+ 2 - 0
filenames.gni

@@ -288,6 +288,8 @@ filenames = {
     "shell/browser/api/electron_api_printing.cc",
     "shell/browser/api/electron_api_protocol.cc",
     "shell/browser/api/electron_api_protocol.h",
+    "shell/browser/api/electron_api_safe_storage.cc",
+    "shell/browser/api/electron_api_safe_storage.h",
     "shell/browser/api/electron_api_screen.cc",
     "shell/browser/api/electron_api_screen.h",
     "shell/browser/api/electron_api_service_worker_context.cc",

+ 1 - 0
lib/browser/api/module-list.ts

@@ -24,6 +24,7 @@ export const browserModuleList: ElectronInternal.ModuleEntry[] = [
   { name: 'powerMonitor', loader: () => require('./power-monitor') },
   { name: 'powerSaveBlocker', loader: () => require('./power-save-blocker') },
   { name: 'protocol', loader: () => require('./protocol') },
+  { name: 'safeStorage', loader: () => require('./safe-storage') },
   { name: 'screen', loader: () => require('./screen') },
   { name: 'session', loader: () => require('./session') },
   { name: 'ShareMenu', loader: () => require('./share-menu') },

+ 3 - 0
lib/browser/api/safe-storage.ts

@@ -0,0 +1,3 @@
+const safeStorage = process._linkedBinding('electron_browser_safe_storage');
+
+module.exports = safeStorage;

+ 121 - 0
shell/browser/api/electron_api_safe_storage.cc

@@ -0,0 +1,121 @@
+// Copyright (c) 2021 Slack Technologies, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/api/electron_api_safe_storage.h"
+
+#include <string>
+#include <vector>
+
+#include "components/os_crypt/os_crypt.h"
+#include "shell/browser/browser.h"
+#include "shell/common/gin_converters/base_converter.h"
+#include "shell/common/gin_converters/callback_converter.h"
+#include "shell/common/gin_helper/dictionary.h"
+#include "shell/common/node_includes.h"
+#include "shell/common/platform_util.h"
+
+namespace electron {
+
+namespace safestorage {
+
+static const char* kEncryptionVersionPrefixV10 = "v10";
+static const char* kEncryptionVersionPrefixV11 = "v11";
+
+#if DCHECK_IS_ON()
+static bool electron_crypto_ready = false;
+
+void SetElectronCryptoReady(bool ready) {
+  electron_crypto_ready = ready;
+}
+#endif
+
+bool IsEncryptionAvailable() {
+  return OSCrypt::IsEncryptionAvailable();
+}
+
+v8::Local<v8::Value> EncryptString(v8::Isolate* isolate,
+                                   const std::string& plaintext) {
+  if (!OSCrypt::IsEncryptionAvailable()) {
+    gin_helper::ErrorThrower(isolate).ThrowError(
+        "Error while decrypting the ciphertext provided to "
+        "safeStorage.decryptString. "
+        "Encryption is not available.");
+    return v8::Local<v8::Value>();
+  }
+
+  std::string ciphertext;
+  bool encrypted = OSCrypt::EncryptString(plaintext, &ciphertext);
+
+  if (!encrypted) {
+    gin_helper::ErrorThrower(isolate).ThrowError(
+        "Error while encrypting the text provided to "
+        "safeStorage.encryptString.");
+    return v8::Local<v8::Value>();
+  }
+
+  return node::Buffer::Copy(isolate, ciphertext.c_str(), ciphertext.size())
+      .ToLocalChecked();
+}
+
+std::string DecryptString(v8::Isolate* isolate, v8::Local<v8::Value> buffer) {
+  if (!OSCrypt::IsEncryptionAvailable()) {
+    gin_helper::ErrorThrower(isolate).ThrowError(
+        "Error while decrypting the ciphertext provided to "
+        "safeStorage.decryptString. "
+        "Decryption is not available.");
+    return "";
+  }
+
+  if (!node::Buffer::HasInstance(buffer)) {
+    gin_helper::ErrorThrower(isolate).ThrowError(
+        "Expected the first argument of decryptString() to be a buffer");
+    return "";
+  }
+
+  // ensures an error is thrown in Mac or Linux on
+  // decryption failure, rather than failing silently
+  const char* data = node::Buffer::Data(buffer);
+  auto size = node::Buffer::Length(buffer);
+  std::string ciphertext(data, size);
+  if (ciphertext.empty()) {
+    return "";
+  }
+
+  if (ciphertext.find(kEncryptionVersionPrefixV10) != 0 &&
+      ciphertext.find(kEncryptionVersionPrefixV11) != 0) {
+    gin_helper::ErrorThrower(isolate).ThrowError(
+        "Error while decrypting the ciphertext provided to "
+        "safeStorage.decryptString. "
+        "Ciphertext does not appear to be encrypted.");
+    return "";
+  }
+
+  std::string plaintext;
+  bool decrypted = OSCrypt::DecryptString(ciphertext, &plaintext);
+  if (!decrypted) {
+    gin_helper::ErrorThrower(isolate).ThrowError(
+        "Error while decrypting the ciphertext provided to "
+        "safeStorage.decryptString.");
+    return "";
+  }
+  return plaintext;
+}
+
+}  // namespace safestorage
+
+}  // namespace electron
+
+void Initialize(v8::Local<v8::Object> exports,
+                v8::Local<v8::Value> unused,
+                v8::Local<v8::Context> context,
+                void* priv) {
+  v8::Isolate* isolate = context->GetIsolate();
+  gin_helper::Dictionary dict(isolate, exports);
+  dict.SetMethod("isEncryptionAvailable",
+                 &electron::safestorage::IsEncryptionAvailable);
+  dict.SetMethod("encryptString", &electron::safestorage::EncryptString);
+  dict.SetMethod("decryptString", &electron::safestorage::DecryptString);
+}
+
+NODE_LINKED_MODULE_CONTEXT_AWARE(electron_browser_safe_storage, Initialize)

+ 25 - 0
shell/browser/api/electron_api_safe_storage.h

@@ -0,0 +1,25 @@
+// Copyright (c) 2021 Slack Technologies, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef SHELL_BROWSER_API_ELECTRON_API_SAFE_STORAGE_H_
+#define SHELL_BROWSER_API_ELECTRON_API_SAFE_STORAGE_H_
+
+#include "base/dcheck_is_on.h"
+
+namespace electron {
+
+namespace safestorage {
+
+// Used in a DCHECK to validate that our assumption that the network context
+// manager has initialized before app ready holds true. Only used in the
+// testing build
+#if DCHECK_IS_ON()
+void SetElectronCryptoReady(bool ready);
+#endif
+
+}  // namespace safestorage
+
+}  // namespace electron
+
+#endif  // SHELL_BROWSER_API_ELECTRON_API_SAFE_STORAGE_H_

+ 8 - 14
shell/browser/browser_process_impl.cc

@@ -103,20 +103,14 @@ void BrowserProcessImpl::PostEarlyInitialization() {
 
   // Only use a persistent prefs store when cookie encryption is enabled as that
   // is the only key that needs it
-  if (electron::fuses::IsCookieEncryptionEnabled()) {
-    base::FilePath prefs_path;
-    CHECK(base::PathService::Get(chrome::DIR_USER_DATA, &prefs_path));
-    prefs_path = prefs_path.Append(FILE_PATH_LITERAL("Local State"));
-    base::ThreadRestrictions::ScopedAllowIO allow_io;
-    scoped_refptr<JsonPrefStore> user_pref_store =
-        base::MakeRefCounted<JsonPrefStore>(prefs_path);
-    user_pref_store->ReadPrefs();
-    prefs_factory.set_user_prefs(user_pref_store);
-  } else {
-    auto user_pref_store =
-        base::MakeRefCounted<OverlayUserPrefStore>(new InMemoryPrefStore);
-    prefs_factory.set_user_prefs(user_pref_store);
-  }
+  base::FilePath prefs_path;
+  CHECK(base::PathService::Get(chrome::DIR_USER_DATA, &prefs_path));
+  prefs_path = prefs_path.Append(FILE_PATH_LITERAL("Local State"));
+  base::ThreadRestrictions::ScopedAllowIO allow_io;
+  scoped_refptr<JsonPrefStore> user_pref_store =
+      base::MakeRefCounted<JsonPrefStore>(prefs_path);
+  user_pref_store->ReadPrefs();
+  prefs_factory.set_user_prefs(user_pref_store);
   local_state_ = prefs_factory.Create(std::move(pref_registry));
 }
 

+ 4 - 6
shell/browser/electron_browser_main_parts.cc

@@ -536,13 +536,11 @@ void ElectronBrowserMainParts::PreCreateMainMessageLoopCommon() {
   media::SetLocalizedStringProvider(MediaStringProvider);
 
 #if defined(OS_WIN)
-  if (electron::fuses::IsCookieEncryptionEnabled()) {
-    auto* local_state = g_browser_process->local_state();
-    DCHECK(local_state);
+  auto* local_state = g_browser_process->local_state();
+  DCHECK(local_state);
 
-    bool os_crypt_init = OSCrypt::Init(local_state);
-    DCHECK(os_crypt_init);
-  }
+  bool os_crypt_init = OSCrypt::Init(local_state);
+  DCHECK(os_crypt_init);
 #endif
 }
 

+ 49 - 26
shell/browser/net/system_network_context_manager.cc

@@ -28,6 +28,7 @@
 #include "services/network/public/cpp/features.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
 #include "services/network/public/mojom/network_context.mojom.h"
+#include "shell/browser/api/electron_api_safe_storage.h"
 #include "shell/browser/browser.h"
 #include "shell/browser/electron_browser_client.h"
 #include "shell/common/application_info.h"
@@ -39,6 +40,10 @@
 #include "components/os_crypt/keychain_password_mac.h"
 #endif
 
+#if defined(OS_LINUX)
+#include "components/os_crypt/key_storage_config_linux.h"
+#endif
+
 namespace {
 
 // The global instance of the SystemNetworkContextmanager.
@@ -233,38 +238,56 @@ void SystemNetworkContextManager::OnNetworkServiceCreated(
       network_context_.BindNewPipeAndPassReceiver(),
       CreateNetworkContextParams());
 
-  if (electron::fuses::IsCookieEncryptionEnabled()) {
-    std::string app_name = electron::Browser::Get()->GetName();
+  std::string app_name = electron::Browser::Get()->GetName();
 #if defined(OS_MAC)
-    KeychainPassword::GetServiceName() = app_name + " Safe Storage";
-    KeychainPassword::GetAccountName() = app_name;
+  KeychainPassword::GetServiceName() = app_name + " Safe Storage";
+  KeychainPassword::GetAccountName() = app_name;
 #endif
-    // The OSCrypt keys are process bound, so if network service is out of
-    // process, send it the required key.
-    if (content::IsOutOfProcessNetworkService()) {
 #if defined(OS_LINUX)
-      // c.f.
-      // https://source.chromium.org/chromium/chromium/src/+/master:chrome/browser/net/system_network_context_manager.cc;l=515;drc=9d82515060b9b75fa941986f5db7390299669ef1;bpv=1;bpt=1
-      const base::CommandLine& command_line =
-          *base::CommandLine::ForCurrentProcess();
-
-      network::mojom::CryptConfigPtr config =
-          network::mojom::CryptConfig::New();
-      config->application_name = app_name;
-      config->product_name = app_name;
-      // c.f.
-      // https://source.chromium.org/chromium/chromium/src/+/master:chrome/common/chrome_switches.cc;l=689;drc=9d82515060b9b75fa941986f5db7390299669ef1
-      config->store =
-          command_line.GetSwitchValueASCII(::switches::kPasswordStore);
-      config->should_use_preference =
-          command_line.HasSwitch(::switches::kEnableEncryptionSelection);
-      base::PathService::Get(chrome::DIR_USER_DATA, &config->user_data_path);
-      network_service->SetCryptConfig(std::move(config));
+  // c.f.
+  // https://source.chromium.org/chromium/chromium/src/+/master:chrome/browser/net/system_network_context_manager.cc;l=515;drc=9d82515060b9b75fa941986f5db7390299669ef1;bpv=1;bpt=1
+  const base::CommandLine& command_line =
+      *base::CommandLine::ForCurrentProcess();
+
+  auto config = std::make_unique<os_crypt::Config>();
+  config->store = command_line.GetSwitchValueASCII(::switches::kPasswordStore);
+  config->product_name = app_name;
+  config->application_name = app_name;
+  config->main_thread_runner = base::ThreadTaskRunnerHandle::Get();
+  // c.f.
+  // https://source.chromium.org/chromium/chromium/src/+/master:chrome/common/chrome_switches.cc;l=689;drc=9d82515060b9b75fa941986f5db7390299669ef1
+  config->should_use_preference =
+      command_line.HasSwitch(::switches::kEnableEncryptionSelection);
+  base::PathService::Get(chrome::DIR_USER_DATA, &config->user_data_path);
+#endif
+
+  // The OSCrypt keys are process bound, so if network service is out of
+  // process, send it the required key.
+  if (content::IsOutOfProcessNetworkService() &&
+      electron::fuses::IsCookieEncryptionEnabled()) {
+#if defined(OS_LINUX)
+    network::mojom::CryptConfigPtr network_crypt_config =
+        network::mojom::CryptConfig::New();
+    network_crypt_config->application_name = config->application_name;
+    network_crypt_config->product_name = config->product_name;
+    network_crypt_config->store = config->store;
+    network_crypt_config->should_use_preference = config->should_use_preference;
+    network_crypt_config->user_data_path = config->user_data_path;
+
+    network_service->SetCryptConfig(std::move(network_crypt_config));
+
 #else
-      network_service->SetEncryptionKey(OSCrypt::GetRawEncryptionKey());
+    network_service->SetEncryptionKey(OSCrypt::GetRawEncryptionKey());
 #endif
-    }
   }
+
+#if defined(OS_LINUX)
+  OSCrypt::SetConfig(std::move(config));
+#endif
+
+#if DCHECK_IS_ON()
+  electron::safestorage::SetElectronCryptoReady(true);
+#endif
 }
 
 network::mojom::NetworkContextParamsPtr

+ 1 - 0
shell/common/node_bindings.cc

@@ -60,6 +60,7 @@
   V(electron_browser_power_save_blocker) \
   V(electron_browser_protocol)           \
   V(electron_browser_printing)           \
+  V(electron_browser_safe_storage)       \
   V(electron_browser_session)            \
   V(electron_browser_system_preferences) \
   V(electron_browser_base_window)        \

+ 103 - 0
spec-main/api-safe-storage-spec.ts

@@ -0,0 +1,103 @@
+import * as cp from 'child_process';
+import * as path from 'path';
+import { safeStorage } from 'electron/main';
+import { expect } from 'chai';
+import { emittedOnce } from './events-helpers';
+import { ifdescribe } from './spec-helpers';
+import * as fs from 'fs';
+
+/* isEncryptionAvailable returns false in Linux when running CI due to a mocked dbus. This stops
+* Chrome from reaching the system's keyring or libsecret. When running the tests with config.store
+* set to basic-text, a nullptr is returned from chromium,  defaulting the available encryption to false.
+*
+* Because all encryption methods are gated by isEncryptionAvailable, the methods will never return the correct values
+* when run on CI and linux.
+*/
+
+ifdescribe(process.platform !== 'linux')('safeStorage module', () => {
+  after(async () => {
+    const pathToEncryptedString = path.resolve(__dirname, 'fixtures', 'api', 'safe-storage', 'encrypted.txt');
+    if (fs.existsSync(pathToEncryptedString)) {
+      await fs.unlinkSync(pathToEncryptedString);
+    }
+  });
+
+  describe('SafeStorage.isEncryptionAvailable()', () => {
+    it('should return true when encryption key is available (macOS, Windows)', () => {
+      expect(safeStorage.isEncryptionAvailable()).to.equal(true);
+    });
+  });
+
+  describe('SafeStorage.encryptString()', () => {
+    it('valid input should correctly encrypt string', () => {
+      const plaintext = 'plaintext';
+      const encrypted = safeStorage.encryptString(plaintext);
+      expect(Buffer.isBuffer(encrypted)).to.equal(true);
+    });
+
+    it('UTF-16 characters can be encrypted', () => {
+      const plaintext = '€ - utf symbol';
+      const encrypted = safeStorage.encryptString(plaintext);
+      expect(Buffer.isBuffer(encrypted)).to.equal(true);
+    });
+  });
+
+  describe('SafeStorage.decryptString()', () => {
+    it('valid input should correctly decrypt string', () => {
+      const encrypted = safeStorage.encryptString('plaintext');
+      expect(safeStorage.decryptString(encrypted)).to.equal('plaintext');
+    });
+
+    it('UTF-16 characters can be decrypted', () => {
+      const plaintext = '€ - utf symbol';
+      const encrypted = safeStorage.encryptString(plaintext);
+      expect(safeStorage.decryptString(encrypted)).to.equal(plaintext);
+    });
+
+    it('unencrypted input should throw', () => {
+      const plaintextBuffer = Buffer.from('I am unencoded!', 'utf-8');
+      expect(() => {
+        safeStorage.decryptString(plaintextBuffer);
+      }).to.throw(Error);
+    });
+
+    it('non-buffer input should throw', () => {
+      const notABuffer = {} as any;
+      expect(() => {
+        safeStorage.decryptString(notABuffer);
+      }).to.throw(Error);
+    });
+  });
+  describe('safeStorage persists encryption key across app relaunch', () => {
+    it('can decrypt after closing and reopening app', async () => {
+      const fixturesPath = path.resolve(__dirname, 'fixtures');
+
+      const encryptAppPath = path.join(fixturesPath, 'api', 'safe-storage', 'encrypt-app');
+      const encryptAppProcess = cp.spawn(process.execPath, [encryptAppPath]);
+      let stdout: string = '';
+      encryptAppProcess.stderr.on('data', data => { stdout += data; });
+      encryptAppProcess.stderr.on('data', data => { stdout += data; });
+
+      try {
+        await emittedOnce(encryptAppProcess, 'exit');
+
+        const appPath = path.join(fixturesPath, 'api', 'safe-storage', 'decrypt-app');
+        const relaunchedAppProcess = cp.spawn(process.execPath, [appPath]);
+
+        let output = '';
+        relaunchedAppProcess.stdout.on('data', data => { output += data; });
+        relaunchedAppProcess.stderr.on('data', data => { output += data; });
+
+        const [code] = await emittedOnce(relaunchedAppProcess, 'exit');
+
+        if (!output.includes('plaintext')) {
+          console.log(code, output);
+        }
+        expect(output).to.include('plaintext');
+      } catch (e) {
+        console.log(stdout);
+        throw e;
+      }
+    });
+  });
+});

+ 13 - 0
spec-main/fixtures/api/safe-storage/decrypt-app/main.js

@@ -0,0 +1,13 @@
+const { app, safeStorage, ipcMain } = require('electron');
+const { promises: fs } = require('fs');
+const path = require('path');
+
+const pathToEncryptedString = path.resolve(__dirname, '..', 'encrypted.txt');
+const readFile = fs.readFile;
+
+app.whenReady().then(async () => {
+  const encryptedString = await readFile(pathToEncryptedString);
+  const decrypted = safeStorage.decryptString(encryptedString);
+  console.log(decrypted);
+  app.quit();
+});

+ 4 - 0
spec-main/fixtures/api/safe-storage/decrypt-app/package.json

@@ -0,0 +1,4 @@
+{
+    "name": "electron-safe-storage",
+    "main": "main.js"
+}

+ 12 - 0
spec-main/fixtures/api/safe-storage/encrypt-app/main.js

@@ -0,0 +1,12 @@
+const { app, safeStorage, ipcMain } = require('electron');
+const { promises: fs } = require('fs');
+const path = require('path');
+
+const pathToEncryptedString = path.resolve(__dirname, '..', 'encrypted.txt');
+const writeFile = fs.writeFile;
+
+app.whenReady().then(async () => {
+  const encrypted = safeStorage.encryptString('plaintext');
+  const encryptedString = await writeFile(pathToEncryptedString, encrypted);
+  app.quit();
+});

+ 4 - 0
spec-main/fixtures/api/safe-storage/encrypt-app/package.json

@@ -0,0 +1,4 @@
+{
+  "name": "electron-safe-storage",
+  "main": "main.js"
+}

+ 1 - 0
typings/internal-ambient.d.ts

@@ -233,6 +233,7 @@ declare namespace NodeJS {
     };
     _linkedBinding(name: 'electron_browser_power_monitor'): PowerMonitorBinding;
     _linkedBinding(name: 'electron_browser_power_save_blocker'): { powerSaveBlocker: Electron.PowerSaveBlocker };
+    _linkedBinding(name: 'electron_browser_safe_storage'): { safeStorage: Electron.SafeStorage };
     _linkedBinding(name: 'electron_browser_session'): typeof Electron.Session;
     _linkedBinding(name: 'electron_browser_system_preferences'): { systemPreferences: Electron.SystemPreferences };
     _linkedBinding(name: 'electron_browser_tray'): { Tray: Electron.Tray };