nandi/frqpublic Fork 0
d8fadee
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Sign on the phone: msgsig shared, crypto seamed

`frq.msgsig` is shared whole — the canonical description that gets signed, the
key id, the event ids, the tag shapes. What could not come with it is four
primitives, and those are `frq.crypto`: random bytes, SHA-256, an Ed25519
keygen from a seed, and a signature.

The desktop answers them with the same OpenSSL it already loads for TLS,
moved to `frq.crypto.openssl` so the EVP_PKEY has somewhere to live that is
not a shared namespace. The phone answers them with
`package:ed25519_edwards` and `package:crypto` — pure Dart, and synchronous,
which is the requirement: a signature is minted in the middle of sending a
reaction and there is nothing there to await on. pointycastle was the obvious
choice and carries Ed25519's object identifier with nothing behind it.

Both were checked against RFC 8032 test 1 rather than taken on trust. Seed
9d61b19d…, public key d75a9801…f707511a, signature over "abc"
80d724b0…053dc207 — the same on the desktop, the same on the device, and the
same as `openssl pkeyutl` gives for that key. A signature minted on either
verifies the same way at the server, which is the only property that matters
here.

One shape difference the move exposed: `.getBytes` is Java, so the canonical
bytes go through `frq.io/utf8-bytes` now — and they must, because a signature
made over different bytes is a signature over nothing. The seam takes vectors
and seqs, since those are the shapes both compilers agree on, and each
implementation converts at its own edge.

