peer-kit / src / ardegazu / peer / core / room_crypto.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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
;; ported-from: src/core/room-crypto.ts @ v1.3.0
;;
;; Room membership crypto, slimmed from the sueta core for the libp2p stack.
;;
;; Post-noise, transport encryption and sender authenticity come from libp2p;
;; the only thing the room secret has to prove is membership. That happens once
;; per peer pair: a mutual AES-GCM "hello" sealed under a per-sender HKDF key,
;; with AAD binding the room and both noise-authenticated peer ids.
;; Undecryptable hello ⇒ not a room member ⇒ drop.
;;
;; Crypto is never reimplemented (dev/docs/CLJS.md): the identical WebCrypto
;; primitives are called through interop, Uint8Array at every boundary, and the
;; derivation infos ("roomid", "hello|<peerId>") plus the AAD string
;; ("v2|<roomId>|<from>|<to>|hello") are the deployed literals.
(ns ardegazu.peer.core.room-crypto
  (:require [shadow.cljs.modern :refer (defclass js-await)]))

(def ^:private td (js/TextDecoder.))

(defn utf8 [s]
  (.encode (js/TextEncoder.) s))

(defn random-bytes [n]
  (let [b (js/Uint8Array. n)]
    (js/crypto.getRandomValues b)
    b))

(defn to-b64 [bytes]
  ;; String.fromCharCode over the bytes then btoa — the deployed encoding
  (let [n (.-length ^js bytes)]
    (loop [i 0 s ""]
      (if (< i n)
        (recur (inc i) (str s (js/String.fromCharCode (aget bytes i))))
        (js/btoa s)))))

(defn from-b64 [s]
  (let [bin (js/atob s)
        n (.-length bin)
        b (js/Uint8Array. n)]
    (loop [i 0]
      (if (< i n)
        (do (aset b i (.charCodeAt bin i)) (recur (inc i)))
        b))))

(defn to-b64url [bytes]
  (-> (to-b64 bytes)
      (.replaceAll "+" "-")
      (.replaceAll "/" "_")
      (.replace #"=+$" "")))

(defn from-b64url [s]
  (from-b64 (-> s (.replaceAll "-" "+") (.replaceAll "_" "/"))))

(defn new-room-secret []
  (to-b64url (random-bytes 32)))

;; --- Sealer: owns (key, epoch, counter) so nonce reuse is structurally
;; --- impossible. Private to this namespace, exactly as in the TS original.

(defclass Sealer
  (constructor [this key]
    (unchecked-set this "_key" key)
    (unchecked-set this "_epoch" (random-bytes 4))
    ;; BigInt counter, as in the TS original (setBigUint64 demands one)
    (unchecked-set this "_counter" (js/BigInt 0))))

(defn- next-iv [self]
  (let [iv (js/Uint8Array. 12)
        c (unchecked-get self "_counter")]
    (.set iv (unchecked-get self "_epoch") 0)
    (.setBigUint64 (js/DataView. (.-buffer iv)) 4 c)
    (unchecked-set self "_counter" (+ c (js/BigInt 1)))
    iv))

(defn- sealer-seal [self obj aad]
  (let [iv (next-iv self)]
    (js-await [ct (js/crypto.subtle.encrypt
                   (js-obj "name" "AES-GCM" "iv" iv "additionalData" aad)
                   (unchecked-get self "_key")
                   (utf8 (js/JSON.stringify obj)))]
      ;; the wire envelope: {v, iv, ct} — three keys, order preserved
      (js-obj "v" 2 "iv" (to-b64 iv) "ct" (to-b64 (js/Uint8Array. ct))))))

(defn- open-env
  "Decrypt one envelope, or null. Authentication failure ⇒ not a member ⇒
   drop silently (never throw: a forged hello must not break the stream)."
  [key env aad]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (js-await [pt (js/crypto.subtle.decrypt
                        (js-obj "name" "AES-GCM"
                                "iv" (from-b64 (unchecked-get env "iv"))
                                "additionalData" aad)
                        key
                        (from-b64 (unchecked-get env "ct")))]
           (js/JSON.parse (.decode td pt)))))
      (.catch (fn [_] nil))))

;; --- RoomCrypto: all derivations for one room; peer ids are libp2p PeerId
;; --- base58 strings.

