rooms-kit / src / ardegazu / rooms / lib / 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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
;; ported-from: src/lib/crypto.ts @ v1.0.0
;;
;; E2E crypto for the sueta p2p stack. WebCrypto only. See chat/docs/PROTOCOL.md
;; §2-3. Nothing is reimplemented: every primitive is the identical WebCrypto
;; call the TypeScript made, with Uint8Array at every boundary.
;;
;; Key structure: every session key is derived per-SENDER per-SESSION (peer ids
;; are fresh random per page load / per process), so no two parties ever encrypt
;; under the same key. IVs are owned by a Sealer (epoch ‖ counter); there is no
;; API that accepts a caller-supplied IV, making nonce reuse structurally
;; impossible.
;;
;; Class-API note: the public surface (`roomId`/`peerId`/`roomTopic` fields, the
;; `create` static, every method) is attached with string keys so :simple — and
;; any future :advanced — never renames what TS consumers call.
(ns ardegazu.rooms.lib.crypto
  (:require [ardegazu.rooms.js :as j]
            [clojure.string :as str]
            [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]
  (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)
        b (js/Uint8Array. (.-length bin))]
    (dotimes [i (.-length bin)]
      (aset b i (.charCodeAt bin i)))
    b))

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

(defn from-b64url [s]
  (from-b64 (-> s
                (str/replace "-" "+")
                (str/replace "_" "/"))))

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

(defn new-peer-id []
  (let [b (random-bytes 16)
        out (array)]
    (dotimes [i 16]
      (.push out (.padStart (.toString (aget b i) 16) 2 "0")))
    (.join out "")))

;; ---- Sealer ----------------------------------------------------------------
;;
;; Owns (key, epoch, counter). The only way to encrypt live traffic in this
;; library. The counter is a BigInt written big-endian into IV bytes 4..11 —
;; `js*` keeps the `1n` literal, since cljs.core arithmetic on BigInt is not
;; something to rely on.

