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