Browse Source

enhance(e2ee): store encrypted password in keychain on desktop

instead of OPFS
Tienson Qin 3 weeks ago
parent
commit
6ed01dfb93

+ 2 - 1
resources/package.json

@@ -46,7 +46,8 @@
     "semver": "7.5.2",
     "socks-proxy-agent": "8.0.2",
     "update-electron-app": "2.0.1",
-    "zod": "^4.1.5"
+    "zod": "^4.1.5",
+    "keytar": "^7.9.0"
   },
   "devDependencies": {
     "@electron-forge/cli": "^7.8.3",

+ 10 - 0
src/electron/electron/handler.cljs

@@ -22,6 +22,7 @@
             [electron.fs-watcher :as watcher]
             [electron.git :as git]
             [electron.handler-interface :refer [handle]]
+            [electron.keychain :as keychain]
             [electron.logger :as logger]
             [electron.plugin :as plugin]
             [electron.server :as server]
@@ -617,6 +618,15 @@
 (defmethod handle :cancel-all-requests [_ args]
   (apply rsapi/cancel-all-requests (rest args)))
 
+(defmethod handle :keychain/save-e2ee-password [_window [_ refresh-token encrypted-text]]
+  (keychain/<set-password! refresh-token encrypted-text))
+
+(defmethod handle :keychain/get-e2ee-password [_window [_ refresh-token]]
+  (keychain/<get-password refresh-token))
+
+(defmethod handle :keychain/delete-e2ee-password [_window [_ refresh-token]]
+  (keychain/<delete-password! refresh-token))
+
 (defmethod handle :default [args]
   (logger/error "Error: no ipc handler for:" args))
 

+ 68 - 0
src/electron/electron/keychain.cljs

@@ -0,0 +1,68 @@
+(ns electron.keychain
+  "Helper functions for storing E2EE secrets inside the OS keychain."
+  (:require ["crypto" :as crypto]
+            ["electron" :refer [app]]
+            ["keytar" :as keytar]
+            [clojure.string :as string]
+            [electron.logger :as logger]
+            [promesa.core :as p]))
+
+(defonce ^:private service-name
+  (delay
+    (let [app-name (try (.getName app)
+                        (catch :default _ nil))]
+      (if (string/blank? app-name)
+        "Logseq"
+        app-name))))
+
+(defn- keychain-service
+  []
+  (str (force service-name) " E2EE"))
+
+(defn- normalize-account
+  [refresh-token]
+  (when (and (string? refresh-token)
+             (not (string/blank? refresh-token)))
+    (try
+      (let [hash (.createHash crypto "sha256")]
+        (.update hash refresh-token)
+        (.digest hash "hex"))
+      (catch :default e
+        (logger/error ::normalize-account {:error e})
+        nil))))
+
+(defn supported?
+  []
+  (boolean keytar))
+
+(defn <set-password!
+  "Persist `encrypted-text` for the `refresh-token` entry."
+  [refresh-token encrypted-text]
+  (if-let [account (and (supported?) (normalize-account refresh-token))]
+    (-> (p/let [_ (.setPassword keytar (keychain-service) account encrypted-text)]
+          true)
+        (p/catch (fn [e]
+                   (logger/error ::set-password {:error e})
+                   (throw e))))
+    (p/resolved false)))
+
+(defn <get-password
+  "Fetch encrypted text stored for `refresh-token`."
+  [refresh-token]
+  (if-let [account (and (supported?) (normalize-account refresh-token))]
+    (-> (p/let [password (.getPassword keytar (keychain-service) account)]
+          password)
+        (p/catch (fn [e]
+                   (logger/error ::get-password {:error e})
+                   (throw e))))
+    (p/resolved nil)))
+
+(defn <delete-password!
+  [refresh-token]
+  (if-let [account (and (supported?) (normalize-account refresh-token))]
+    (-> (p/let [_ (.deletePassword keytar (keychain-service) account)]
+          true)
+        (p/catch (fn [e]
+                   (logger/error ::delete-password {:error e})
+                   (throw e))))
+    (p/resolved false)))

+ 46 - 1
src/main/frontend/handler/e2ee.cljs

@@ -1,11 +1,44 @@
 (ns frontend.handler.e2ee
   "rtc E2EE related fns"
-  (:require [frontend.common.crypt :as crypt]
+  (:require [electron.ipc :as ipc]
+            [frontend.common.crypt :as crypt]
             [frontend.common.thread-api :refer [def-thread-api]]
             [frontend.state :as state]
+            [frontend.util :as util]
             [lambdaisland.glogi :as log]
             [promesa.core :as p]))
 
+(def ^:private save-op :keychain/save-e2ee-password)
+(def ^:private get-op :keychain/get-e2ee-password)
+(def ^:private delete-op :keychain/delete-e2ee-password)
+
+(defn- <keychain-save!
+  [refresh-token encrypted-text]
+  (if (util/electron?)
+    (-> (ipc/ipc save-op refresh-token encrypted-text)
+        (p/catch (fn [e]
+                   (log/error :keychain-save-failed e)
+                   (throw e))))
+    (p/resolved nil)))
+
+(defn- <keychain-get
+  [refresh-token]
+  (if (util/electron?)
+    (-> (ipc/ipc get-op refresh-token)
+        (p/catch (fn [e]
+                   (log/error :keychain-get-failed e)
+                   (throw e))))
+    (p/resolved nil)))
+
+(defn- <keychain-delete!
+  [refresh-token]
+  (if (util/electron?)
+    (-> (ipc/ipc delete-op refresh-token)
+        (p/catch (fn [e]
+                   (log/error :keychain-delete-failed e)
+                   (throw e))))
+    (p/resolved nil)))
+
 (def-thread-api :thread-api/request-e2ee-password
   []
   (p/let [password-promise (state/pub-event! [:rtc/request-e2ee-password])
@@ -25,3 +58,15 @@
 (def-thread-api :thread-api/decrypt-user-e2ee-private-key
   [encrypted-private-key]
   (<decrypt-user-e2ee-private-key encrypted-private-key))
+
+(def-thread-api :thread-api/electron-save-e2ee-password
+  [refresh-token encrypted-text]
+  (<keychain-save! refresh-token encrypted-text))
+
+(def-thread-api :thread-api/electron-get-e2ee-password
+  [refresh-token]
+  (<keychain-get refresh-token))
+
+(def-thread-api :thread-api/electron-delete-e2ee-password
+  [refresh-token]
+  (<keychain-delete! refresh-token))

+ 29 - 2
src/main/frontend/worker/rtc/crypt.cljs

@@ -4,12 +4,14 @@
   Each graph has an AES key.
   Server stores the encrypted AES key, public key, and encrypted private key."
   (:require ["/frontend/idbkv" :as idb-keyval]
+            [clojure.string :as string]
             [frontend.common.crypt :as crypt]
             [frontend.common.file.opfs :as opfs]
             [frontend.common.missionary :as c.m]
             [frontend.common.thread-api :refer [def-thread-api]]
             [frontend.worker.rtc.ws-util :as ws-util]
             [frontend.worker.state :as worker-state]
+            [lambdaisland.glogi :as log]
             [logseq.db :as ldb]
             [missionary.core :as m]
             [promesa.core :as p])
@@ -17,16 +19,41 @@
 
 (defonce ^:private store (delay (idb-keyval/newStore "localforage" "keyvaluepairs" 2)))
 (defonce ^:private e2ee-password-file "e2ee-password")
+(defonce ^:private electron-env?
+  (let [href (try (.. js/self -location -href)
+                  (catch :default _ nil))]
+    (boolean (and (string? href)
+                  (string/includes? href "electron=true")))))
+
+(defn- electron-worker?
+  []
+  electron-env?)
+
+(defn- <electron-save-password-text!
+  [refresh-token encrypted-text]
+  (worker-state/<invoke-main-thread :thread-api/electron-save-e2ee-password refresh-token encrypted-text))
+
+(defn- <electron-read-password-text
+  [refresh-token]
+  (worker-state/<invoke-main-thread :thread-api/electron-get-e2ee-password refresh-token))
 
 (defn- <save-e2ee-password
   [refresh-token password]
   (p/let [result (crypt/<encrypt-text-by-text-password refresh-token password)
           text (ldb/write-transit-str result)]
-    (opfs/<write-text! e2ee-password-file text)))
+    (if (electron-worker?)
+      (-> (p/let [_ (<electron-save-password-text! refresh-token text)]
+            nil)
+          (p/catch (fn [e]
+                     (log/error :electron-save-e2ee-password {:error e})
+                     (throw e))))
+      (opfs/<write-text! e2ee-password-file text))))
 
 (defn- <read-e2ee-password
   [refresh-token]
-  (p/let [text (opfs/<read-text! e2ee-password-file)
+  (p/let [text (if (electron-worker?)
+                 (<electron-read-password-text refresh-token)
+                 (opfs/<read-text! e2ee-password-file))
           data (ldb/read-transit-str text)
           password (crypt/<decrypt-text-by-text-password refresh-token data)]
     password))