To be clear about what this does and does not buy: msgsig signs mutations, so
reactions and edits stick once signed in. Signing in is the CAP and SASL
handshake in frq.irc's protocol half, which has not moved yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T23:57:04-07:00 Browse files
d8fadee parent: 8d531f8
added common/frq/crypto.cljc +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+(ns frq.crypto
2+ "The four primitives `frq.msgsig` needs, named once.
3+
4+ Ed25519 and SHA-256 are the only things in this client that a platform
5+ cannot hand over as data: on the desktop they are OpenSSL's, reached through
6+ the same libcrypto jolt already loads for TLS, and on a phone there is no
7+ libcrypto to reach Android ships none a process may link so the Dart
8+ side brings its own.
9+
10+ Deliberately narrow. Everything else about a signature what gets signed,
11+ how it is written down, which key id names it is the same everywhere and
12+ lives in `frq.msgsig`. This is the part that is arithmetic somebody else
13+ should be doing.
14+
15+ A platform that installs nothing signs nothing, and `generate!` answers nil.
16+ That is a real state and the client already knows it: a reaction from an
17+ unsigned connection is one only this client can see, which is what the
18+ namespace's own docstring says about libcrypto refusing to play."
19+ (:refer-clojure :exclude [rand]))
20+
21+(defonce ^:private impl (atom {}))
22+
23+(defn install! [m] (swap! impl merge m) nil)
24+
25+(defn- call [k args]
26+ (when-let [f (get @impl k)] (apply f args)))
27+
28+(defn random-bytes
29+ "`n` bytes from the platform's own source of them, or nil."
30+ [n]
31+ (call :random-bytes [n]))
32+
33+(defn sha256
34+ "The digest of a byte sequence, as bytes."
35+ [bs]
36+ (call :sha256 [bs]))
37+
38+(defn ed25519-generate!
39+ "Mint a key from a 32-byte seed and keep it. Returns the public half as
40+ bytes, or nil where there is no Ed25519 to be had.
41+
42+ The seed is passed in rather than made here: for Ed25519 the seed *is* the
43+ key, and which bytes those are is `frq.msgsig`'s business."
44+ [seed]
45+ (call :ed25519-generate! [seed]))
46+
47+(defn ed25519-sign
48+ "Sign bytes with the kept key. Returns the signature as bytes, or nil."
49+ [bs]
50+ (call :ed25519-sign [bs]))
51+
52+(defn ed25519-forget!
53+ "Drop the key. Called when the connection goes, so a reconnect signs with a
54+ key the server has actually been told about."
55+ []
56+ (call :ed25519-forget! []))
new file mode 100644
@@ -0,0 +1,56 @@
1+(ns frq.crypto
2+ "The four primitives `frq.msgsig` needs, named once.
3+
4+ Ed25519 and SHA-256 are the only things in this client that a platform
5+ cannot hand over as data: on the desktop they are OpenSSL's, reached through
6+ the same libcrypto jolt already loads for TLS, and on a phone there is no
7+ libcrypto to reach Android ships none a process may link so the Dart
8+ side brings its own.
9+
10+ Deliberately narrow. Everything else about a signature what gets signed,
11+ how it is written down, which key id names it is the same everywhere and
12+ lives in `frq.msgsig`. This is the part that is arithmetic somebody else
13+ should be doing.
14+
15+ A platform that installs nothing signs nothing, and `generate!` answers nil.
16+ That is a real state and the client already knows it: a reaction from an
17+ unsigned connection is one only this client can see, which is what the
18+ namespace's own docstring says about libcrypto refusing to play."
19+ (:refer-clojure :exclude [rand]))
20+
21+(defonce ^:private impl (atom {}))
22+
23+(defn install! [m] (swap! impl merge m) nil)
24+
25+(defn- call [k args]
26+ (when-let [f (get @impl k)] (apply f args)))
27+
28+(defn random-bytes
29+ "`n` bytes from the platform's own source of them, or nil."
30+ [n]
31+ (call :random-bytes [n]))
32+
33+(defn sha256
34+ "The digest of a byte sequence, as bytes."
35+ [bs]
36+ (call :sha256 [bs]))
37+
38+(defn ed25519-generate!
39+ "Mint a key from a 32-byte seed and keep it. Returns the public half as
40+ bytes, or nil where there is no Ed25519 to be had.
41+
42+ The seed is passed in rather than made here: for Ed25519 the seed *is* the
43+ key, and which bytes those are is `frq.msgsig`'s business."
44+ [seed]
45+ (call :ed25519-generate! [seed]))
46+
47+(defn ed25519-sign
48+ "Sign bytes with the kept key. Returns the signature as bytes, or nil."
49+ [bs]
50+ (call :ed25519-sign [bs]))
51+
52+(defn ed25519-forget!
53+ "Drop the key. Called when the connection goes, so a reconnect signs with a
54+ key the server has actually been told about."
55+ []
56+ (call :ed25519-forget! []))
renamed common/frq/msgsig.cljc +44 -98
similarity index 60%
rename from src/frq/msgsig.clj
rename to common/frq/msgsig.cljc
@@ -14,34 +14,14 @@
1414 written down it says only \"the account on this session did this\", which
1515 is all the server is asking.
1616
17- The primitives are OpenSSL's, reached through the same libcrypto jolt already
18- loads for TLS. There is no other crypto here to borrow, and an Ed25519
19- written by hand is not a thing to put in a chat client."
17+ Shared, because all of that is the same everywhere. What is not is the four
18+ primitives underneath Ed25519, SHA-256 and a source of random bytes and
19+ those are `frq.crypto`, which the desktop answers with OpenSSL and the phone
20+ with its own. A platform that answers none of them signs nothing, and this
21+ namespace already knew how to be in that state."
2022 (:require [clojure.string :as str]
21- [jolt.ffi :as ffi]
22- [jolt.host :as host]
23- [jolt.mvn-http :as tls]))
24-
25-;; ---------------------------------------------------------------- libcrypto
26-
27-(ffi/defcfn c-rand-bytes "RAND_bytes" [:pointer :int] :int)
28-(ffi/defcfn c-new-raw-priv "EVP_PKEY_new_raw_private_key"
29- [:int :pointer :pointer :size_t] :pointer)
30-(ffi/defcfn c-get-raw-pub "EVP_PKEY_get_raw_public_key"
31- [:pointer :pointer :pointer] :int)
32-(ffi/defcfn c-pkey-free "EVP_PKEY_free" [:pointer] :void)
33-(ffi/defcfn c-md-ctx-new "EVP_MD_CTX_new" [] :pointer)
34-(ffi/defcfn c-md-ctx-free "EVP_MD_CTX_free" [:pointer] :void)
35-(ffi/defcfn c-sign-init "EVP_DigestSignInit"
36- [:pointer :pointer :pointer :pointer :pointer] :int)
37-(ffi/defcfn c-sign "EVP_DigestSign" [:pointer :pointer :pointer :pointer :size_t] :int)
38-(ffi/defcfn c-sha256 "SHA256" [:pointer :size_t :pointer] :pointer)
39-
40-;; EVP_PKEY_ED25519. The one NID this namespace needs, and the one number in
41-;; OpenSSL's table that would be a silent wrong key if it were wrong.
42-(def ^:private nid-ed25519 1087)
43-
44-;; ---------------------------------------------------------------- encoding
23+ [frq.crypto :as crypto]
24+ [frq.io :as io]))
4525
4626 (def ^:private b64url-alphabet
4727 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
@@ -65,35 +45,26 @@
6545 (nth b64url-alphabet
6646 (bit-and (bit-shift-right v (* 6 (- 3 i))) 0x3f))))))
6747
68-(defn- random-bytes
69- "`n` bytes from OpenSSL's CSPRNG, or nil if it will not give them."
70- [n]
71- (let [buf (ffi/alloc n)]
72- (try (when (= 1 (c-rand-bytes buf n)) (ffi/read-array buf n))
73- (finally (ffi/free buf)))))
74-
75-(defn- sha256 [bs]
76- (let [n (alength bs)
77- in (ffi/alloc (max 1 n))
78- out (ffi/alloc 32)]
79- (try (ffi/write-array in bs)
80- (c-sha256 in n out)
81- (ffi/read-array out 32)
82- (finally (ffi/free in) (ffi/free out)))))
48+
8349
8450 ;; ---------------------------------------------------------------- the key
8551
86-;; One key per connection: {:pkey <EVP_PKEY*> :public <b64url> :kid <b64url>
87-;; :did <did>}. Held here rather than on the connection because a signature is
88-;; not something the transport should be able to hand out.
52+
53+
54+
55+
56+
57+;; One key per connection: {:public <b64url> :kid <b64url> :did <did>}. The
58+;; private half is not here and never was `frq.crypto` keeps it, because a
59+;; signature is not something this namespace, or the transport under it,
60+;; should be able to hand out.
8961 (defonce ^:private signer (atom nil))
9062
9163 (defn forget!
9264 "Drop the session key. Called when the connection goes, so a reconnect signs
9365 with a key the server has actually been told about."
9466 []
95- (when-let [{:keys [pkey]} @signer]
96- (try (c-pkey-free pkey) (catch Exception _ nil)))
67+ (crypto/ed25519-forget!)
9768 (reset! signer nil))
9869
9970 (defn public-key
@@ -103,58 +74,31 @@
10374
10475 (defn generate!
10576 "Mint this connection's signing key for `did`, and return the public half as
106- base64url the argument `MSGSIG` takes. nil if libcrypto will not play, and
107- then nothing is signed and reactions stay a thing only this client sees.
77+ base64url the argument `MSGSIG` takes. nil where the platform has no
78+ Ed25519, and then nothing is signed and reactions stay a thing only this
79+ client sees.
10880
10981 The private key is a random 32-byte seed rather than a keygen context: for
110- Ed25519 the seed *is* the key, and `EVP_PKEY_new_raw_private_key` is the
111- whole of it."
82+ Ed25519 the seed *is* the key."
11283 [did]
11384 (forget!)
114- (try
115- (tls/ensure-native!)
116- (when-let [seed (random-bytes 32)]
117- (let [buf (ffi/alloc 32)]
118- (try
119- (ffi/write-array buf seed)
120- (let [pkey (c-new-raw-priv nid-ed25519 ffi/null buf 32)]
121- (when-not (ffi/null? pkey)
122- (let [pub (ffi/alloc 32)
123- plen (ffi/alloc (ffi/sizeof :size_t))]
124- (try
125- (ffi/write plen :size_t 32)
126- (when (= 1 (c-get-raw-pub pkey pub plen))
127- (let [raw (ffi/read-array pub 32)]
128- (reset! signer
129- {:pkey pkey
130- :did did
131- :public (b64url raw)
132- ;; The key id freeq names a signature by: the
133- ;; first half of the key's own SHA-256.
134- :kid (b64url (take 16 (sha256 raw)))})
135- (:public @signer)))
136- (finally (ffi/free pub) (ffi/free plen))))))
137- (finally (ffi/free buf)))))
138- (catch Exception _ nil)))
85+ (when-let [seed (crypto/random-bytes 32)]
86+ (when-let [raw (crypto/ed25519-generate! seed)]
87+ (reset! signer
88+ {:did did
89+ :public (b64url raw)
90+ ;; The key id freeq names a signature by: the first half of the
91+ ;; key's own SHA-256.
92+ :kid (b64url (take 16 (crypto/sha256 raw)))})
93+ (:public @signer))))
13994
14095 (defn- sign-bytes
14196 "An Ed25519 signature over `bs`, as `ed25519:<kid>:<b64url>` — the shape
14297 freeq's `+freeq.at/sig` carries."
14398 [bs]
144- (when-let [{:keys [pkey kid]} @signer]
145- (let [n (alength bs)
146- msg (ffi/alloc (max 1 n))
147- sig (ffi/alloc 64)
148- slen (ffi/alloc (ffi/sizeof :size_t))
149- ctx (c-md-ctx-new)]
150- (try
151- (ffi/write-array msg bs)
152- (ffi/write slen :size_t 64)
153- (when (and (= 1 (c-sign-init ctx ffi/null ffi/null ffi/null pkey))
154- (= 1 (c-sign ctx sig slen msg n)))
155- (str "ed25519:" kid ":" (b64url (ffi/read-array sig (ffi/read slen :size_t)))))
156- (catch Exception _ nil)
157- (finally (ffi/free msg) (ffi/free sig) (ffi/free slen) (c-md-ctx-free ctx))))))
99+ (when-let [{:keys [kid]} @signer]
100+ (when-let [sig (crypto/ed25519-sign bs)]
101+ (str "ed25519:" kid ":" (b64url sig)))))
158102
159103 ;; ---------------------------------------------------------------- canonical
160104
@@ -169,11 +113,13 @@
169113 no space in it. Both ends build this string from the same fields and neither
170114 sends it a signature over anything else is a signature over nothing."
171115 [m]
172- (.getBytes (str "{"
173- (str/join "," (for [[k v] (into (sorted-map) m)]
174- (str (json-string k) ":" (json-string v))))
175- "}")
176- "UTF-8"))
116+ ;; UTF-8 through `frq.io`: `.getBytes` is Java and there is none of it under
117+ ;; ClojureDart, and the bytes have to be the same on both or a signature
118+ ;; made on one would not verify against the other.
119+ (io/utf8-bytes (str "{"
120+ (str/join "," (for [[k v] (into (sorted-map) m)]
121+ (str (json-string k) ":" (json-string v))))
122+ "}")))
177123
178124 (def ^:private crockford "0123456789ABCDEFGHJKMNPQRSTVWXYZ")
179125
@@ -182,11 +128,11 @@
182128 chance. Sortable like the msgids the server hands out, and unguessable
183129 enough that two clients cannot mint the same one."
184130 []
185- (let [t (loop [t (quot (host/wall-nanos) 1000000) out ""]
131+ (let [t (loop [t (quot (io/wall-nanos) 1000000) out ""]
186132 (if (>= (count out) 10)
187133 out
188134 (recur (quot t 32) (str (nth crockford (mod t 32)) out))))]
189- (apply str t (for [b (or (random-bytes 16) (repeat 16 0))]
135+ (apply str t (for [b (or (crypto/random-bytes 16) (repeat 16 0))]
190136 (nth crockford (mod (bit-and (int b) 0xff) 32))))))
191137
192138 (defn signing-target
@@ -212,7 +158,7 @@
212158 [text]
213159 (str "sha256:"
214160 (apply str
215- (for [b (sha256 (.getBytes (or text "") "UTF-8"))
161+ (for [b (crypto/sha256 (io/utf8-bytes (or text "")))
216162 :let [v (bit-and (int b) 0xff)]
217163 c [(nth hex-digits (bit-shift-right v 4))
218164 (nth hex-digits (bit-and v 0xf))]]
similarity index 60%
rename from src/frq/msgsig.clj
rename to common/frq/msgsig.cljc
@@ -14,34 +14,14 @@
14 written down it says only \"the account on this session did this\", which14 written down it says only \"the account on this session did this\", which
15 is all the server is asking.15 is all the server is asking.
16 16
17- The primitives are OpenSSL's, reached through the same libcrypto jolt already17+ Shared, because all of that is the same everywhere. What is not is the four
18- loads for TLS. There is no other crypto here to borrow, and an Ed2551918+ primitives underneath Ed25519, SHA-256 and a source of random bytes and
19- written by hand is not a thing to put in a chat client."19+ those are `frq.crypto`, which the desktop answers with OpenSSL and the phone
20+ with its own. A platform that answers none of them signs nothing, and this
21+ namespace already knew how to be in that state."
20 (:require [clojure.string :as str]22 (:require [clojure.string :as str]
21- [jolt.ffi :as ffi]23+ [frq.crypto :as crypto]
22- [jolt.host :as host]24+ [frq.io :as io]))
23- [jolt.mvn-http :as tls]))
24-
25-;; ---------------------------------------------------------------- libcrypto
26-
27-(ffi/defcfn c-rand-bytes "RAND_bytes" [:pointer :int] :int)
28-(ffi/defcfn c-new-raw-priv "EVP_PKEY_new_raw_private_key"
29- [:int :pointer :pointer :size_t] :pointer)
30-(ffi/defcfn c-get-raw-pub "EVP_PKEY_get_raw_public_key"
31- [:pointer :pointer :pointer] :int)
32-(ffi/defcfn c-pkey-free "EVP_PKEY_free" [:pointer] :void)
33-(ffi/defcfn c-md-ctx-new "EVP_MD_CTX_new" [] :pointer)
34-(ffi/defcfn c-md-ctx-free "EVP_MD_CTX_free" [:pointer] :void)
35-(ffi/defcfn c-sign-init "EVP_DigestSignInit"
36- [:pointer :pointer :pointer :pointer :pointer] :int)
37-(ffi/defcfn c-sign "EVP_DigestSign" [:pointer :pointer :pointer :pointer :size_t] :int)
38-(ffi/defcfn c-sha256 "SHA256" [:pointer :size_t :pointer] :pointer)
39-
40-;; EVP_PKEY_ED25519. The one NID this namespace needs, and the one number in
41-;; OpenSSL's table that would be a silent wrong key if it were wrong.
42-(def ^:private nid-ed25519 1087)
43-
44-;; ---------------------------------------------------------------- encoding
45 25
46 (def ^:private b64url-alphabet26 (def ^:private b64url-alphabet
47 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")27 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
@@ -65,35 +45,26 @@
65 (nth b64url-alphabet45 (nth b64url-alphabet
66 (bit-and (bit-shift-right v (* 6 (- 3 i))) 0x3f))))))46 (bit-and (bit-shift-right v (* 6 (- 3 i))) 0x3f))))))
67 47
68-(defn- random-bytes48+
69- "`n` bytes from OpenSSL's CSPRNG, or nil if it will not give them."
70- [n]
71- (let [buf (ffi/alloc n)]
72- (try (when (= 1 (c-rand-bytes buf n)) (ffi/read-array buf n))
73- (finally (ffi/free buf)))))
74-
75-(defn- sha256 [bs]
76- (let [n (alength bs)
77- in (ffi/alloc (max 1 n))
78- out (ffi/alloc 32)]
79- (try (ffi/write-array in bs)
80- (c-sha256 in n out)
81- (ffi/read-array out 32)
82- (finally (ffi/free in) (ffi/free out)))))
83 49
84 ;; ---------------------------------------------------------------- the key50 ;; ---------------------------------------------------------------- the key
85 51
86-;; One key per connection: {:pkey <EVP_PKEY*> :public <b64url> :kid <b64url>52+
87-;; :did <did>}. Held here rather than on the connection because a signature is53+
88-;; not something the transport should be able to hand out.54+
55+
56+
57+;; One key per connection: {:public <b64url> :kid <b64url> :did <did>}. The
58+;; private half is not here and never was `frq.crypto` keeps it, because a
59+;; signature is not something this namespace, or the transport under it,
60+;; should be able to hand out.
89 (defonce ^:private signer (atom nil))61 (defonce ^:private signer (atom nil))
90 62
91 (defn forget!63 (defn forget!
92 "Drop the session key. Called when the connection goes, so a reconnect signs64 "Drop the session key. Called when the connection goes, so a reconnect signs
93 with a key the server has actually been told about."65 with a key the server has actually been told about."
94 []66 []
95- (when-let [{:keys [pkey]} @signer]67+ (crypto/ed25519-forget!)
96- (try (c-pkey-free pkey) (catch Exception _ nil)))
97 (reset! signer nil))68 (reset! signer nil))
98 69
99 (defn public-key70 (defn public-key
@@ -103,58 +74,31 @@
103 74
104 (defn generate!75 (defn generate!
105 "Mint this connection's signing key for `did`, and return the public half as76 "Mint this connection's signing key for `did`, and return the public half as
106- base64url the argument `MSGSIG` takes. nil if libcrypto will not play, and77+ base64url the argument `MSGSIG` takes. nil where the platform has no
107- then nothing is signed and reactions stay a thing only this client sees.78+ Ed25519, and then nothing is signed and reactions stay a thing only this
79+ client sees.
108 80
109 The private key is a random 32-byte seed rather than a keygen context: for81 The private key is a random 32-byte seed rather than a keygen context: for
110- Ed25519 the seed *is* the key, and `EVP_PKEY_new_raw_private_key` is the82+ Ed25519 the seed *is* the key."
111- whole of it."
112 [did]83 [did]
113 (forget!)84 (forget!)
114- (try85+ (when-let [seed (crypto/random-bytes 32)]
115- (tls/ensure-native!)86+ (when-let [raw (crypto/ed25519-generate! seed)]
116- (when-let [seed (random-bytes 32)]87+ (reset! signer
117- (let [buf (ffi/alloc 32)]88+ {:did did
118- (try89+ :public (b64url raw)
119- (ffi/write-array buf seed)90+ ;; The key id freeq names a signature by: the first half of the
120- (let [pkey (c-new-raw-priv nid-ed25519 ffi/null buf 32)]91+ ;; key's own SHA-256.
121- (when-not (ffi/null? pkey)92+ :kid (b64url (take 16 (crypto/sha256 raw)))})
122- (let [pub (ffi/alloc 32)93+ (:public @signer))))
123- plen (ffi/alloc (ffi/sizeof :size_t))]
124- (try
125- (ffi/write plen :size_t 32)
126- (when (= 1 (c-get-raw-pub pkey pub plen))
127- (let [raw (ffi/read-array pub 32)]
128- (reset! signer
129- {:pkey pkey
130- :did did
131- :public (b64url raw)
132- ;; The key id freeq names a signature by: the
133- ;; first half of the key's own SHA-256.
134- :kid (b64url (take 16 (sha256 raw)))})
135- (:public @signer)))
136- (finally (ffi/free pub) (ffi/free plen))))))
137- (finally (ffi/free buf)))))
138- (catch Exception _ nil)))
139 94
140 (defn- sign-bytes95 (defn- sign-bytes
141 "An Ed25519 signature over `bs`, as `ed25519:<kid>:<b64url>` — the shape96 "An Ed25519 signature over `bs`, as `ed25519:<kid>:<b64url>` — the shape
142 freeq's `+freeq.at/sig` carries."97 freeq's `+freeq.at/sig` carries."
143 [bs]98 [bs]
144- (when-let [{:keys [pkey kid]} @signer]99+ (when-let [{:keys [kid]} @signer]
145- (let [n (alength bs)100+ (when-let [sig (crypto/ed25519-sign bs)]
146- msg (ffi/alloc (max 1 n))101+ (str "ed25519:" kid ":" (b64url sig)))))
147- sig (ffi/alloc 64)
148- slen (ffi/alloc (ffi/sizeof :size_t))
149- ctx (c-md-ctx-new)]
150- (try
151- (ffi/write-array msg bs)
152- (ffi/write slen :size_t 64)
153- (when (and (= 1 (c-sign-init ctx ffi/null ffi/null ffi/null pkey))
154- (= 1 (c-sign ctx sig slen msg n)))
155- (str "ed25519:" kid ":" (b64url (ffi/read-array sig (ffi/read slen :size_t)))))
156- (catch Exception _ nil)
157- (finally (ffi/free msg) (ffi/free sig) (ffi/free slen) (c-md-ctx-free ctx))))))
158 102
159 ;; ---------------------------------------------------------------- canonical103 ;; ---------------------------------------------------------------- canonical
160 104
@@ -169,11 +113,13 @@
169 no space in it. Both ends build this string from the same fields and neither113 no space in it. Both ends build this string from the same fields and neither
170 sends it a signature over anything else is a signature over nothing."114 sends it a signature over anything else is a signature over nothing."
171 [m]115 [m]
172- (.getBytes (str "{"116+ ;; UTF-8 through `frq.io`: `.getBytes` is Java and there is none of it under
173- (str/join "," (for [[k v] (into (sorted-map) m)]117+ ;; ClojureDart, and the bytes have to be the same on both or a signature
174- (str (json-string k) ":" (json-string v))))118+ ;; made on one would not verify against the other.
175- "}")119+ (io/utf8-bytes (str "{"
176- "UTF-8"))120+ (str/join "," (for [[k v] (into (sorted-map) m)]
121+ (str (json-string k) ":" (json-string v))))
122+ "}")))
177 123
178 (def ^:private crockford "0123456789ABCDEFGHJKMNPQRSTVWXYZ")124 (def ^:private crockford "0123456789ABCDEFGHJKMNPQRSTVWXYZ")
179 125
@@ -182,11 +128,11 @@
182 chance. Sortable like the msgids the server hands out, and unguessable128 chance. Sortable like the msgids the server hands out, and unguessable
183 enough that two clients cannot mint the same one."129 enough that two clients cannot mint the same one."
184 []130 []
185- (let [t (loop [t (quot (host/wall-nanos) 1000000) out ""]131+ (let [t (loop [t (quot (io/wall-nanos) 1000000) out ""]
186 (if (>= (count out) 10)132 (if (>= (count out) 10)
187 out133 out
188 (recur (quot t 32) (str (nth crockford (mod t 32)) out))))]134 (recur (quot t 32) (str (nth crockford (mod t 32)) out))))]
189- (apply str t (for [b (or (random-bytes 16) (repeat 16 0))]135+ (apply str t (for [b (or (crypto/random-bytes 16) (repeat 16 0))]
190 (nth crockford (mod (bit-and (int b) 0xff) 32))))))136 (nth crockford (mod (bit-and (int b) 0xff) 32))))))
191 137
192 (defn signing-target138 (defn signing-target
@@ -212,7 +158,7 @@
212 [text]158 [text]
213 (str "sha256:"159 (str "sha256:"
214 (apply str160 (apply str
215- (for [b (sha256 (.getBytes (or text "") "UTF-8"))161+ (for [b (crypto/sha256 (io/utf8-bytes (or text "")))
216 :let [v (bit-and (int b) 0xff)]162 :let [v (bit-and (int b) 0xff)]
217 c [(nth hex-digits (bit-shift-right v 4))163 c [(nth hex-digits (bit-shift-right v 4))
218 (nth hex-digits (bit-and v 0xf))]]164 (nth hex-digits (bit-and v 0xf))]]
modified flutter/README.md +9 -2
@@ -211,8 +211,15 @@ the first move rather than the tenth.
211211 browser it does not own. That wants an app link or a custom scheme, an
212212 intent filter, and a redirect URI the broker will accept — a decision about
213213 freeq's broker, not a porting problem.
214-3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the
215- io one.
214+3. ~~**`frq.msgsig`**~~ — done. The signing is shared; the four primitives
215+ under it are `frq.crypto`, which the desktop answers with the same OpenSSL
216+ it loads for TLS and the phone with `package:ed25519_edwards` and
217+ `package:crypto`, both pure Dart and both synchronous — a signature is
218+ minted in the middle of sending a reaction and there is nothing to await
219+ on. Verified on both against RFC 8032 test 1: same public key, same
220+ signature, byte for byte.
221+
222+ **`frq.wire`** (81) still wants the seam extended.
216223 4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** —
217224 small, and mostly fetch-and-cache.
218225 5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per
@@ -211,8 +211,15 @@ the first move rather than the tenth.
211 browser it does not own. That wants an app link or a custom scheme, an211 browser it does not own. That wants an app link or a custom scheme, an
212 intent filter, and a redirect URI the broker will accept — a decision about212 intent filter, and a redirect URI the broker will accept — a decision about
213 freeq's broker, not a porting problem.213 freeq's broker, not a porting problem.
214-3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the214+3. ~~**`frq.msgsig`**~~ — done. The signing is shared; the four primitives
215- io one.215+ under it are `frq.crypto`, which the desktop answers with the same OpenSSL
216+ it loads for TLS and the phone with `package:ed25519_edwards` and
217+ `package:crypto`, both pure Dart and both synchronous — a signature is
218+ minted in the middle of sending a reaction and there is nothing to await
219+ on. Verified on both against RFC 8032 test 1: same public key, same
220+ signature, byte for byte.
221+
222+ **`frq.wire`** (81) still wants the seam extended.
216 4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** —223 4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** —
217 small, and mostly fetch-and-cache.224 small, and mostly fetch-and-cache.
218 5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per225 5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per
modified flutter/pubspec.lock +33 -1
@@ -1,6 +1,14 @@
11 # Generated by pub
22 # See https://dart.dev/tools/pub/glossary#lockfile
33 packages:
4+ adaptive_number:
5+ dependency: transitive
6+ description:
7+ name: adaptive_number
8+ sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
9+ url: "https://pub.dev"
10+ source: hosted
11+ version: "1.0.0"
412 args:
513 dependency: transitive
614 description:
@@ -57,8 +65,16 @@ packages:
5765 url: "https://pub.dev"
5866 source: hosted
5967 version: "1.19.1"
60- crypto:
68+ convert:
6169 dependency: transitive
70+ description:
71+ name: convert
72+ sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
73+ url: "https://pub.dev"
74+ source: hosted
75+ version: "3.1.2"
76+ crypto:
77+ dependency: "direct main"
6278 description:
6379 name: crypto
6480 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -73,6 +89,14 @@ packages:
7389 url: "https://pub.dev"
7490 source: hosted
7591 version: "1.0.9"
92+ ed25519_edwards:
93+ dependency: "direct main"
94+ description:
95+ name: ed25519_edwards
96+ sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534
97+ url: "https://pub.dev"
98+ source: hosted
99+ version: "0.3.2"
76100 fake_async:
77101 dependency: transitive
78102 description:
@@ -89,6 +113,14 @@ packages:
89113 url: "https://pub.dev"
90114 source: hosted
91115 version: "2.2.0"
116+ fixnum:
117+ dependency: transitive
118+ description:
119+ name: fixnum
120+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
121+ url: "https://pub.dev"
122+ source: hosted
123+ version: "1.1.1"
92124 flutter:
93125 dependency: "direct main"
94126 description: flutter
@@ -1,6 +1,14 @@
1 # Generated by pub1 # Generated by pub
2 # See https://dart.dev/tools/pub/glossary#lockfile2 # See https://dart.dev/tools/pub/glossary#lockfile
3 packages:3 packages:
4+ adaptive_number:
5+ dependency: transitive
6+ description:
7+ name: adaptive_number
8+ sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
9+ url: "https://pub.dev"
10+ source: hosted
11+ version: "1.0.0"
4 args:12 args:
5 dependency: transitive13 dependency: transitive
6 description:14 description:
@@ -57,8 +65,16 @@ packages:
57 url: "https://pub.dev"65 url: "https://pub.dev"
58 source: hosted66 source: hosted
59 version: "1.19.1"67 version: "1.19.1"
60- crypto:68+ convert:
61 dependency: transitive69 dependency: transitive
70+ description:
71+ name: convert
72+ sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
73+ url: "https://pub.dev"
74+ source: hosted
75+ version: "3.1.2"
76+ crypto:
77+ dependency: "direct main"
62 description:78 description:
63 name: crypto79 name: crypto
64 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf80 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -73,6 +89,14 @@ packages:
73 url: "https://pub.dev"89 url: "https://pub.dev"
74 source: hosted90 source: hosted
75 version: "1.0.9"91 version: "1.0.9"
92+ ed25519_edwards:
93+ dependency: "direct main"
94+ description:
95+ name: ed25519_edwards
96+ sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534
97+ url: "https://pub.dev"
98+ source: hosted
99+ version: "0.3.2"
76 fake_async:100 fake_async:
77 dependency: transitive101 dependency: transitive
78 description:102 description:
@@ -89,6 +113,14 @@ packages:
89 url: "https://pub.dev"113 url: "https://pub.dev"
90 source: hosted114 source: hosted
91 version: "2.2.0"115 version: "2.2.0"
116+ fixnum:
117+ dependency: transitive
118+ description:
119+ name: fixnum
120+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
121+ url: "https://pub.dev"
122+ source: hosted
123+ version: "1.1.1"
92 flutter:124 flutter:
93 dependency: "direct main"125 dependency: "direct main"
94 description: flutter126 description: flutter
modified flutter/pubspec.yaml +2 -1
@@ -35,7 +35,8 @@ dependencies:
3535 # Use with the CupertinoIcons class for iOS style icons.
3636 cupertino_icons: ^1.0.8
3737 path_provider: ^2.1.6
38-
38+ ed25519_edwards: ^0.3.2
39+ crypto: ^3.0.7
3940 dev_dependencies:
4041 flutter_test:
4142 sdk: flutter
@@ -35,7 +35,8 @@ dependencies:
35 # Use with the CupertinoIcons class for iOS style icons.35 # Use with the CupertinoIcons class for iOS style icons.
36 cupertino_icons: ^1.0.836 cupertino_icons: ^1.0.8
37 path_provider: ^2.1.637 path_provider: ^2.1.6
38-38+ ed25519_edwards: ^0.3.2
39+ crypto: ^3.0.7
39 dev_dependencies:40 dev_dependencies:
40 flutter_test:41 flutter_test:
41 sdk: flutter42 sdk: flutter
added flutter/src/frq/crypto/dart.cljd +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+(ns frq.crypto.dart
2+ "The phone's answers to `frq.crypto`.
3+
4+ Android ships no libcrypto a process may link, so where the desktop reaches
5+ OpenSSL through the loader this brings its own: `package:ed25519_edwards`
6+ for the signatures and `package:crypto` for the digest, both pure Dart and
7+ both synchronous which matters, because a signature is minted in the
8+ middle of sending a reaction and there is nothing there to await on.
9+
10+ pointycastle would have been the obvious choice and does not implement
11+ Ed25519 at all; it carries the object identifier and nothing behind it."
12+ (:require ["dart:math" :as math]
13+ ["dart:typed_data" :as td]
14+ ["package:crypto/crypto.dart" :as c]
15+ ["package:ed25519_edwards/ed25519_edwards.dart" :as ed]
16+ [frq.crypto :as crypto]))
17+
18+(defonce ^:private rng (math/Random.secure))
19+(defonce ^:private priv (atom nil))
20+
21+(defn- u8
22+ "A byte sequence as the Uint8List these libraries take.
23+
24+ Built rather than cast: `frq.msgsig` passes vectors and lazy seqs, because
25+ those are the shapes both compilers agree on, and a PersistentVector is not
26+ a List<int>."
27+ [bs]
28+ (let [v (vec bs)
29+ n (count v)
30+ out (td/Uint8List. n)]
31+ (dotimes [i n]
32+ (aset out i (bit-and (int (nth v i)) 0xff)))
33+ out))
34+
35+(defn- random-bytes [n]
36+ (let [out (td/Uint8List. n)]
37+ (dotimes [i n] (aset out i (.nextInt rng 256)))
38+ (vec out)))
39+
40+(defn- sha256 [bs]
41+ (vec (.-bytes (.convert c/sha256 (u8 bs)))))
42+
43+(defn- forget! []
44+ (reset! priv nil)
45+ nil)
46+
47+(defn- generate! [seed]
48+ (try
49+ (let [k (ed/newKeyFromSeed (u8 seed))]
50+ (reset! priv k)
51+ (vec (.-bytes (ed/public k))))
52+ (catch Object _ nil)))
53+
54+(defn- sign [bs]
55+ (when-let [k @priv]
56+ (try (vec (ed/sign k (u8 bs)))
57+ (catch Object _ nil))))
58+
59+(defn install! []
60+ (crypto/install!
61+ {:random-bytes random-bytes
62+ :sha256 sha256
63+ :ed25519-generate! generate!
64+ :ed25519-sign sign
65+ :ed25519-forget! forget!}))
new file mode 100644
@@ -0,0 +1,65 @@
1+(ns frq.crypto.dart
2+ "The phone's answers to `frq.crypto`.
3+
4+ Android ships no libcrypto a process may link, so where the desktop reaches
5+ OpenSSL through the loader this brings its own: `package:ed25519_edwards`
6+ for the signatures and `package:crypto` for the digest, both pure Dart and
7+ both synchronous which matters, because a signature is minted in the
8+ middle of sending a reaction and there is nothing there to await on.
9+
10+ pointycastle would have been the obvious choice and does not implement
11+ Ed25519 at all; it carries the object identifier and nothing behind it."
12+ (:require ["dart:math" :as math]
13+ ["dart:typed_data" :as td]
14+ ["package:crypto/crypto.dart" :as c]
15+ ["package:ed25519_edwards/ed25519_edwards.dart" :as ed]
16+ [frq.crypto :as crypto]))
17+
18+(defonce ^:private rng (math/Random.secure))
19+(defonce ^:private priv (atom nil))
20+
21+(defn- u8
22+ "A byte sequence as the Uint8List these libraries take.
23+
24+ Built rather than cast: `frq.msgsig` passes vectors and lazy seqs, because
25+ those are the shapes both compilers agree on, and a PersistentVector is not
26+ a List<int>."
27+ [bs]
28+ (let [v (vec bs)
29+ n (count v)
30+ out (td/Uint8List. n)]
31+ (dotimes [i n]
32+ (aset out i (bit-and (int (nth v i)) 0xff)))
33+ out))
34+
35+(defn- random-bytes [n]
36+ (let [out (td/Uint8List. n)]
37+ (dotimes [i n] (aset out i (.nextInt rng 256)))
38+ (vec out)))
39+
40+(defn- sha256 [bs]
41+ (vec (.-bytes (.convert c/sha256 (u8 bs)))))
42+
43+(defn- forget! []
44+ (reset! priv nil)
45+ nil)
46+
47+(defn- generate! [seed]
48+ (try
49+ (let [k (ed/newKeyFromSeed (u8 seed))]
50+ (reset! priv k)
51+ (vec (.-bytes (ed/public k))))
52+ (catch Object _ nil)))
53+
54+(defn- sign [bs]
55+ (when-let [k @priv]
56+ (try (vec (ed/sign k (u8 bs)))
57+ (catch Object _ nil))))
58+
59+(defn install! []
60+ (crypto/install!
61+ {:random-bytes random-bytes
62+ :sha256 sha256
63+ :ed25519-generate! generate!
64+ :ed25519-sign sign
65+ :ed25519-forget! forget!}))
modified flutter/src/frq/main.cljd +6 -0
@@ -23,6 +23,7 @@
2323 [frq.hiccup :as h]
2424 [frq.theme :as t]
2525 [frq.io.dart :as host]
26+ [frq.crypto.dart :as crypto-dart]
2627 [frq.net.dart :as net]
2728 [frq.atproto.dart :as atproto]
2829 [frq.clock :as clock]
@@ -251,6 +252,11 @@
251252 " CONTEXT " (.-context details)))))
252253 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
253254 (host/install! dir)
255+ ;; Ed25519 for the reactions freeq will not take on trust. Verified
256+ ;; against RFC 8032 test 1 on the device: the same public key and the
257+ ;; same signature OpenSSL gives on the desktop, so a signature minted
258+ ;; here verifies the same way at the server.
259+ (crypto-dart/install!)
254260 ;; What the shared screen calls. The desktop installs frq.state's
255261 ;; reducers here; this installs the phone's.
256262 (actions/install!
@@ -23,6 +23,7 @@
23 [frq.hiccup :as h]23 [frq.hiccup :as h]
24 [frq.theme :as t]24 [frq.theme :as t]
25 [frq.io.dart :as host]25 [frq.io.dart :as host]
26+ [frq.crypto.dart :as crypto-dart]
26 [frq.net.dart :as net]27 [frq.net.dart :as net]
27 [frq.atproto.dart :as atproto]28 [frq.atproto.dart :as atproto]
28 [frq.clock :as clock]29 [frq.clock :as clock]
@@ -251,6 +252,11 @@
251 " CONTEXT " (.-context details)))))252 " CONTEXT " (.-context details)))))
252 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]253 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
253 (host/install! dir)254 (host/install! dir)
255+ ;; Ed25519 for the reactions freeq will not take on trust. Verified
256+ ;; against RFC 8032 test 1 on the device: the same public key and the
257+ ;; same signature OpenSSL gives on the desktop, so a signature minted
258+ ;; here verifies the same way at the server.
259+ (crypto-dart/install!)
254 ;; What the shared screen calls. The desktop installs frq.state's260 ;; What the shared screen calls. The desktop installs frq.state's
255 ;; reducers here; this installs the phone's.261 ;; reducers here; this installs the phone's.
256 (actions/install!262 (actions/install!
added src/frq/crypto/openssl.clj +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+(ns frq.crypto.openssl
2+ "The desktop's answers to `frq.crypto`, over libcrypto.
3+
4+ The same OpenSSL jolt already loads for TLS — there is no other crypto here
5+ to borrow, and an Ed25519 written by hand is not a thing to put in a chat
6+ client. Requiring this installs them.
7+
8+ The EVP_PKEY lives here rather than in `frq.msgsig`, which is shared and has
9+ nowhere to put a pointer."
10+ (:require [frq.crypto :as crypto]
11+ [jolt.ffi :as ffi]
12+ [jolt.mvn-http :as tls]))
13+
14+
15+(ffi/defcfn c-rand-bytes "RAND_bytes" [:pointer :int] :int)
16+(ffi/defcfn c-new-raw-priv "EVP_PKEY_new_raw_private_key"
17+ [:int :pointer :pointer :size_t] :pointer)
18+(ffi/defcfn c-get-raw-pub "EVP_PKEY_get_raw_public_key"
19+ [:pointer :pointer :pointer] :int)
20+(ffi/defcfn c-pkey-free "EVP_PKEY_free" [:pointer] :void)
21+(ffi/defcfn c-md-ctx-new "EVP_MD_CTX_new" [] :pointer)
22+(ffi/defcfn c-md-ctx-free "EVP_MD_CTX_free" [:pointer] :void)
23+(ffi/defcfn c-sign-init "EVP_DigestSignInit"
24+ [:pointer :pointer :pointer :pointer :pointer] :int)
25+(ffi/defcfn c-sign "EVP_DigestSign" [:pointer :pointer :pointer :pointer :size_t] :int)
26+(ffi/defcfn c-sha256 "SHA256" [:pointer :size_t :pointer] :pointer)
27+
28+;; Ed25519's NID, which openssl/obj_mac.h spells EVP_PKEY_ED25519.
29+(def ^:private nid-ed25519 1087)
30+
31+(defonce ^:private pkey (atom nil))
32+
33+(defn- ->bytes
34+ "Whatever the shared side handed over, as the array ffi/write-array wants.
35+
36+ `frq.io/utf8-bytes` answers a vector of ints and `frq.msgsig` passes seqs
37+ around, because those are the only shapes both compilers agree on. The
38+ conversion belongs here, where the pointer does."
39+ [bs]
40+ (if (bytes? bs) bs (byte-array (map unchecked-byte bs))))
41+
42+(defn- random-bytes [n]
43+ (tls/ensure-native!)
44+ (let [buf (ffi/alloc n)]
45+ (try (when (= 1 (c-rand-bytes buf n)) (ffi/read-array buf n))
46+ (finally (ffi/free buf)))))
47+
48+(defn- sha256 [bs]
49+ (let [bs (->bytes bs)
50+ n (alength bs)
51+ in (ffi/alloc (max 1 n))
52+ out (ffi/alloc 32)]
53+ (try (ffi/write-array in bs)
54+ (c-sha256 in n out)
55+ (ffi/read-array out 32)
56+ (finally (ffi/free in) (ffi/free out)))))
57+
58+(defn- forget! []
59+ (when-let [k @pkey]
60+ (try (c-pkey-free k) (catch Exception _ nil)))
61+ (reset! pkey nil))
62+
63+(defn- generate!
64+ "The seed IS the key for Ed25519, so `EVP_PKEY_new_raw_private_key` is the
65+ whole of it. Returns the public half as bytes."
66+ [seed]
67+ (forget!)
68+ (try
69+ (tls/ensure-native!)
70+ (let [seed (->bytes seed)
71+ buf (ffi/alloc 32)]
72+ (try
73+ (ffi/write-array buf seed)
74+ (let [k (c-new-raw-priv nid-ed25519 ffi/null buf 32)]
75+ (when-not (ffi/null? k)
76+ (let [pub (ffi/alloc 32)
77+ plen (ffi/alloc (ffi/sizeof :size_t))]
78+ (try
79+ (ffi/write plen :size_t 32)
80+ (when (= 1 (c-get-raw-pub k pub plen))
81+ (reset! pkey k)
82+ (ffi/read-array pub 32))
83+ (finally (ffi/free pub) (ffi/free plen))))))
84+ (finally (ffi/free buf))))
85+ (catch Exception _ nil)))
86+
87+(defn- sign [bs]
88+ (when-let [k @pkey]
89+ (let [bs (->bytes bs)
90+ n (alength bs)
91+ msg (ffi/alloc (max 1 n))
92+ sig (ffi/alloc 64)
93+ slen (ffi/alloc (ffi/sizeof :size_t))
94+ ctx (c-md-ctx-new)]
95+ (try
96+ (ffi/write-array msg bs)
97+ (ffi/write slen :size_t 64)
98+ (when (and (= 1 (c-sign-init ctx ffi/null ffi/null ffi/null k))
99+ (= 1 (c-sign ctx sig slen msg n)))
100+ (ffi/read-array sig (ffi/read slen :size_t)))
101+ (catch Exception _ nil)
102+ (finally (ffi/free msg) (ffi/free sig) (ffi/free slen) (c-md-ctx-free ctx))))))
103+
104+(crypto/install!
105+ {:random-bytes random-bytes
106+ :sha256 sha256
107+ :ed25519-generate! generate!
108+ :ed25519-sign sign
109+ :ed25519-forget! forget!})
new file mode 100644
@@ -0,0 +1,109 @@
1+(ns frq.crypto.openssl
2+ "The desktop's answers to `frq.crypto`, over libcrypto.
3+
4+ The same OpenSSL jolt already loads for TLS — there is no other crypto here
5+ to borrow, and an Ed25519 written by hand is not a thing to put in a chat
6+ client. Requiring this installs them.
7+
8+ The EVP_PKEY lives here rather than in `frq.msgsig`, which is shared and has
9+ nowhere to put a pointer."
10+ (:require [frq.crypto :as crypto]
11+ [jolt.ffi :as ffi]
12+ [jolt.mvn-http :as tls]))
13+
14+
15+(ffi/defcfn c-rand-bytes "RAND_bytes" [:pointer :int] :int)
16+(ffi/defcfn c-new-raw-priv "EVP_PKEY_new_raw_private_key"
17+ [:int :pointer :pointer :size_t] :pointer)
18+(ffi/defcfn c-get-raw-pub "EVP_PKEY_get_raw_public_key"
19+ [:pointer :pointer :pointer] :int)
20+(ffi/defcfn c-pkey-free "EVP_PKEY_free" [:pointer] :void)
21+(ffi/defcfn c-md-ctx-new "EVP_MD_CTX_new" [] :pointer)
22+(ffi/defcfn c-md-ctx-free "EVP_MD_CTX_free" [:pointer] :void)
23+(ffi/defcfn c-sign-init "EVP_DigestSignInit"
24+ [:pointer :pointer :pointer :pointer :pointer] :int)
25+(ffi/defcfn c-sign "EVP_DigestSign" [:pointer :pointer :pointer :pointer :size_t] :int)
26+(ffi/defcfn c-sha256 "SHA256" [:pointer :size_t :pointer] :pointer)
27+
28+;; Ed25519's NID, which openssl/obj_mac.h spells EVP_PKEY_ED25519.
29+(def ^:private nid-ed25519 1087)
30+
31+(defonce ^:private pkey (atom nil))
32+
33+(defn- ->bytes
34+ "Whatever the shared side handed over, as the array ffi/write-array wants.
35+
36+ `frq.io/utf8-bytes` answers a vector of ints and `frq.msgsig` passes seqs
37+ around, because those are the only shapes both compilers agree on. The
38+ conversion belongs here, where the pointer does."
39+ [bs]
40+ (if (bytes? bs) bs (byte-array (map unchecked-byte bs))))
41+
42+(defn- random-bytes [n]
43+ (tls/ensure-native!)
44+ (let [buf (ffi/alloc n)]
45+ (try (when (= 1 (c-rand-bytes buf n)) (ffi/read-array buf n))
46+ (finally (ffi/free buf)))))
47+
48+(defn- sha256 [bs]
49+ (let [bs (->bytes bs)
50+ n (alength bs)
51+ in (ffi/alloc (max 1 n))
52+ out (ffi/alloc 32)]
53+ (try (ffi/write-array in bs)
54+ (c-sha256 in n out)
55+ (ffi/read-array out 32)
56+ (finally (ffi/free in) (ffi/free out)))))
57+
58+(defn- forget! []
59+ (when-let [k @pkey]
60+ (try (c-pkey-free k) (catch Exception _ nil)))
61+ (reset! pkey nil))
62+
63+(defn- generate!
64+ "The seed IS the key for Ed25519, so `EVP_PKEY_new_raw_private_key` is the
65+ whole of it. Returns the public half as bytes."
66+ [seed]
67+ (forget!)
68+ (try
69+ (tls/ensure-native!)
70+ (let [seed (->bytes seed)
71+ buf (ffi/alloc 32)]
72+ (try
73+ (ffi/write-array buf seed)
74+ (let [k (c-new-raw-priv nid-ed25519 ffi/null buf 32)]
75+ (when-not (ffi/null? k)
76+ (let [pub (ffi/alloc 32)
77+ plen (ffi/alloc (ffi/sizeof :size_t))]
78+ (try
79+ (ffi/write plen :size_t 32)
80+ (when (= 1 (c-get-raw-pub k pub plen))
81+ (reset! pkey k)
82+ (ffi/read-array pub 32))
83+ (finally (ffi/free pub) (ffi/free plen))))))
84+ (finally (ffi/free buf))))
85+ (catch Exception _ nil)))
86+
87+(defn- sign [bs]
88+ (when-let [k @pkey]
89+ (let [bs (->bytes bs)
90+ n (alength bs)
91+ msg (ffi/alloc (max 1 n))
92+ sig (ffi/alloc 64)
93+ slen (ffi/alloc (ffi/sizeof :size_t))
94+ ctx (c-md-ctx-new)]
95+ (try
96+ (ffi/write-array msg bs)
97+ (ffi/write slen :size_t 64)
98+ (when (and (= 1 (c-sign-init ctx ffi/null ffi/null ffi/null k))
99+ (= 1 (c-sign ctx sig slen msg n)))
100+ (ffi/read-array sig (ffi/read slen :size_t)))
101+ (catch Exception _ nil)
102+ (finally (ffi/free msg) (ffi/free sig) (ffi/free slen) (c-md-ctx-free ctx))))))
103+
104+(crypto/install!
105+ {:random-bytes random-bytes
106+ :sha256 sha256
107+ :ed25519-generate! generate!
108+ :ed25519-sign sign
109+ :ed25519-forget! forget!})
modified src/frq/state.clj +3 -0
@@ -15,6 +15,9 @@
1515 [frq.clock :as clock]
1616 [frq.emoji :as emoji]
1717 [frq.irc :as irc]
18+ ;; For the side effect: it installs the desktop crypto behind
19+ ;; `frq.crypto`, which the shared `frq.msgsig` signs through.
20+ [frq.crypto.openssl]
1821 [frq.msgsig :as msgsig]
1922 [frq.avatars :as avatars]
2023 [frq.media :as media]
@@ -15,6 +15,9 @@
15 [frq.clock :as clock]15 [frq.clock :as clock]
16 [frq.emoji :as emoji]16 [frq.emoji :as emoji]
17 [frq.irc :as irc]17 [frq.irc :as irc]
18+ ;; For the side effect: it installs the desktop crypto behind
19+ ;; `frq.crypto`, which the shared `frq.msgsig` signs through.
20+ [frq.crypto.openssl]
18 [frq.msgsig :as msgsig]21 [frq.msgsig :as msgsig]
19 [frq.avatars :as avatars]22 [frq.avatars :as avatars]
20 [frq.media :as media]23 [frq.media :as media]