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 | ;; ported-from: src/identity.ts @ v1.1.0
;;
;; Persistent cryptographic identity (app-agnostic).
;;
;; An identity is a single 32-byte Ed25519 seed, encoded base64url (43 chars).
;; The keypair is derived from it on every boot via WebCrypto's PKCS8 import
;; trick (RFC 8410 fixed prefix + raw seed). Each session the identity signs a
;; binding assertion "this room + this session peerId is mine"; peers verify it
;; and render a fingerprint of the public key.
;;
;; The binding domain tag is the literal "sueta-id|v2" for historical wire
;; compatibility; app separation comes from `appSalt`, never from the tag.
;;
;; Class-API note: public members (`publicKeyB64`, `fingerprint`, `assert`,
;; `signRaw`, statics `fromSeed`/`newSeed`) are attached with string keys /
;; string-named property descriptors so :advanced never renames the surface
;; TS consumers use โ the same rationale the canon gives for string-named
;; getters, applied to the whole public surface.
;;
;; No `clojure.string` here (see the note in ardegazu.id.crypto): it retains
;; cljs.core's seq/collection runtime through :advanced DCE in every consumer
;; that compiles this kit from source.
(ns ardegazu.id.identity
(:require [ardegazu.id.crypto :as c]
[shadow.cljs.modern :refer (defclass js-await)]))
;; The two b64 -> b64url substitutions clojure.string/replace used to make;
;; global, exactly as it forced them to be.
(def ^:private re-plus (js/RegExp. "\\+" "g"))
(def ^:private re-slash (js/RegExp. "/" "g"))
;; PKCS8 wrapper for a raw Ed25519 seed (RFC 8410 structure, fixed prefix).
(def ^:private pkcs8-prefix
(js/Uint8Array.
#js [0x30 0x2e 0x02 0x01 0x00 0x30 0x05 0x06 0x03 0x2b 0x65 0x70 0x04 0x22 0x04 0x20]))
;; 64 visually distinct emoji -> 4 x 6 bits = first 24 bits of SHA-256(pub)
(def ^:private emoji64
#js ["๐ข" "๐ฆ" "๐ผ" "๐ฆ" "๐ธ" "๐" "๐ฆ" "๐" "๐ฌ" "๐ฆ" "๐" "๐ฆ" "๐ต" "๐ฒ" "๐" "๐"
"๐ป" "๐" "โญ" "๐ฅ" "๐" "โก" "โ๏ธ" "๐" "๐" "๐" "๐" "๐" "๐ฅ" "๐ฝ" "๐ฅ" "๐ฉ"
"๐" "๐ฅจ" "๐ง" "๐ฟ" "โ" "๐ต" "๐ง" "๐" "๐ฒ" "๐ฏ" "๐ธ" "๐บ" "๐ฅ" "๐" "๐ฒ" "โต"
"๐" "๐ฟ" "๐๏ธ" "๐" "๐งฒ" "๐งญ" "โณ" "๐" "๐๏ธ" "๐" "โ๏ธ" "๐" "๐" "๐ก๏ธ" "โ๏ธ" "๐"])
(defn- binding-bytes [app-salt room-id peer-id]
(c/utf8 (str "sueta-id|v2|" app-salt "|" room-id "|" peer-id)))
(defn fingerprint-of
"SHA-256 of the public key as {emoji, hex}. Returns a promise."
[pub-b64]
(-> (js/Promise.resolve nil)
(.then
(fn [_]
(js-await [digest (js/crypto.subtle.digest "SHA-256" (c/from-b64url pub-b64))]
(let [hash (js/Uint8Array. digest)
bits (bit-or (bit-shift-left (aget hash 0) 16)
(bit-shift-left (aget hash 1) 8)
(aget hash 2))
emoji (str (aget emoji64 (bit-and (unsigned-bit-shift-right bits 18) 63))
(aget emoji64 (bit-and (unsigned-bit-shift-right bits 12) 63))
(aget emoji64 (bit-and (unsigned-bit-shift-right bits 6) 63))
(aget emoji64 (bit-and bits 63)))
hex (loop [i 0 s ""]
(if (< i (.-length hash))
(recur (inc i) (str s (.padStart (.toString (aget hash i) 16) 2 "0")))
s))]
(js-obj "emoji" emoji "hex" hex)))))))
;; An Identity instance holds the non-extractable private CryptoKey ("_priv"),
;; the raw public key b64url ("publicKeyB64") and the fingerprint ("_fp").
(defclass Identity
(constructor [this priv pub-b64 fp]
(unchecked-set this "_priv" priv)
(unchecked-set this "publicKeyB64" pub-b64)
(unchecked-set this "_fp" fp)))
;; string-named getter (rename-safe under :advanced); the backing slot is
;; "_fp" so no own property ever shadows it
(js/Object.defineProperty
(.-prototype Identity) "fingerprint"
#js {:get (fn [] (this-as self (unchecked-get self "_fp")))
:configurable true})
(defn from-seed-impl
"Load from a seed string (43-char base64url). Rejects on malformed seeds."
[seed-b64url]
(-> (js/Promise.resolve nil)
(.then
(fn [_]
(let [seed (c/from-b64url seed-b64url)]
(when-not (identical? 32 (.-length seed))
(throw (js/Error. "identity seed must be 32 bytes")))
(let [pkcs8 (js/Uint8Array. (+ (.-length pkcs8-prefix) 32))]
(.set pkcs8 pkcs8-prefix)
(.set pkcs8 seed (.-length pkcs8-prefix))
;; extractable=true only to let the UA compute the public half
(js-await [tmp (js/crypto.subtle.importKey "pkcs8" pkcs8 "Ed25519" true #js ["sign"])]
(js-await [jwk (js/crypto.subtle.exportKey "jwk" tmp)]
(let [pub-b64 (unchecked-get jwk "x")]
(when-not pub-b64
(throw (js/Error. "could not derive public key")))
(js-await [priv (js/crypto.subtle.importKey "pkcs8" pkcs8 "Ed25519" false #js ["sign"])]
(js-await [fp (fingerprint-of pub-b64)]
(Identity. priv pub-b64 fp))))))))))))
(defn new-seed []
(c/to-b64url (c/random-bytes 32)))
(defn sign-raw-impl
"Raw Ed25519 signature over arbitrary bytes. Returns a promise of Uint8Array."
[identity data]
(js-await [sig (js/crypto.subtle.sign "Ed25519" (unchecked-get identity "_priv") data)]
(js/Uint8Array. sig)))
(defn- assert-impl [identity app-salt room-id peer-id]
(js-await [sig (js/crypto.subtle.sign "Ed25519" (unchecked-get identity "_priv")
(binding-bytes app-salt room-id peer-id))]
(c/to-b64 (js/Uint8Array. sig))))
(unchecked-set Identity "fromSeed" (fn [seed-b64url] (from-seed-impl seed-b64url)))
(unchecked-set Identity "newSeed" (fn [] (new-seed)))
(let [proto (.-prototype Identity)]
;; Sign the session binding: proves this room-session peerId belongs to us.
(unchecked-set proto "assert"
(fn [app-salt room-id peer-id]
(this-as self (assert-impl self app-salt room-id peer-id))))
;; Raw Ed25519 signature over arbitrary bytes (OrbitDB identity provider).
(unchecked-set proto "signRaw"
(fn [data]
(this-as self (sign-raw-impl self data)))))
(defn verify-raw
"Verify a raw Ed25519 signature against a base64url public key."
[public-key-b64 sig-b64url data]
(-> (js/Promise.resolve nil)
(.then
(fn [_]
(js-await [pub (js/crypto.subtle.importKey "raw" (c/from-b64url public-key-b64)
"Ed25519" false #js ["verify"])]
(js/crypto.subtle.verify "Ed25519" pub (c/from-b64url sig-b64url) data))))
(.catch (fn [_] false))))
(defn verify-assertion
"Verify a peer's binding assertion; resolves to the fingerprint or null."
[app-salt room-id peer-id public-key-b64 sig-b64]
(-> (js/Promise.resolve nil)
(.then
(fn [_]
(js-await [pub (js/crypto.subtle.importKey "raw" (c/from-b64url public-key-b64)
"Ed25519" false #js ["verify"])]
(js-await [ok (js/crypto.subtle.verify
"Ed25519" pub
(c/from-b64url (-> sig-b64
(.replace re-plus "-")
(.replace re-slash "_")))
(binding-bytes app-salt room-id peer-id))]
(if ok (fingerprint-of public-key-b64) nil)))))
(.catch (fn [_] nil))))
(defn is-ed25519-supported []
(-> (js/crypto.subtle.generateKey "Ed25519" false #js ["sign" "verify"])
(.then (fn [_] true))
(.catch (fn [_] false))))
|