chat / client / src / sueta / app / trust.cljs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
;; ported-from: src/app/trust.ts
;;
;; Local trust store: trust-on-first-use bindings of display name → identity
;; key, plus explicitly verified identities. Lives only in this browser.
;;
;; - TOFU: the first identity key seen for a display name is remembered; if the
;;   same name later shows up with a DIFFERENT key, the UI warns loudly.
;; - Verified: after comparing fingerprints out-of-band, you mark an identity
;;   verified; the checkmark follows the KEY (not the name) from then on.
;;
;; TWO KINDS OF MAP, and the difference is the security property. The state has
;; exactly two fields, {names, verified}, so it is a declared shape (see below)
;; and a Clojure map in this file. What each field HOLDS stays a JS record,
;; because its keys are attacker-chosen: a display name of "__proto__" or
;; "constructor" must hit an own property or nothing, never the prototype
;; chain. `record` and the four accessors under it are that guarantee, in one
;; place — Object.create(nil) at construction and own-property reads after.
;; Modelling those as Clojure maps would also lose their insertion order the
;; moment a user knew nine names, and test/vectors/trust.json pins the stored
;; bytes.
;;
;; A TrustVerdict is {verified, keyChanged} — a JS object, because app/ui reads
;; it and MUTATES `verified`/`keyChanged` in place when you tick the box.
(ns sueta.app.trust
  (:require [sueta.config :as config]
            [sueta.wire :as w]
            [ardegazu.rooms.js :as j]))

(def ^:private KEY (config/ns-key "trust"))

;; ---- the shapes ------------------------------------------------------------

(def ^:private TRUST-STATE
  "The stored JSON: {names, verified}. trust.json pins these bytes."
  [[:names "names"] [:verified "verified"]])

(def ^:private VERDICT
  "What observe hands app/ui: {verified, keyChanged}."
  [[:verified "verified"] [:key-changed "keyChanged"]])

;; ---- attacker-keyed records ------------------------------------------------
;;
;; A string→string map whose KEYS come from the network. Null-prototype, so a
;; lookup can only ever find an own property; nothing here walks a chain.

(defn- record
  "A null-prototype record seeded from a parsed JSON object (nil for none).
   Non-string values are dropped; insertion order is the source's."
  [o]
  (let [out (js/Object.create nil)]
    ;; `typeof null` is "object", so the some? gate is what excludes it
    (when (and (some? o) (j/js-object? o))
      (doseq [pair (array-seq (js/Object.entries o))]
        (when (w/str? (aget pair 1))
          (unchecked-set out (aget pair 0) (aget pair 1)))))
    out))

(defn- rget [rec k] (unchecked-get rec k))
(defn- rput! [rec k v] (unchecked-set rec k v))
(defn- rdel! [rec k] (js-delete rec k))
(defn- ^boolean rhas? [rec k] (js/Object.hasOwn rec k))

;; ---- state -----------------------------------------------------------------

(defn- load-state
  "{:names <record> :verified <record>} — always both, whatever storage holds."
  []
  (let [parsed (try (js/JSON.parse (j/nn (js/localStorage.getItem KEY) "{}"))
                    (catch :default _ nil))]
    {:names (record (w/oget parsed "names"))
     :verified (record (w/oget parsed "verified"))}))

(defn- persist [state]
  (try (js/localStorage.setItem KEY (js/JSON.stringify (w/encode TRUST-STATE state)))
       (catch :default _ nil)))

;; ---- the store -------------------------------------------------------------

(defn- ^boolean bound?
  "TS's `!!known` on a name→key lookup: a record only ever holds strings, so an
   absent name and an empty binding are the same 'nothing was bound yet'."
  [known]
  (and (w/str? known) (pos? (.-length ^string known))))

(defn observe
  "Record a sighting of (name, key); returns the verdict to render."
  [name public-key-b64]
  (let [{:keys [names verified] :as state} (load-state)
        n (.toLowerCase (.trim ^string name))
        known (rget names n)
        key-changed (and (bound? known) (not (identical? known public-key-b64)))]
    (when-not (bound? known)
      (rput! names n public-key-b64) ; trust on first use
      (persist state))
    (w/encode VERDICT {:verified (rhas? verified public-key-b64)
                       :key-changed key-changed})))

(defn set-verified [public-key-b64 name on]
  (let [{:keys [names verified] :as state} (load-state)]
    ;; exported to JS, so `on` gets the JS test the original gave it
    (if (j/truthy? on)
      (do (rput! verified public-key-b64 name)
          ;; verifying also (re)binds the name to this key, clearing stale TOFU
          ;; warnings
          (rput! names (.toLowerCase (.trim ^string name)) public-key-b64))
      (rdel! verified public-key-b64))
    (persist state)
    js/undefined))

(defn ^boolean verified? [public-key-b64]
  (rhas? (:verified (load-state)) public-key-b64))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git