(defclass Sealer
  (constructor [this key]
    (unchecked-set this "_key" key)
    (unchecked-set this "_epoch" (random-bytes 4))
    (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" (js* "~{} + 1n" c))
    iv))

(defn seal
  "Seal a JSON-serializable value under `aad`; resolves to an Envelope
   {v:1, iv:<b64>, ct:<b64>}. The envelope's three keys are set in order — it is
   JSON.stringified onto the wire by lib/net (test/vectors/protocol.json)."
  [self obj aad]
  (let [iv (next-iv self)]
    (js-await [ct (js/crypto.subtle.encrypt
                   (j/ordered "name" "AES-GCM" "iv" iv "additionalData" aad)
                   (unchecked-get self "_key")
                   (utf8 (js/JSON.stringify obj)))]
      (j/ordered "v" 1 "iv" (to-b64 iv) "ct" (to-b64 (js/Uint8Array. ct))))))

(defn seal-binary
  "Binary envelope: [0x01 | 12-byte IV | ciphertext+tag] as one ArrayBuffer."
  [self data aad]
  (let [iv (next-iv self)]
    (js-await [ct-buf (js/crypto.subtle.encrypt
                       (j/ordered "name" "AES-GCM" "iv" iv "additionalData" aad)
                       (unchecked-get self "_key")
                       data)]
      (let [ct (js/Uint8Array. ct-buf)
            out (js/Uint8Array. (+ 1 12 (.-length ct)))]
        (aset out 0 0x01)
        (.set out iv 1)
        (.set out ct 13)
        (.-buffer out)))))

(let [proto (.-prototype Sealer)]
  (unchecked-set proto "seal" (fn [obj aad] (this-as self (seal self obj aad))))
  (unchecked-set proto "sealBinary" (fn [data aad] (this-as self (seal-binary self data aad)))))

;; ---- AtRestCipher ----------------------------------------------------------
;;
;; At-rest AEAD for the replicated log (v2). Unlike Sealer, the key is ROOM-WIDE
;; (any member must decrypt entries whose author's session is long gone), so
;; epoch‖counter IVs are unsafe — two sessions could collide. Random 96-bit IVs
;; are safe by the birthday bound for far more entries than any room will ever
;; hold (NIST cap 2^32; a 10M-entry room is ~2^-51 risk). Still no
;; caller-supplied IVs. Envelope: [0x02 | 12-byte IV | ct‖tag].

(defclass AtRestCipher
  (constructor [this key aad]
    (unchecked-set this "_key" key)
    (unchecked-set this "_aad" aad)))

(defn at-rest-seal [self data]
  (let [iv (random-bytes 12)]
    (js-await [ct-buf (js/crypto.subtle.encrypt
                       (j/ordered "name" "AES-GCM" "iv" iv
                                  "additionalData" (unchecked-get self "_aad"))
                       (unchecked-get self "_key")
                       data)]
      (let [ct (js/Uint8Array. ct-buf)
            out (js/Uint8Array. (+ 1 12 (.-length ct)))]
        (aset out 0 0x02)
        (.set out iv 1)
        (.set out ct 13)
        out))))

(defn at-rest-open
  "null on any failure ⇒ caller drops (PROTOCOL.md §3 discipline)."
  [self data]
  (if (or (< (.-length ^js data) (+ 1 12 16))
          (not (identical? 0x02 (aget data 0))))
    (js/Promise.resolve nil)
    (-> (js/crypto.subtle.decrypt
         (j/ordered "name" "AES-GCM" "iv" (.subarray data 1 13)
                    "additionalData" (unchecked-get self "_aad"))
         (unchecked-get self "_key")
         (.subarray data 13))
        (.then (fn [pt] (js/Uint8Array. pt)))
        (.catch (fn [_] nil)))))

(let [proto (.-prototype AtRestCipher)]
  (unchecked-set proto "seal" (fn [data] (this-as self (at-rest-seal self data))))
  (unchecked-set proto "open" (fn [data] (this-as self (at-rest-open self data)))))

;; ---- opening ---------------------------------------------------------------

(defn- open-envelope
  "Decrypt a JSON envelope; nil on authentication failure ⇒ drop silently (§3)."
  [key env aad]
  (-> (js/Promise.resolve nil)
      (.then (fn [_]
               (js-await [pt (js/crypto.subtle.decrypt
                              (j/ordered "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))))

(defn- open-binary [key buf aad]
  (let [b (js/Uint8Array. buf)]
    (if (or (< (.-length b) (+ 1 12 16))
            (not (identical? 0x01 (aget b 0))))
      (js/Promise.resolve nil)
      (-> (js/crypto.subtle.decrypt
           (j/ordered "name" "AES-GCM" "iv" (.subarray b 1 13) "additionalData" aad)
           key
           (.subarray b 13))
          (.then (fn [pt] (js/Uint8Array. pt)))
          (.catch (fn [_] nil))))))

(defn- derive-key [ikm salt info]
  (js/crypto.subtle.deriveKey
   (j/ordered "name" "HKDF" "hash" "SHA-256" "salt" salt "info" (utf8 info))
   ikm
   (j/ordered "name" "AES-GCM" "length" 256)
   false
   #js ["encrypt" "decrypt"]))

(defn- derive-bits-b64url [ikm salt info]
  (js-await [bits (js/crypto.subtle.deriveBits
                   (j/ordered "name" "HKDF" "hash" "SHA-256" "salt" salt "info" (utf8 info))
                   ikm
                   256)]
    (to-b64url (js/Uint8Array. bits))))

;; ---- RoomCrypto ------------------------------------------------------------
;;
;; All derivations for one room. `appSalt` namespaces the application (e.g.
;; "chat.ardegazu.ro/v1") so identical secrets on different apps built on this
;; stack land in different rooms.

(defclass RoomCrypto
  (constructor [this ikm salt room-id room-topic peer-id sig msg]
    (unchecked-set this "_ikm" ikm)
    (unchecked-set this "_salt" salt)
    (unchecked-set this "roomId" room-id)
    ;; Room-scoped gossipsub topic for ephemeral traffic (presence, calls).
    ;; One-way from the secret, like roomId.
    (unchecked-set this "roomTopic" room-topic)
    (unchecked-set this "peerId" peer-id)
    (unchecked-set this "_sigSealer" sig)
    (unchecked-set this "_msgSealer" msg)
    (unchecked-set this "_keyCache" (js/Map.))))

(defn- create* [secret-b64url app-salt peer-id]
  (-> (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 (derive-bits-b64url ikm salt "roomid")]
                 (js-await [topic (derive-bits-b64url ikm salt "topic|room")]
                   (let [pid (if (identical? peer-id js/undefined) (new-peer-id)
                                 (if (nil? peer-id) (new-peer-id) peer-id))]
                     (js-await [k-sig (derive-key ikm salt (str "signal|" pid))]
                       (js-await [k-msg (derive-key ikm salt (str "msg|" pid))]
                         (RoomCrypto. ikm salt room-id (str "sueta/2/" topic) pid
                                      (Sealer. k-sig) (Sealer. k-msg))))))))))))))

(defn create
  "`peerId` is the session's network id. When omitted a fresh random hex id is
   generated (v1 behavior); the libp2p stack passes its own base58 PeerId so
   per-sender keys and AADs bind to the id peers actually see on the wire."
  ([secret-b64url app-salt] (create* secret-b64url app-salt nil))
  ([secret-b64url app-salt peer-id] (create* secret-b64url app-salt peer-id)))

(defn- key-for [self info]
  (let [cache ^js (unchecked-get self "_keyCache")
        p (.get cache info)]
    (if (identical? p js/undefined)
      ;; bounded: decrypt keys are derived per REMOTE sender id, and sender ids
      ;; are attacker-mintable — an unbounded cache would grow for the session
      ;; lifetime under a spam of fresh PeerIds. FIFO eviction; re-deriving an
      ;; evicted key is cheap (one HKDF).
      (do
        (when (>= (.-size cache) 512)
          (let [oldest (.-value (.next (.keys cache)))]
            (when-not (identical? oldest js/undefined)
              (.delete cache oldest))))
        (let [fresh (derive-key (unchecked-get self "_ikm") (unchecked-get self "_salt") info)]
          (.set cache info fresh)
          fresh))
      p)))

(defn- sig-aad [self from to]
  (utf8 (str "v1|" (unchecked-get self "roomId") "|" from "|" to "|sig")))

(defn- msg-aad [self from]
  (utf8 (str "v1|" (unchecked-get self "roomId") "|" from "|msg")))

(defn seal-signal
  "Encrypt a signaling payload for a specific recipient."
  [self to payload]
  (seal (unchecked-get self "_sigSealer") payload
        (sig-aad self (unchecked-get self "peerId") to)))

(defn open-signal
  "Decrypt a signaling payload from `from` addressed to me."
  [self from env]
  (js-await [k (key-for self (str "signal|" from))]
    (open-envelope k env (sig-aad self from (unchecked-get self "peerId")))))

(defn seal-msg
  "Encrypt a datachannel JSON payload (same ciphertext broadcasts to everyone)."
  [self payload]
  (seal (unchecked-get self "_msgSealer") payload
        (msg-aad self (unchecked-get self "peerId"))))

(defn open-msg [self from env]
  (js-await [k (key-for self (str "msg|" from))]
    (open-envelope k env (msg-aad self from))))

(defn seal-msg-binary [self data]
  (seal-binary (unchecked-get self "_msgSealer") data
               (msg-aad self (unchecked-get self "peerId"))))

(defn open-msg-binary [self from buf]
  (js-await [k (key-for self (str "msg|" from))]
    (open-binary k buf (msg-aad self from))))

;; ---- v2 at-rest derivations (replicated log; room-wide keys) ---------------

(defn db-name
  "Deterministic OrbitDB database name — same for every member, like roomId."
  [self]
  (derive-bits-b64url (unchecked-get self "_ikm") (unchecked-get self "_salt") "dbname"))

(defn- at-rest [self info aad-suffix]
  (js-await [k (key-for self info)]
    (AtRestCipher. k (utf8 (str "v2|" (unchecked-get self "roomId") "|" aad-suffix)))))

(defn log-entry-cipher
  "Encrypts whole log entries before they become content-addressed blocks."
  [self]
  (at-rest self "log-entry" "entry"))

(defn log-payload-cipher
  "Encrypts entry payloads inside the (already encrypted) entry."
  [self]
  (at-rest self "log-payload" "payload"))

(defn img-cipher
  "Encrypts image blobs stored as unixfs blocks."
  [self]
  (at-rest self "img" "img"))

;; ---- offline mailbox derivations (PROTOCOL.md §9) --------------------------

(defn mailbox-room-id
  "Server-visible mailbox room id. Its own derivation — never the roomId,
   roomTopic or dbName: the mailbox node must not be able to correlate its
   per-room store with pubsub topics or database addresses it also sees."
  [self]
  (derive-bits-b64url (unchecked-get self "_ikm") (unchecked-get self "_salt") "mailbox|room"))

(defn mbx-cipher
  "Seals identity records in mailbox transit (they are plaintext blocks, §7b)."
  [self]
  (at-rest self "mailbox-blob" "mbx"))

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

(let [proto (.-prototype RoomCrypto)]
  (unchecked-set proto "sealSignal" (fn [to payload] (this-as self (seal-signal self to payload))))
  (unchecked-set proto "openSignal" (fn [from env] (this-as self (open-signal self from env))))
  (unchecked-set proto "sealMsg" (fn [payload] (this-as self (seal-msg self payload))))
  (unchecked-set proto "openMsg" (fn [from env] (this-as self (open-msg self from env))))
  (unchecked-set proto "sealMsgBinary" (fn [data] (this-as self (seal-msg-binary self data))))
  (unchecked-set proto "openMsgBinary" (fn [from buf] (this-as self (open-msg-binary self from buf))))
  (unchecked-set proto "dbName" (fn [] (this-as self (db-name self))))
  (unchecked-set proto "logEntryCipher" (fn [] (this-as self (log-entry-cipher self))))
  (unchecked-set proto "logPayloadCipher" (fn [] (this-as self (log-payload-cipher self))))
  (unchecked-set proto "imgCipher" (fn [] (this-as self (img-cipher self))))
  (unchecked-set proto "mailboxRoomId" (fn [] (this-as self (mailbox-room-id self))))
  (unchecked-set proto "mbxCipher" (fn [] (this-as self (mbx-cipher self)))))

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