(defclass RoomCrypto
  (constructor [this ikm salt room-id]
    (unchecked-set this "_ikm" ikm)
    (unchecked-set this "_salt" salt)
    (unchecked-set this "roomId" room-id)
    (unchecked-set this "_keyCache" (js/Map.))
    (unchecked-set this "_mySealer" nil)
    (unchecked-set this "_myId" "")))

(defn create
  "Derive the room from its secret + the per-app salt. Rejects a short secret."
  [secret-b64url app-salt]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (let [secret (from-b64url secret-b64url)]
           (when (< (.-length secret) 16)
             (throw (js/Error. "room secret too short")))
           (let [salt (utf8 app-salt)]
             (js-await [ikm (js/crypto.subtle.importKey
                             "raw" secret "HKDF" false #js ["deriveBits" "deriveKey"])]
               (js-await [room-id-bits (js/crypto.subtle.deriveBits
                                        (js-obj "name" "HKDF" "hash" "SHA-256"
                                                "salt" salt "info" (utf8 "roomid"))
                                        ikm 256)]
                 (RoomCrypto. ikm salt (to-b64url (js/Uint8Array. room-id-bits)))))))))))

(unchecked-set RoomCrypto "create" (fn [secret-b64url app-salt] (create secret-b64url app-salt)))

(defn- key-for [self peer-id]
  (let [cache (unchecked-get self "_keyCache")]
    (or (.get cache peer-id)
        (let [p (js/crypto.subtle.deriveKey
                 (js-obj "name" "HKDF" "hash" "SHA-256"
                         "salt" (unchecked-get self "_salt")
                         "info" (utf8 (str "hello|" peer-id)))
                 (unchecked-get self "_ikm")
                 (js-obj "name" "AES-GCM" "length" 256)
                 false
                 #js ["encrypt" "decrypt"])]
          (.set cache peer-id p)
          p))))

(defn- aad-for [self from to]
  (utf8 (str "v2|" (unchecked-get self "roomId") "|" from "|" to "|hello")))

(defn- assign-extra
  "`{ ...extra, v: 2 }`: extra's own enumerable keys in their own order, then v."
  [extra]
  (let [o (js-obj)]
    (when (and (some? extra) (not (identical? extra js/undefined)))
      (js/Object.assign o extra))
    (unchecked-set o "v" 2)
    o))

(defn seal-hello
  "Seal my hello for `to`. `from` is my own (noise-authenticated) peer id.
   `extra` fields ride inside the sealed plaintext next to `v` — old clients
   validate only `v === 2` and ignore them, so the payload is versionless both
   ways (the identity announcement uses this: `{ id: {...} }`).

   The plaintext is built as `{ ...extra, v: 2 }` — extra's keys FIRST, `v`
   last, matching the TS spread order byte for byte."
  [self from to extra]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (if (and (unchecked-get self "_mySealer")
                  (identical? (unchecked-get self "_myId") from))
           (sealer-seal (unchecked-get self "_mySealer") (assign-extra extra) (aad-for self from to))
           (js-await [k (key-for self from)]
             (do (unchecked-set self "_mySealer" (Sealer. k))
                 (unchecked-set self "_myId" from)
                 (sealer-seal (unchecked-get self "_mySealer")
                              (assign-extra extra)
                              (aad-for self from to)))))))))

(defn open-hello
  "The decrypted hello plaintext from `from` addressed to `to` (me), or null
   when it isn't a valid room hello. Extra fields (e.g. `id`) come back for the
   caller to interpret; membership proof is solely the successful open."
  [self from to env]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (if-not (and (some? env)
                      (not (identical? env js/undefined))
                      (identical? 2 (unchecked-get env "v"))
                      (string? (unchecked-get env "iv"))
                      (string? (unchecked-get env "ct")))
           nil
           (js-await [k (key-for self from)]
             (js-await [pt (open-env k env (aad-for self from to))]
               (if (or (nil? pt)
                       (not (identical? "object" (js* "typeof ~{}" pt)))
                       (not (identical? 2 (unchecked-get pt "v"))))
                 nil
                 pt))))))))

(let [proto (.-prototype RoomCrypto)]
  ;; string-keyed prototype methods: the public surface TS consumers use, and
  ;; rename-safe whatever the optimization level (dev/docs/CLJS.md)
  (unchecked-set proto "sealHello"
                 (fn [from to extra] (this-as self (seal-hello self from to extra))))
  (unchecked-set proto "openHello"
                 (fn [from to env] (this-as self (open-hello self from to env)))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/peer-kit.git