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>
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 @@ | ||
| 14 | 14 | written down — it says only \"the account on this session did this\", which |
| 15 | 15 | is all the server is asking. |
| 16 | 16 | |
| 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." | |
| 20 | 22 | (: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])) | |
| 45 | 25 | |
| 46 | 26 | (def ^:private b64url-alphabet |
| 47 | 27 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") |
| @@ -65,35 +45,26 @@ | ||
| 65 | 45 | (nth b64url-alphabet |
| 66 | 46 | (bit-and (bit-shift-right v (* 6 (- 3 i))) 0x3f)))))) |
| 67 | 47 | |
| 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 | + | |
| 83 | 49 | |
| 84 | 50 | ;; ---------------------------------------------------------------- the key |
| 85 | 51 | |
| 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. | |
| 89 | 61 | (defonce ^:private signer (atom nil)) |
| 90 | 62 | |
| 91 | 63 | (defn forget! |
| 92 | 64 | "Drop the session key. Called when the connection goes, so a reconnect signs |
| 93 | 65 | with a key the server has actually been told about." |
| 94 | 66 | [] |
| 95 | - (when-let [{:keys [pkey]} @signer] | |
| 96 | - (try (c-pkey-free pkey) (catch Exception _ nil))) | |
| 67 | + (crypto/ed25519-forget!) | |
| 97 | 68 | (reset! signer nil)) |
| 98 | 69 | |
| 99 | 70 | (defn public-key |
| @@ -103,58 +74,31 @@ | ||
| 103 | 74 | |
| 104 | 75 | (defn generate! |
| 105 | 76 | "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. | |
| 108 | 80 | |
| 109 | 81 | 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." | |
| 112 | 83 | [did] |
| 113 | 84 | (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)))) | |
| 139 | 94 | |
| 140 | 95 | (defn- sign-bytes |
| 141 | 96 | "An Ed25519 signature over `bs`, as `ed25519:<kid>:<b64url>` — the shape |
| 142 | 97 | freeq's `+freeq.at/sig` carries." |
| 143 | 98 | [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))))) | |
| 158 | 102 | |
| 159 | 103 | ;; ---------------------------------------------------------------- canonical |
| 160 | 104 | |
| @@ -169,11 +113,13 @@ | ||
| 169 | 113 | no space in it. Both ends build this string from the same fields and neither |
| 170 | 114 | sends it — a signature over anything else is a signature over nothing." |
| 171 | 115 | [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 | + "}"))) | |
| 177 | 123 | |
| 178 | 124 | (def ^:private crockford "0123456789ABCDEFGHJKMNPQRSTVWXYZ") |
| 179 | 125 | |
| @@ -182,11 +128,11 @@ | ||
| 182 | 128 | chance. Sortable like the msgids the server hands out, and unguessable |
| 183 | 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 | 132 | (if (>= (count out) 10) |
| 187 | 133 | out |
| 188 | 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 | 136 | (nth crockford (mod (bit-and (int b) 0xff) 32)))))) |
| 191 | 137 | |
| 192 | 138 | (defn signing-target |
| @@ -212,7 +158,7 @@ | ||
| 212 | 158 | [text] |
| 213 | 159 | (str "sha256:" |
| 214 | 160 | (apply str |
| 215 | - (for [b (sha256 (.getBytes (or text "") "UTF-8")) | |
| 161 | + (for [b (crypto/sha256 (io/utf8-bytes (or text ""))) | |
| 216 | 162 | :let [v (bit-and (int b) 0xff)] |
| 217 | 163 | c [(nth hex-digits (bit-shift-right v 4)) |
| 218 | 164 | (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\", which | 14 | 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 already | 17 | + 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 Ed25519 | 18 | + 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-alphabet | 26 | (def ^:private b64url-alphabet |
| 47 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") | 27 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") |
| @@ -65,35 +45,26 @@ | |||
| 65 | (nth b64url-alphabet | 45 | (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-bytes | 48 | + |
| 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 key | 50 | ;; ---------------------------------------------------------------- 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 is | 53 | + |
| 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 signs | 64 | "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-key | 70 | (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 as | 76 | "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 | 77 | + 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: for | 81 | 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 | 82 | + Ed25519 the seed *is* the key." |
| 111 | - whole of it." | ||
| 112 | [did] | 83 | [did] |
| 113 | (forget!) | 84 | (forget!) |
| 114 | - (try | 85 | + (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 | - (try | 89 | + :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-bytes | 95 | (defn- sign-bytes |
| 141 | "An Ed25519 signature over `bs`, as `ed25519:<kid>:<b64url>` — the shape | 96 | "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 | ;; ---------------------------------------------------------------- canonical | 103 | ;; ---------------------------------------------------------------- canonical |
| 160 | 104 | ||
| @@ -169,11 +113,13 @@ | |||
| 169 | no space in it. Both ends build this string from the same fields and neither | 113 | 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 unguessable | 128 | 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 | out | 133 | 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-target | 138 | (defn signing-target |
| @@ -212,7 +158,7 @@ | |||
| 212 | [text] | 158 | [text] |
| 213 | (str "sha256:" | 159 | (str "sha256:" |
| 214 | (apply str | 160 | (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. | ||
| 211 | 211 | browser it does not own. That wants an app link or a custom scheme, an |
| 212 | 212 | intent filter, and a redirect URI the broker will accept — a decision about |
| 213 | 213 | 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. | |
| 216 | 223 | 4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** — |
| 217 | 224 | small, and mostly fetch-and-cache. |
| 218 | 225 | 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, an | 211 | 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 about | 212 | 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 the | 214 | +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 per | 225 | 5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per |
modified
flutter/pubspec.lock +33 -1 | @@ -1,6 +1,14 @@ | ||
| 1 | 1 | # Generated by pub |
| 2 | 2 | # See https://dart.dev/tools/pub/glossary#lockfile |
| 3 | 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 | 12 | args: |
| 5 | 13 | dependency: transitive |
| 6 | 14 | description: |
| @@ -57,8 +65,16 @@ packages: | ||
| 57 | 65 | url: "https://pub.dev" |
| 58 | 66 | source: hosted |
| 59 | 67 | version: "1.19.1" |
| 60 | - crypto: | |
| 68 | + convert: | |
| 61 | 69 | 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 | 78 | description: |
| 63 | 79 | name: crypto |
| 64 | 80 | sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf |
| @@ -73,6 +89,14 @@ packages: | ||
| 73 | 89 | url: "https://pub.dev" |
| 74 | 90 | source: hosted |
| 75 | 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 | 100 | fake_async: |
| 77 | 101 | dependency: transitive |
| 78 | 102 | description: |
| @@ -89,6 +113,14 @@ packages: | ||
| 89 | 113 | url: "https://pub.dev" |
| 90 | 114 | source: hosted |
| 91 | 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 | 124 | flutter: |
| 93 | 125 | dependency: "direct main" |
| 94 | 126 | description: flutter |
| @@ -1,6 +1,14 @@ | |||
| 1 | # Generated by pub | 1 | # Generated by pub |
| 2 | # See https://dart.dev/tools/pub/glossary#lockfile | 2 | # 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: transitive | 13 | dependency: transitive |
| 6 | description: | 14 | description: |
| @@ -57,8 +65,16 @@ packages: | |||
| 57 | url: "https://pub.dev" | 65 | url: "https://pub.dev" |
| 58 | source: hosted | 66 | source: hosted |
| 59 | version: "1.19.1" | 67 | version: "1.19.1" |
| 60 | - crypto: | 68 | + convert: |
| 61 | dependency: transitive | 69 | 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: crypto | 79 | name: crypto |
| 64 | sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf | 80 | sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf |
| @@ -73,6 +89,14 @@ packages: | |||
| 73 | url: "https://pub.dev" | 89 | url: "https://pub.dev" |
| 74 | source: hosted | 90 | 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: transitive | 101 | dependency: transitive |
| 78 | description: | 102 | description: |
| @@ -89,6 +113,14 @@ packages: | |||
| 89 | url: "https://pub.dev" | 113 | url: "https://pub.dev" |
| 90 | source: hosted | 114 | 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: flutter | 126 | description: flutter |
modified
flutter/pubspec.yaml +2 -1 | @@ -35,7 +35,8 @@ dependencies: | ||
| 35 | 35 | # Use with the CupertinoIcons class for iOS style icons. |
| 36 | 36 | cupertino_icons: ^1.0.8 |
| 37 | 37 | path_provider: ^2.1.6 |
| 38 | - | |
| 38 | + ed25519_edwards: ^0.3.2 | |
| 39 | + crypto: ^3.0.7 | |
| 39 | 40 | dev_dependencies: |
| 40 | 41 | flutter_test: |
| 41 | 42 | 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.8 | 36 | cupertino_icons: ^1.0.8 |
| 37 | path_provider: ^2.1.6 | 37 | 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: flutter | 42 | 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 @@ | ||
| 23 | 23 | [frq.hiccup :as h] |
| 24 | 24 | [frq.theme :as t] |
| 25 | 25 | [frq.io.dart :as host] |
| 26 | + [frq.crypto.dart :as crypto-dart] | |
| 26 | 27 | [frq.net.dart :as net] |
| 27 | 28 | [frq.atproto.dart :as atproto] |
| 28 | 29 | [frq.clock :as clock] |
| @@ -251,6 +252,11 @@ | ||
| 251 | 252 | " CONTEXT " (.-context details))))) |
| 252 | 253 | (let [dir (.-path (await (pp/getApplicationSupportDirectory)))] |
| 253 | 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 | 260 | ;; What the shared screen calls. The desktop installs frq.state's |
| 255 | 261 | ;; reducers here; this installs the phone's. |
| 256 | 262 | (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's | 260 | ;; 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 @@ | ||
| 15 | 15 | [frq.clock :as clock] |
| 16 | 16 | [frq.emoji :as emoji] |
| 17 | 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 | 21 | [frq.msgsig :as msgsig] |
| 19 | 22 | [frq.avatars :as avatars] |
| 20 | 23 | [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] |