Browse Source

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

Tienson Qin 3 weeks ago
parent
commit
4593ab3d77

+ 1 - 0
android/app/capacitor.build.gradle

@@ -9,6 +9,7 @@ android {
 
 apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
 dependencies {
+    implementation project(':aparajita-capacitor-secure-storage')
     implementation project(':capacitor-community-safe-area')
     implementation project(':capacitor-action-sheet')
     implementation project(':capacitor-app')

+ 4 - 0
android/app/src/main/assets/capacitor.plugins.json

@@ -1,4 +1,8 @@
 [
+	{
+		"pkg": "@aparajita/capacitor-secure-storage",
+		"classpath": "com.aparajita.capacitor.securestorage.SecureStorage"
+	},
 	{
 		"pkg": "@capacitor-community/safe-area",
 		"classpath": "com.getcapacitor.community.safearea.SafeAreaPlugin"

+ 3 - 0
android/capacitor.settings.gradle

@@ -2,6 +2,9 @@
 include ':capacitor-android'
 project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
 
+include ':aparajita-capacitor-secure-storage'
+project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/@aparajita/capacitor-secure-storage/android')
+
 include ':capacitor-community-safe-area'
 project(':capacitor-community-safe-area').projectDir = new File('../node_modules/@capacitor-community/safe-area/android')
 

+ 1 - 0
ios/App/Podfile

@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
 def capacitor_pods
   pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
   pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
+  pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/@aparajita/capacitor-secure-storage'
   pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/@capacitor-community/safe-area'
   pod 'CapacitorActionSheet', :path => '../../node_modules/@capacitor/action-sheet'
   pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'

+ 14 - 1
ios/App/Podfile.lock

@@ -1,4 +1,7 @@
 PODS:
+  - AparajitaCapacitorSecureStorage (7.1.6):
+    - Capacitor
+    - KeychainSwift (~> 21.0)
   - Capacitor (7.2.0):
     - CapacitorCordova
   - CapacitorActionSheet (7.0.1):
@@ -32,12 +35,14 @@ PODS:
     - Capacitor
   - JcesarmobileSslSkip (0.4.0):
     - Capacitor
+  - KeychainSwift (21.0.0)
   - SendIntent (7.0.0):
     - Capacitor
   - StayLiquid (0.1.0):
     - Capacitor (>= 6.0.0)
 
 DEPENDENCIES:
+  - "AparajitaCapacitorSecureStorage (from `../../node_modules/@aparajita/capacitor-secure-storage`)"
   - "Capacitor (from `../../node_modules/@capacitor/ios`)"
   - "CapacitorActionSheet (from `../../node_modules/@capacitor/action-sheet`)"
   - "CapacitorApp (from `../../node_modules/@capacitor/app`)"
@@ -58,7 +63,13 @@ DEPENDENCIES:
   - SendIntent (from `../../node_modules/send-intent`)
   - StayLiquid (from `../../node_modules/stay-liquid`)
 
+SPEC REPOS:
+  trunk:
+    - KeychainSwift
+
 EXTERNAL SOURCES:
+  AparajitaCapacitorSecureStorage:
+    :path: "../../node_modules/@aparajita/capacitor-secure-storage"
   Capacitor:
     :path: "../../node_modules/@capacitor/ios"
   CapacitorActionSheet:
@@ -99,6 +110,7 @@ EXTERNAL SOURCES:
     :path: "../../node_modules/stay-liquid"
 
 SPEC CHECKSUMS:
+  AparajitaCapacitorSecureStorage: 502bff73187cf9d0164459458ccf47ec65d5895a
   Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
   CapacitorActionSheet: 4213427449132ae4135674d93010cb011725647e
   CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
@@ -116,9 +128,10 @@ SPEC CHECKSUMS:
   CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
   CapgoCapacitorNavigationBar: 067b1c1d1ede5ce96200a730ce7fd498e9641509
   JcesarmobileSslSkip: 5fa98636a64c36faa50f32ab4daf34e38f4d45b9
+  KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d
   SendIntent: 8a6f646a4489f788d253ffbd1082a98ea388d870
   StayLiquid: dac4b6cd7761472754f97d367ba4651ca79fcd2e
 
-PODFILE CHECKSUM: 858411d5b4560fd80593ea76b0fd6359bccabec3
+PODFILE CHECKSUM: 3223217c556441dd921e66e1c855a0e8ee98159c
 
 COCOAPODS: 1.16.2

+ 1 - 0
package.json

@@ -108,6 +108,7 @@
         "postinstall": "yarn tldraw:build && yarn ui:build"
     },
     "dependencies": {
+        "@aparajita/capacitor-secure-storage": "^7.1.6",
         "@capacitor-community/safe-area": "7.0.0-alpha.1",
         "@capacitor/action-sheet": "7.0.1",
         "@capacitor/android": "7.2.0",

+ 6 - 6
src/electron/electron/handler.cljs

@@ -618,14 +618,14 @@
 (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/save-e2ee-password [_window [_ key encrypted-text]]
+  (keychain/<set-password! key encrypted-text))
 
-(defmethod handle :keychain/get-e2ee-password [_window [_ refresh-token]]
-  (keychain/<get-password refresh-token))
+(defmethod handle :keychain/get-e2ee-password [_window [_ key]]
+  (keychain/<get-password key))
 
-(defmethod handle :keychain/delete-e2ee-password [_window [_ refresh-token]]
-  (keychain/<delete-password! refresh-token))
+(defmethod handle :keychain/delete-e2ee-password [_window [_ key]]
+  (keychain/<delete-password! key))
 
 (defmethod handle :default [args]
   (logger/error "Error: no ipc handler for:" args))

+ 7 - 20
src/electron/electron/keychain.cljs

@@ -1,7 +1,6 @@
 (ns electron.keychain
   "Helper functions for storing E2EE secrets inside the OS keychain."
-  (:require ["crypto" :as crypto]
-            ["electron" :refer [app]]
+  (:require ["electron" :refer [app]]
             ["keytar" :as keytar]
             [clojure.string :as string]
             [electron.logger :as logger]
@@ -19,26 +18,14 @@
   []
   (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))]
+  [key encrypted-text]
+  (if-let [account (and (supported?) key)]
     (-> (p/let [_ (.setPassword keytar (keychain-service) account encrypted-text)]
           true)
         (p/catch (fn [e]
@@ -48,8 +35,8 @@
 
 (defn <get-password
   "Fetch encrypted text stored for `refresh-token`."
-  [refresh-token]
-  (if-let [account (and (supported?) (normalize-account refresh-token))]
+  [key]
+  (if-let [account (and (supported?) key)]
     (-> (p/let [password (.getPassword keytar (keychain-service) account)]
           password)
         (p/catch (fn [e]
@@ -58,8 +45,8 @@
     (p/resolved nil)))
 
 (defn <delete-password!
-  [refresh-token]
-  (if-let [account (and (supported?) (normalize-account refresh-token))]
+  [key]
+  (if-let [account (and (supported?) key)]
     (-> (p/let [_ (.deletePassword keytar (keychain-service) account)]
           true)
         (p/catch (fn [e]

+ 37 - 27
src/main/frontend/handler/e2ee.cljs

@@ -3,6 +3,7 @@
   (:require [electron.ipc :as ipc]
             [frontend.common.crypt :as crypt]
             [frontend.common.thread-api :refer [def-thread-api]]
+            [frontend.mobile.secure-storage :as secure-storage]
             [frontend.state :as state]
             [frontend.util :as util]
             [lambdaisland.glogi :as log]
@@ -13,30 +14,39 @@
 (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))))
+  [key encrypted-text]
+  (cond
+    (util/electron?)
+    (ipc/ipc save-op key encrypted-text)
+
+    (util/capacitor?)
+    (secure-storage/<set-item! key encrypted-text)
+
+    :else
     (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))))
+  [key]
+  (cond
+    (util/electron?)
+    (ipc/ipc get-op key)
+
+    (util/capacitor?)
+    (secure-storage/<get-item key)
+
+    :else
     (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))))
+  [key]
+  (cond
+    (util/electron?)
+    (ipc/ipc delete-op key)
+
+    (util/capacitor?)
+    (secure-storage/<remove-item! key)
+
+    :else
     (p/resolved nil)))
 
 (def-thread-api :thread-api/request-e2ee-password
@@ -59,14 +69,14 @@
   [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/native-save-e2ee-password
+  [encrypted-text]
+  (<keychain-save! "logseq-encrypted-password" encrypted-text))
 
-(def-thread-api :thread-api/electron-get-e2ee-password
-  [refresh-token]
-  (<keychain-get refresh-token))
+(def-thread-api :thread-api/native-get-e2ee-password
+  []
+  (<keychain-get "logseq-encrypted-password"))
 
-(def-thread-api :thread-api/electron-delete-e2ee-password
-  [refresh-token]
-  (<keychain-delete! refresh-token))
+(def-thread-api :thread-api/native-delete-e2ee-password
+  []
+  (<keychain-delete! "logseq-encrypted-password"))

+ 57 - 0
src/main/frontend/mobile/secure_storage.cljs

@@ -0,0 +1,57 @@
+(ns frontend.mobile.secure-storage
+  "Wrapper around the Capacitor secure storage plugin."
+  (:require ["@aparajita/capacitor-secure-storage" :refer [SecureStorage]]
+            [frontend.mobile.util :as mobile-util]
+            [lambdaisland.glogi :as log]
+            [promesa.core :as p]))
+
+(defonce ^:private *initialized? (atom false))
+(def ^:private key-prefix "logseq.e2ee.")
+
+(defn- <ensure-initialized!
+  []
+  (cond
+    (not (mobile-util/native-platform?))
+    (p/resolved false)
+
+    @*initialized?
+    (p/resolved true)
+
+    :else
+    (-> (p/let [_ (.setKeyPrefix SecureStorage key-prefix)]
+          (reset! *initialized? true))
+        (p/catch (fn [e]
+                   (log/error ::init {:error e})
+                   (throw e)))))) ;; propagate so callers can fallback if needed
+
+(defn <set-item!
+  [key value]
+  (if (mobile-util/native-platform?)
+    (-> (p/let [_ (<ensure-initialized!)
+                _ (.setItem SecureStorage key value)]
+          true)
+        (p/catch (fn [e]
+                   (log/error ::set-item {:error e})
+                   (throw e))))
+    (p/resolved false)))
+
+(defn <get-item
+  [key]
+  (if (mobile-util/native-platform?)
+    (-> (p/let [_ (<ensure-initialized!)]
+          (.getItem SecureStorage key))
+        (p/catch (fn [e]
+                   (log/error ::get-item {:error e})
+                   (throw e))))
+    (p/resolved nil)))
+
+(defn <remove-item!
+  [key]
+  (if (mobile-util/native-platform?)
+    (-> (p/let [_ (<ensure-initialized!)
+                _ (.removeItem SecureStorage key)]
+          true)
+        (p/catch (fn [e]
+                   (log/error ::remove-item {:error e})
+                   (throw e))))
+    (p/resolved false)))

+ 10 - 2
src/main/frontend/persist_db/browser.cljs

@@ -119,7 +119,11 @@
     (p/do!
      (reload-app-if-old-db-worker-exists)
      (let [worker-url (if config/publishing? "static/js/db-worker.js" "js/db-worker.js")
-           worker (js/Worker. (str worker-url "?electron=" (util/electron?) "&publishing=" config/publishing?))
+           worker (js/Worker.
+                   (str worker-url
+                        "?electron=" (util/electron?)
+                        "&capacitor=" (util/capacitor?)
+                        "&publishing=" config/publishing?))
            _ (set-worker-fs worker)
            wrapped-worker* (Comlink/wrap worker)
            wrapped-worker (fn [qkw direct-pass? & args]
@@ -166,7 +170,11 @@
   []
   (when-not util/node-test?
     (let [worker-url "js/inference-worker.js"
-          ^js worker (js/SharedWorker. (str worker-url "?electron=" (util/electron?) "&publishing=" config/publishing?))
+          ^js worker (js/SharedWorker.
+                      (str worker-url
+                           "?electron=" (util/electron?)
+                           "&capacitor=" (util/capacitor?)
+                           "&publishing=" config/publishing?))
           ^js port (.-port worker)
           wrapped-worker (Comlink/wrap port)
           t1 (util/time-ms)]

+ 17 - 16
src/main/frontend/worker/rtc/crypt.cljs

@@ -19,40 +19,41 @@
 
 (defonce ^:private store (delay (idb-keyval/newStore "localforage" "keyvaluepairs" 2)))
 (defonce ^:private e2ee-password-file "e2ee-password")
-(defonce ^:private electron-env?
+(defonce ^:private native-env?
   (let [href (try (.. js/self -location -href)
                   (catch :default _ nil))]
     (boolean (and (string? href)
-                  (string/includes? href "electron=true")))))
+                  (or (string/includes? href "electron=true")
+                      (string/includes? href "capacitor=true"))))))
 
-(defn- electron-worker?
+(defn- native-worker?
   []
-  electron-env?)
+  native-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- <native-save-password-text!
+  [encrypted-text]
+  (worker-state/<invoke-main-thread :thread-api/native-save-e2ee-password encrypted-text))
 
-(defn- <electron-read-password-text
-  [refresh-token]
-  (worker-state/<invoke-main-thread :thread-api/electron-get-e2ee-password refresh-token))
+(defn- <native-read-password-text
+  []
+  (worker-state/<invoke-main-thread :thread-api/native-get-e2ee-password))
 
 (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)]
-    (if (electron-worker?)
-      (-> (p/let [_ (<electron-save-password-text! refresh-token text)]
+    (if (native-worker?)
+      (-> (p/let [_ (<native-save-password-text! text)]
             nil)
           (p/catch (fn [e]
-                     (log/error :electron-save-e2ee-password {:error e})
-                     (throw e))))
+                     (log/error :native-save-e2ee-password {:error e})
+                     (opfs/<write-text! e2ee-password-file text))))
       (opfs/<write-text! e2ee-password-file text))))
 
 (defn- <read-e2ee-password
   [refresh-token]
-  (p/let [text (if (electron-worker?)
-                 (<electron-read-password-text refresh-token)
+  (p/let [text (if (native-worker?)
+                 (<native-read-password-text)
                  (opfs/<read-text! e2ee-password-file))
           data (ldb/read-transit-str text)
           password (crypt/<decrypt-text-by-text-password refresh-token data)]

+ 25 - 0
yarn.lock

@@ -15,6 +15,16 @@
     "@jridgewell/gen-mapping" "^0.3.5"
     "@jridgewell/trace-mapping" "^0.3.24"
 
+"@aparajita/capacitor-secure-storage@^7.1.6":
+  version "7.1.6"
+  resolved "https://registry.yarnpkg.com/@aparajita/capacitor-secure-storage/-/capacitor-secure-storage-7.1.6.tgz#43e669e33dc6d680797d32ac85d2d116cc2b7397"
+  integrity sha512-KIfLOm/oSciUiWsOMtMv2nzlnwW3LERid0DvZ3djt7UORBQvFqpvSefiLG5kkc8D34pGkb2ZqAz0ZprrI9LiTQ==
+  dependencies:
+    "@capacitor/android" "^7.4.4"
+    "@capacitor/app" "^7.1.0"
+    "@capacitor/core" "^7.4.4"
+    "@capacitor/ios" "^7.4.4"
+
 "@axe-core/playwright@=4.4.4":
   version "4.4.4"
   resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.4.4.tgz#3786c5f6bba38d1991b608584b00ae2744544573"
@@ -182,11 +192,21 @@
   resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-7.2.0.tgz#f30e313315ab92c500bd57d9f0c4716a46b42f40"
   integrity sha512-zdhEy3jZPG5Toe/pGzKtDgIiBGywjaoEuQWnGVjBYPlSAEUtAhpZ2At7V0SCb26yluAuzrAUV0Ue+LQeEtHwFQ==
 
+"@capacitor/android@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-7.4.4.tgz#a14a1e844bd5079982427e247fdd17555b5fbedd"
+  integrity sha512-y8knfV1JXNrd6XZZLZireGT+EBCN0lvOo+HZ/s7L8LkrPBu4nY5UZn0Wxz4yOezItEII9rqYJSHsS5fMJG9gdw==
+
 "@capacitor/[email protected]":
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/@capacitor/app/-/app-7.0.1.tgz#0d0709fb4dde5046c24853f2d6b77a7ea411f748"
   integrity sha512-ArlVZAAla4MwQoKh26x2AaTDOBh5Vhp1VhMKR3RwqZSsZnazKTFGNrPbr9Ez5r1knnEDfApyjwp1uZnXK1WTYQ==
 
+"@capacitor/app@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@capacitor/app/-/app-7.1.0.tgz#d275eebbb0fec6104b3f9c41f09f7598d7808b47"
+  integrity sha512-W7m09IWrUjZbo7AKeq+rc/KyucxrJekTBg0l4QCm/yDtCejE3hebxp/W2esU26KKCzMc7H3ClkUw32E9lZkwRA==
+
 "@capacitor/assets@^3.0.5":
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/@capacitor/assets/-/assets-3.0.5.tgz#55c66458e703ce95e2e3188caad8b987b94b66f0"
@@ -318,6 +338,11 @@
   resolved "https://registry.yarnpkg.com/@capacitor/ios/-/ios-7.2.0.tgz#c3d9435582e5267b57085229e5678d1d53b15b5a"
   integrity sha512-MQgRZcXZpbpjN83bjkGrzQd7s3XeHBZplmWf38/msF/siMGJKLrXNmNzmmPIWA5Xpi/aH6UoJFk1wXuU2U+zMg==
 
+"@capacitor/ios@^7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@capacitor/ios/-/ios-7.4.4.tgz#051c37df4ffa878bd757b3849ce61668ac174083"
+  integrity sha512-Xp3bGWlSQAwsZGngRMWTdoD2agdMV12Whnm+/xsYPxfQSj+Tksbr7r/8Mso7VWkpnTKO4iMlx762g3PjW+wi4w==
+
 "@capacitor/[email protected]":
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/@capacitor/keyboard/-/keyboard-7.0.1.tgz#edf212528cd1587494ceb1d260868c1a9540aed1"