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

instead of OPFS
This commit is contained in:
Tienson Qin
2025-11-11 20:35:10 +08:00
parent a9326361cb
commit 6ed01dfb93
5 changed files with 155 additions and 4 deletions

View File

@@ -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",

View File

@@ -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))

View File

@@ -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)))

View File

@@ -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))

View File

@@ -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))