Sign in with a Bluesky identity
freeq verifies a PDS session token against the DID document itself, so the client's whole share of the work is identity resolution and one createSession call: handle → DID → PDS → token, then SASL ATPROTO-CHALLENGE carrying it as method "pds-session" with the server's nonce echoed back. No OAuth broker and no key material. The app password reaches the user's own PDS and nothing else. freeq is handed only the token it mints and checks that token with the same PDS; the password is never written to disk and is dropped once the session exists. HTTPS here is hand-rolled over jolt.mvn-http's TLS bindings, whose `fetch` writes to a file and cannot POST — and base64url is hand-rolled too, rather than java.util.Base64, since the host classes jolt registers are not all present in a cross-compiled boot image. Verified against irc.freeq.at with a deliberately invalid token: CAP LS, REQ, ACK, the challenge, our response, 904, and the connection carrying on as a guest with the refusal on screen. That last part needed join! to stop clearing `error` — the auto-join was wiping the one message the user needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5192124 parent: 4719d6f modified
README.md +23 -3 | @@ -12,7 +12,8 @@ tags rather than immediate-mode drawing code, and state lives in ratoms instead | ||
| 12 | 12 | of an `AppState` struct. |
| 13 | 13 | |
| 14 | 14 | ``` |
| 15 | -src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, JOIN/PRIVMSG/PING | |
| 15 | +src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payload | |
| 16 | +src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | |
| 16 | 17 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 17 | 18 | src/frq/app.jolt the screens |
| 18 | 19 | ``` |
| @@ -35,6 +36,25 @@ plain listener: | ||
| 35 | 36 | cargo run --release --bin freeq-server # in the freeq checkout |
| 36 | 37 | ``` |
| 37 | 38 | |
| 39 | +## Signing in | |
| 40 | + | |
| 41 | +Guest is the default. The **Bluesky** tab on the connect screen takes a handle | |
| 42 | +and an [app password](https://bsky.app/settings/app-passwords) and signs in | |
| 43 | +through AT Protocol: | |
| 44 | + | |
| 45 | +1. `com.atproto.identity.resolveHandle` turns the handle into a DID | |
| 46 | +2. the DID document (PLC directory, or the domain for `did:web`) gives its PDS | |
| 47 | +3. `com.atproto.server.createSession` mints a session token there | |
| 48 | +4. freeq's SASL `ATPROTO-CHALLENGE` carries that token as `method: | |
| 49 | + "pds-session"`, with the server's own nonce echoed back so it cannot be | |
| 50 | + replayed elsewhere | |
| 51 | + | |
| 52 | +The app password goes to the user's own PDS and nowhere else — freeq is handed | |
| 53 | +only the token, and verifies it by asking that same PDS. It is not written to | |
| 54 | +disk, and is dropped from memory once the session exists. | |
| 55 | + | |
| 56 | +A refused sign-in is reported and the connection continues as a guest. | |
| 57 | + | |
| 38 | 58 | ## Android |
| 39 | 59 | |
| 40 | 60 | An APK with two shared libraries and no Java: `libvidya.so` (vidya's Rust/egui |
| @@ -68,7 +88,7 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 68 | 88 | ## Limits |
| 69 | 89 | |
| 70 | 90 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. |
| 71 | -* **Guest identity only.** No AT Protocol SASL, no OAuth, no credential gates, | |
| 72 | - no E2EE — the parts of freeq that need crypto are exactly the parts left out. | |
| 91 | +* **App-password sign-in only.** No OAuth broker, no `did:key` signing, no | |
| 92 | + credential gates, no E2EE. Sign-in needs TLS, so it is desktop-only. | |
| 73 | 93 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 74 | 94 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
| @@ -12,7 +12,8 @@ tags rather than immediate-mode drawing code, and state lives in ratoms instead | |||
| 12 | of an `AppState` struct. | 12 | of an `AppState` struct. |
| 13 | 13 | ||
| 14 | ``` | 14 | ``` |
| 15 | -src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, JOIN/PRIVMSG/PING | 15 | +src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payload |
| 16 | +src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | ||
| 16 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | 17 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 17 | src/frq/app.jolt the screens | 18 | src/frq/app.jolt the screens |
| 18 | ``` | 19 | ``` |
| @@ -35,6 +36,25 @@ plain listener: | |||
| 35 | cargo run --release --bin freeq-server # in the freeq checkout | 36 | cargo run --release --bin freeq-server # in the freeq checkout |
| 36 | ``` | 37 | ``` |
| 37 | 38 | ||
| 39 | +## Signing in | ||
| 40 | + | ||
| 41 | +Guest is the default. The **Bluesky** tab on the connect screen takes a handle | ||
| 42 | +and an [app password](https://bsky.app/settings/app-passwords) and signs in | ||
| 43 | +through AT Protocol: | ||
| 44 | + | ||
| 45 | +1. `com.atproto.identity.resolveHandle` turns the handle into a DID | ||
| 46 | +2. the DID document (PLC directory, or the domain for `did:web`) gives its PDS | ||
| 47 | +3. `com.atproto.server.createSession` mints a session token there | ||
| 48 | +4. freeq's SASL `ATPROTO-CHALLENGE` carries that token as `method: | ||
| 49 | + "pds-session"`, with the server's own nonce echoed back so it cannot be | ||
| 50 | + replayed elsewhere | ||
| 51 | + | ||
| 52 | +The app password goes to the user's own PDS and nowhere else — freeq is handed | ||
| 53 | +only the token, and verifies it by asking that same PDS. It is not written to | ||
| 54 | +disk, and is dropped from memory once the session exists. | ||
| 55 | + | ||
| 56 | +A refused sign-in is reported and the connection continues as a guest. | ||
| 57 | + | ||
| 38 | ## Android | 58 | ## Android |
| 39 | 59 | ||
| 40 | An APK with two shared libraries and no Java: `libvidya.so` (vidya's Rust/egui | 60 | An APK with two shared libraries and no Java: `libvidya.so` (vidya's Rust/egui |
| @@ -68,7 +88,7 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 68 | ## Limits | 88 | ## Limits |
| 69 | 89 | ||
| 70 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. | 90 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. |
| 71 | -* **Guest identity only.** No AT Protocol SASL, no OAuth, no credential gates, | 91 | +* **App-password sign-in only.** No OAuth broker, no `did:key` signing, no |
| 72 | - no E2EE — the parts of freeq that need crypto are exactly the parts left out. | 92 | + credential gates, no E2EE. Sign-in needs TLS, so it is desktop-only. |
| 73 | * **No scrollback trimming, avatars, reactions, threads, or calls.** | 93 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 74 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. | 94 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
modified
src/frq/app.jolt +67 -30 | @@ -27,42 +27,74 @@ | ||
| 27 | 27 | |
| 28 | 28 | ;; ---------------------------------------------------------------- connect |
| 29 | 29 | |
| 30 | +(defn- mode-tabs [] | |
| 31 | + [:hbox {:spacing 8} | |
| 32 | + (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"]]] | |
| 33 | + [:button {:key k | |
| 34 | + :label label | |
| 35 | + :kind (if (= k @s/auth-mode) :primary :default) | |
| 36 | + :on-click #(reset! s/auth-mode k)}])]) | |
| 37 | + | |
| 38 | +(defn- server-fields [] | |
| 39 | + [:vbox {:spacing 6} | |
| 40 | + [:label {:label "Server"}] | |
| 41 | + [:hbox {:spacing 8} | |
| 42 | + [:entry {:text @s/form-host | |
| 43 | + :width-request 220 | |
| 44 | + :placeholder "host" | |
| 45 | + :on-change #(reset! s/form-host %)}] | |
| 46 | + [:entry {:text @s/form-port | |
| 47 | + :width-request 90 | |
| 48 | + :placeholder "6697" | |
| 49 | + :on-change #(reset! s/form-port %)}]] | |
| 50 | + [:checkbutton {:label "TLS" | |
| 51 | + :active @s/form-tls? | |
| 52 | + :on-toggled #(do (swap! s/form-tls? not) | |
| 53 | + (reset! s/form-port | |
| 54 | + (if @s/form-tls? "6697" "6667")))}]]) | |
| 55 | + | |
| 56 | +(defn- connect-action [] | |
| 57 | + (if @s/connecting? | |
| 58 | + [:hbox {:spacing 8} | |
| 59 | + [:spinner {}] | |
| 60 | + [:dim-label {:label @s/status}]] | |
| 61 | + [:hbox {:spacing 8} | |
| 62 | + [:button {:label "Connect" :kind :primary :on-click s/connect!}] | |
| 63 | + [:dim-label {:label @s/status}]])) | |
| 64 | + | |
| 30 | 65 | (defn connect-screen [] |
| 31 | 66 | [:page {:max-width 520} |
| 32 | 67 | [:title {:label "frq"}] |
| 33 | - [:dim-label {:label "freeq client — guest connect over plain TCP."}] | |
| 68 | + [:dim-label {:label "freeq client — guest, or your Bluesky identity."}] | |
| 34 | 69 | [error-note] |
| 35 | 70 | [:card {} |
| 36 | - [:title-2 {:label "Connect as guest"}] | |
| 37 | - [:label {:label "Nick"}] | |
| 38 | - [:entry {:text @s/form-nick | |
| 39 | - :width-request 320 | |
| 40 | - :placeholder "your nick" | |
| 41 | - :on-change #(reset! s/form-nick %)}] | |
| 42 | - [:label {:label "Server"}] | |
| 43 | - [:hbox {:spacing 8} | |
| 44 | - [:entry {:text @s/form-host | |
| 45 | - :width-request 220 | |
| 46 | - :placeholder "host" | |
| 47 | - :on-change #(reset! s/form-host %)}] | |
| 48 | - [:entry {:text @s/form-port | |
| 49 | - :width-request 90 | |
| 50 | - :placeholder "6697" | |
| 51 | - :on-change #(reset! s/form-port %)}]] | |
| 52 | - [:checkbutton {:label "TLS" | |
| 53 | - :active @s/form-tls? | |
| 54 | - :on-toggled #(do (swap! s/form-tls? not) | |
| 55 | - (reset! s/form-port | |
| 56 | - (if @s/form-tls? "6697" "6667")))}] | |
| 71 | + [mode-tabs] | |
| 72 | + (if (= :bluesky @s/auth-mode) | |
| 73 | + [:vbox {:spacing 6} | |
| 74 | + [:title-2 {:label "Sign in with Bluesky"}] | |
| 75 | + [:dim-label {:label "Your app password goes to your own PDS. freeq is handed the session it mints, and checks it with that same PDS."}] | |
| 76 | + [:label {:label "Handle"}] | |
| 77 | + [:entry {:text @s/form-handle | |
| 78 | + :width-request 320 | |
| 79 | + :placeholder "alice.bsky.social" | |
| 80 | + :on-change #(reset! s/form-handle %)}] | |
| 81 | + [:label {:label "App password"}] | |
| 82 | + [:entry {:text @s/form-app-password | |
| 83 | + :width-request 320 | |
| 84 | + :placeholder "xxxx-xxxx-xxxx-xxxx" | |
| 85 | + :on-change #(reset! s/form-app-password %)}] | |
| 86 | + [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]] | |
| 87 | + [:vbox {:spacing 6} | |
| 88 | + [:title-2 {:label "Connect as guest"}] | |
| 89 | + [:label {:label "Nick"}] | |
| 90 | + [:entry {:text @s/form-nick | |
| 91 | + :width-request 320 | |
| 92 | + :placeholder "your nick" | |
| 93 | + :on-change #(reset! s/form-nick %)}]]) | |
| 94 | + [server-fields] | |
| 57 | 95 | [:separator {}] |
| 58 | - (if @s/connecting? | |
| 59 | - [:hbox {:spacing 8} | |
| 60 | - [:spinner {}] | |
| 61 | - [:dim-label {:label @s/status}]] | |
| 62 | - [:hbox {:spacing 8} | |
| 63 | - [:button {:label "Connect" :kind :primary :on-click s/connect!}] | |
| 64 | - [:dim-label {:label @s/status}]])] | |
| 65 | - [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener."}]]) | |
| 96 | + [connect-action]] | |
| 97 | + [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]]) | |
| 66 | 98 | |
| 67 | 99 | ;; ---------------------------------------------------------------- chats |
| 68 | 100 | |
| @@ -169,6 +201,11 @@ | ||
| 169 | 201 | [:status {:label @s/status :live (s/connected?)}] |
| 170 | 202 | [:label {:label (str "Server: " @s/form-host ":" @s/form-port)}] |
| 171 | 203 | [:label {:label (str "Nick: " @s/form-nick)}] |
| 204 | + (if-let [sess @s/session] | |
| 205 | + [:vbox {:spacing 2} | |
| 206 | + [:label {:label (str "Signed in as " (:handle sess))}] | |
| 207 | + [:dim-label {:label (:did sess)}]] | |
| 208 | + [:dim-label {:label "Guest — not signed in."}]) | |
| 172 | 209 | [:separator {}] |
| 173 | 210 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
| 174 | 211 | [:card {} |
| @@ -27,42 +27,74 @@ | |||
| 27 | 27 | ||
| 28 | ;; ---------------------------------------------------------------- connect | 28 | ;; ---------------------------------------------------------------- connect |
| 29 | 29 | ||
| 30 | +(defn- mode-tabs [] | ||
| 31 | + [:hbox {:spacing 8} | ||
| 32 | + (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"]]] | ||
| 33 | + [:button {:key k | ||
| 34 | + :label label | ||
| 35 | + :kind (if (= k @s/auth-mode) :primary :default) | ||
| 36 | + :on-click #(reset! s/auth-mode k)}])]) | ||
| 37 | + | ||
| 38 | +(defn- server-fields [] | ||
| 39 | + [:vbox {:spacing 6} | ||
| 40 | + [:label {:label "Server"}] | ||
| 41 | + [:hbox {:spacing 8} | ||
| 42 | + [:entry {:text @s/form-host | ||
| 43 | + :width-request 220 | ||
| 44 | + :placeholder "host" | ||
| 45 | + :on-change #(reset! s/form-host %)}] | ||
| 46 | + [:entry {:text @s/form-port | ||
| 47 | + :width-request 90 | ||
| 48 | + :placeholder "6697" | ||
| 49 | + :on-change #(reset! s/form-port %)}]] | ||
| 50 | + [:checkbutton {:label "TLS" | ||
| 51 | + :active @s/form-tls? | ||
| 52 | + :on-toggled #(do (swap! s/form-tls? not) | ||
| 53 | + (reset! s/form-port | ||
| 54 | + (if @s/form-tls? "6697" "6667")))}]]) | ||
| 55 | + | ||
| 56 | +(defn- connect-action [] | ||
| 57 | + (if @s/connecting? | ||
| 58 | + [:hbox {:spacing 8} | ||
| 59 | + [:spinner {}] | ||
| 60 | + [:dim-label {:label @s/status}]] | ||
| 61 | + [:hbox {:spacing 8} | ||
| 62 | + [:button {:label "Connect" :kind :primary :on-click s/connect!}] | ||
| 63 | + [:dim-label {:label @s/status}]])) | ||
| 64 | + | ||
| 30 | (defn connect-screen [] | 65 | (defn connect-screen [] |
| 31 | [:page {:max-width 520} | 66 | [:page {:max-width 520} |
| 32 | [:title {:label "frq"}] | 67 | [:title {:label "frq"}] |
| 33 | - [:dim-label {:label "freeq client — guest connect over plain TCP."}] | 68 | + [:dim-label {:label "freeq client — guest, or your Bluesky identity."}] |
| 34 | [error-note] | 69 | [error-note] |
| 35 | [:card {} | 70 | [:card {} |
| 36 | - [:title-2 {:label "Connect as guest"}] | 71 | + [mode-tabs] |
| 37 | - [:label {:label "Nick"}] | 72 | + (if (= :bluesky @s/auth-mode) |
| 38 | - [:entry {:text @s/form-nick | 73 | + [:vbox {:spacing 6} |
| 39 | - :width-request 320 | 74 | + [:title-2 {:label "Sign in with Bluesky"}] |
| 40 | - :placeholder "your nick" | 75 | + [:dim-label {:label "Your app password goes to your own PDS. freeq is handed the session it mints, and checks it with that same PDS."}] |
| 41 | - :on-change #(reset! s/form-nick %)}] | 76 | + [:label {:label "Handle"}] |
| 42 | - [:label {:label "Server"}] | 77 | + [:entry {:text @s/form-handle |
| 43 | - [:hbox {:spacing 8} | 78 | + :width-request 320 |
| 44 | - [:entry {:text @s/form-host | 79 | + :placeholder "alice.bsky.social" |
| 45 | - :width-request 220 | 80 | + :on-change #(reset! s/form-handle %)}] |
| 46 | - :placeholder "host" | 81 | + [:label {:label "App password"}] |
| 47 | - :on-change #(reset! s/form-host %)}] | 82 | + [:entry {:text @s/form-app-password |
| 48 | - [:entry {:text @s/form-port | 83 | + :width-request 320 |
| 49 | - :width-request 90 | 84 | + :placeholder "xxxx-xxxx-xxxx-xxxx" |
| 50 | - :placeholder "6697" | 85 | + :on-change #(reset! s/form-app-password %)}] |
| 51 | - :on-change #(reset! s/form-port %)}]] | 86 | + [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]] |
| 52 | - [:checkbutton {:label "TLS" | 87 | + [:vbox {:spacing 6} |
| 53 | - :active @s/form-tls? | 88 | + [:title-2 {:label "Connect as guest"}] |
| 54 | - :on-toggled #(do (swap! s/form-tls? not) | 89 | + [:label {:label "Nick"}] |
| 55 | - (reset! s/form-port | 90 | + [:entry {:text @s/form-nick |
| 56 | - (if @s/form-tls? "6697" "6667")))}] | 91 | + :width-request 320 |
| 92 | + :placeholder "your nick" | ||
| 93 | + :on-change #(reset! s/form-nick %)}]]) | ||
| 94 | + [server-fields] | ||
| 57 | [:separator {}] | 95 | [:separator {}] |
| 58 | - (if @s/connecting? | 96 | + [connect-action]] |
| 59 | - [:hbox {:spacing 8} | 97 | + [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]]) |
| 60 | - [:spinner {}] | ||
| 61 | - [:dim-label {:label @s/status}]] | ||
| 62 | - [:hbox {:spacing 8} | ||
| 63 | - [:button {:label "Connect" :kind :primary :on-click s/connect!}] | ||
| 64 | - [:dim-label {:label @s/status}]])] | ||
| 65 | - [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener."}]]) | ||
| 66 | 98 | ||
| 67 | ;; ---------------------------------------------------------------- chats | 99 | ;; ---------------------------------------------------------------- chats |
| 68 | 100 | ||
| @@ -169,6 +201,11 @@ | |||
| 169 | [:status {:label @s/status :live (s/connected?)}] | 201 | [:status {:label @s/status :live (s/connected?)}] |
| 170 | [:label {:label (str "Server: " @s/form-host ":" @s/form-port)}] | 202 | [:label {:label (str "Server: " @s/form-host ":" @s/form-port)}] |
| 171 | [:label {:label (str "Nick: " @s/form-nick)}] | 203 | [:label {:label (str "Nick: " @s/form-nick)}] |
| 204 | + (if-let [sess @s/session] | ||
| 205 | + [:vbox {:spacing 2} | ||
| 206 | + [:label {:label (str "Signed in as " (:handle sess))}] | ||
| 207 | + [:dim-label {:label (:did sess)}]] | ||
| 208 | + [:dim-label {:label "Guest — not signed in."}]) | ||
| 172 | [:separator {}] | 209 | [:separator {}] |
| 173 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | 210 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
| 174 | [:card {} | 211 | [:card {} |
added
src/frq/atproto.jolt +172 -0 | new file mode 100644 | ||
| @@ -0,0 +1,172 @@ | ||
| 1 | +(ns frq.atproto | |
| 2 | + "The AT Protocol half of logging in: handle → DID → PDS → session token. | |
| 3 | + | |
| 4 | + freeq's SASL mechanism takes a PDS access token and verifies it against the | |
| 5 | + DID document itself (`method: \"pds-session\"`), so this is all the identity | |
| 6 | + work the client has to do — no OAuth broker, no key material. | |
| 7 | + | |
| 8 | + HTTPS is hand-rolled over jolt.mvn-http's TLS bindings: `fetch` there writes | |
| 9 | + to a file and cannot POST. That also means login is desktop-only, for the | |
| 10 | + same reason TLS is — there is no libssl to load on Android." | |
| 11 | + (:require [clojure.string :as str] | |
| 12 | + [jolt.mvn-http :as tls])) | |
| 13 | + | |
| 14 | +(def directory-host "public.api.bsky.app") | |
| 15 | +(def plc-host "plc.directory") | |
| 16 | + | |
| 17 | +;; ------------------------------------------------------------------ HTTP | |
| 18 | + | |
| 19 | +(defn- read-all! | |
| 20 | + "Drain a TLS connection into a string." | |
| 21 | + [t] | |
| 22 | + (loop [acc ""] | |
| 23 | + (let [b (try (tls/tls-read t) (catch Exception _ nil))] | |
| 24 | + (if (or (nil? b) (zero? (count b))) | |
| 25 | + acc | |
| 26 | + (recur (str acc (String. b))))))) | |
| 27 | + | |
| 28 | +(defn request | |
| 29 | + "One HTTPS request, connection-per-request. Returns the response body. | |
| 30 | + `body` nil makes it a GET." | |
| 31 | + [host path body] | |
| 32 | + (tls/ensure-native!) | |
| 33 | + (let [t (tls/tls-connect host 443) | |
| 34 | + payload (or body "") | |
| 35 | + head (str (if body "POST " "GET ") path " HTTP/1.1\r\n" | |
| 36 | + "Host: " host "\r\n" | |
| 37 | + "User-Agent: frq\r\n" | |
| 38 | + "Accept: application/json\r\n" | |
| 39 | + (when body | |
| 40 | + (str "Content-Type: application/json\r\n" | |
| 41 | + "Content-Length: " (count (.getBytes payload)) "\r\n")) | |
| 42 | + "Connection: close\r\n\r\n")] | |
| 43 | + (try | |
| 44 | + (tls/tls-write t (.getBytes (str head payload))) | |
| 45 | + (let [resp (read-all! t) | |
| 46 | + [_ b] (str/split resp #"\r\n\r\n" 2)] | |
| 47 | + (or b "")) | |
| 48 | + (finally (try (tls/tls-close t) (catch Exception _ nil)))))) | |
| 49 | + | |
| 50 | +;; ------------------------------------------------------------------ JSON | |
| 51 | + | |
| 52 | +(defn json-str | |
| 53 | + "The string value of a top-level JSON field, or nil. | |
| 54 | + | |
| 55 | + Enough of a parser for the four fields this namespace reads. Escapes are | |
| 56 | + passed through unchanged — none of a DID, a handle, a URL or a JWT contains | |
| 57 | + one." | |
| 58 | + [json field] | |
| 59 | + (let [m (re-find (re-pattern (str "\"" field "\"\\s*:\\s*\"([^\"]*)\"")) (or json ""))] | |
| 60 | + (second m))) | |
| 61 | + | |
| 62 | +(defn- json-escape [s] | |
| 63 | + (-> (or s "") | |
| 64 | + (str/replace "\\" "\\\\") | |
| 65 | + (str/replace "\"" "\\\""))) | |
| 66 | + | |
| 67 | +(defn json-object | |
| 68 | + "A flat JSON object from a map of string keys to string values." | |
| 69 | + [m] | |
| 70 | + (str "{" (str/join "," (for [[k v] m] (str "\"" k "\":\"" (json-escape v) "\""))) "}")) | |
| 71 | + | |
| 72 | +;; ------------------------------------------------------------------ identity | |
| 73 | + | |
| 74 | +(defn resolve-handle | |
| 75 | + "A handle (alice.bsky.social) to its DID. A DID passes through untouched." | |
| 76 | + [handle] | |
| 77 | + (let [h (str/trim (or handle ""))] | |
| 78 | + (if (str/starts-with? h "did:") | |
| 79 | + h | |
| 80 | + (let [body (request directory-host | |
| 81 | + (str "/xrpc/com.atproto.identity.resolveHandle?handle=" h) | |
| 82 | + nil)] | |
| 83 | + (or (json-str body "did") | |
| 84 | + (throw (ex-info (str "Could not resolve handle " h) {:handle h :body body}))))))) | |
| 85 | + | |
| 86 | +(defn pds-endpoint | |
| 87 | + "The DID's PDS service endpoint, from its DID document. | |
| 88 | + | |
| 89 | + did:plc documents come from the PLC directory; did:web ones from the domain | |
| 90 | + itself, which is the whole of what did:web means." | |
| 91 | + [did] | |
| 92 | + (let [doc (cond | |
| 93 | + (str/starts-with? did "did:plc:") (request plc-host (str "/" did) nil) | |
| 94 | + (str/starts-with? did "did:web:") | |
| 95 | + (request (subs did (count "did:web:")) "/.well-known/did.json" nil) | |
| 96 | + :else (throw (ex-info (str "Unsupported DID method: " did) {:did did}))) | |
| 97 | + ;; The document lists several services; the PDS is the one whose entry | |
| 98 | + ;; carries a serviceEndpoint next to type AtprotoPersonalDataServer. | |
| 99 | + endpoint (or (second (re-find #"\"AtprotoPersonalDataServer\"\s*,\s*\"serviceEndpoint\"\s*:\s*\"([^\"]*)\"" doc)) | |
| 100 | + (second (re-find #"\"serviceEndpoint\"\s*:\s*\"([^\"]*)\"[^}]*\"AtprotoPersonalDataServer\"" doc)) | |
| 101 | + (json-str doc "serviceEndpoint"))] | |
| 102 | + (or endpoint | |
| 103 | + (throw (ex-info (str "No PDS endpoint for " did) {:did did}))))) | |
| 104 | + | |
| 105 | +(defn- host-of [url] | |
| 106 | + (-> url (str/replace #"^https?://" "") (str/split #"/") first)) | |
| 107 | + | |
| 108 | +(defn create-session | |
| 109 | + "Sign in to the PDS with an app password. Returns | |
| 110 | + {:did :handle :access-jwt :pds}. | |
| 111 | + | |
| 112 | + The password goes to the user's own PDS and nowhere else — freeq never sees | |
| 113 | + it, and verifies the token it gets by asking that same PDS." | |
| 114 | + [identifier password] | |
| 115 | + (let [did (resolve-handle identifier) | |
| 116 | + pds (pds-endpoint did) | |
| 117 | + body (request (host-of pds) | |
| 118 | + "/xrpc/com.atproto.server.createSession" | |
| 119 | + (json-object {"identifier" identifier "password" password})) | |
| 120 | + jwt (json-str body "accessJwt")] | |
| 121 | + (when-not jwt | |
| 122 | + (throw (ex-info (or (json-str body "message") "Sign-in failed") {:body body}))) | |
| 123 | + {:did (or (json-str body "did") did) | |
| 124 | + :handle (or (json-str body "handle") identifier) | |
| 125 | + :access-jwt jwt | |
| 126 | + :pds pds})) | |
| 127 | + | |
| 128 | +;; ------------------------------------------------------------------ base64url | |
| 129 | + | |
| 130 | +(def ^:private alphabet | |
| 131 | + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") | |
| 132 | + | |
| 133 | +(defn b64-encode | |
| 134 | + "base64url of a string, unpadded — what SASL and freeq's challenge use. | |
| 135 | + Hand-rolled rather than java.util.Base64: the host classes jolt registers are | |
| 136 | + not all there in a cross-compiled boot image, and this is three lines." | |
| 137 | + [s] | |
| 138 | + (let [bs (mapv #(bit-and (int %) 0xff) (.getBytes s))] | |
| 139 | + (apply str | |
| 140 | + (for [group (partition-all 3 bs) | |
| 141 | + :let [[a b c] group | |
| 142 | + n (count group) | |
| 143 | + v (+ (bit-shift-left a 16) | |
| 144 | + (bit-shift-left (or b 0) 8) | |
| 145 | + (or c 0))] | |
| 146 | + i (range (inc n))] | |
| 147 | + (nth alphabet (bit-and (bit-shift-right v (* 6 (- 3 i))) 0x3f)))))) | |
| 148 | + | |
| 149 | +(defn b64-decode | |
| 150 | + "base64url back to a string. Padding is tolerated and ignored." | |
| 151 | + [s] | |
| 152 | + (let [idx (into {} (map-indexed (fn [i c] [c i]) alphabet)) | |
| 153 | + vals (keep idx (remove #{\=} (seq (or s "")))) | |
| 154 | + bytes (for [group (partition-all 4 vals) | |
| 155 | + :let [n (count group) | |
| 156 | + v (reduce (fn [acc x] (+ (bit-shift-left acc 6) x)) | |
| 157 | + 0 | |
| 158 | + (concat group (repeat (- 4 n) 0)))] | |
| 159 | + i (range (dec n))] | |
| 160 | + (bit-and (bit-shift-right v (* 8 (- 2 i))) 0xff))] | |
| 161 | + (String. (byte-array (map unchecked-byte bytes))))) | |
| 162 | + | |
| 163 | +(defn sasl-response | |
| 164 | + "The base64url SASL payload for a PDS session: the token, the DID it belongs | |
| 165 | + to, its PDS, and the server's own nonce echoed back so the token cannot be | |
| 166 | + replayed at another server." | |
| 167 | + [session nonce] | |
| 168 | + (b64-encode (json-object {"did" (:did session) | |
| 169 | + "signature" (:access-jwt session) | |
| 170 | + "method" "pds-session" | |
| 171 | + "pds_url" (:pds session) | |
| 172 | + "challenge_nonce" nonce}))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,172 @@ | |||
| 1 | +(ns frq.atproto | ||
| 2 | + "The AT Protocol half of logging in: handle → DID → PDS → session token. | ||
| 3 | + | ||
| 4 | + freeq's SASL mechanism takes a PDS access token and verifies it against the | ||
| 5 | + DID document itself (`method: \"pds-session\"`), so this is all the identity | ||
| 6 | + work the client has to do — no OAuth broker, no key material. | ||
| 7 | + | ||
| 8 | + HTTPS is hand-rolled over jolt.mvn-http's TLS bindings: `fetch` there writes | ||
| 9 | + to a file and cannot POST. That also means login is desktop-only, for the | ||
| 10 | + same reason TLS is — there is no libssl to load on Android." | ||
| 11 | + (:require [clojure.string :as str] | ||
| 12 | + [jolt.mvn-http :as tls])) | ||
| 13 | + | ||
| 14 | +(def directory-host "public.api.bsky.app") | ||
| 15 | +(def plc-host "plc.directory") | ||
| 16 | + | ||
| 17 | +;; ------------------------------------------------------------------ HTTP | ||
| 18 | + | ||
| 19 | +(defn- read-all! | ||
| 20 | + "Drain a TLS connection into a string." | ||
| 21 | + [t] | ||
| 22 | + (loop [acc ""] | ||
| 23 | + (let [b (try (tls/tls-read t) (catch Exception _ nil))] | ||
| 24 | + (if (or (nil? b) (zero? (count b))) | ||
| 25 | + acc | ||
| 26 | + (recur (str acc (String. b))))))) | ||
| 27 | + | ||
| 28 | +(defn request | ||
| 29 | + "One HTTPS request, connection-per-request. Returns the response body. | ||
| 30 | + `body` nil makes it a GET." | ||
| 31 | + [host path body] | ||
| 32 | + (tls/ensure-native!) | ||
| 33 | + (let [t (tls/tls-connect host 443) | ||
| 34 | + payload (or body "") | ||
| 35 | + head (str (if body "POST " "GET ") path " HTTP/1.1\r\n" | ||
| 36 | + "Host: " host "\r\n" | ||
| 37 | + "User-Agent: frq\r\n" | ||
| 38 | + "Accept: application/json\r\n" | ||
| 39 | + (when body | ||
| 40 | + (str "Content-Type: application/json\r\n" | ||
| 41 | + "Content-Length: " (count (.getBytes payload)) "\r\n")) | ||
| 42 | + "Connection: close\r\n\r\n")] | ||
| 43 | + (try | ||
| 44 | + (tls/tls-write t (.getBytes (str head payload))) | ||
| 45 | + (let [resp (read-all! t) | ||
| 46 | + [_ b] (str/split resp #"\r\n\r\n" 2)] | ||
| 47 | + (or b "")) | ||
| 48 | + (finally (try (tls/tls-close t) (catch Exception _ nil)))))) | ||
| 49 | + | ||
| 50 | +;; ------------------------------------------------------------------ JSON | ||
| 51 | + | ||
| 52 | +(defn json-str | ||
| 53 | + "The string value of a top-level JSON field, or nil. | ||
| 54 | + | ||
| 55 | + Enough of a parser for the four fields this namespace reads. Escapes are | ||
| 56 | + passed through unchanged — none of a DID, a handle, a URL or a JWT contains | ||
| 57 | + one." | ||
| 58 | + [json field] | ||
| 59 | + (let [m (re-find (re-pattern (str "\"" field "\"\\s*:\\s*\"([^\"]*)\"")) (or json ""))] | ||
| 60 | + (second m))) | ||
| 61 | + | ||
| 62 | +(defn- json-escape [s] | ||
| 63 | + (-> (or s "") | ||
| 64 | + (str/replace "\\" "\\\\") | ||
| 65 | + (str/replace "\"" "\\\""))) | ||
| 66 | + | ||
| 67 | +(defn json-object | ||
| 68 | + "A flat JSON object from a map of string keys to string values." | ||
| 69 | + [m] | ||
| 70 | + (str "{" (str/join "," (for [[k v] m] (str "\"" k "\":\"" (json-escape v) "\""))) "}")) | ||
| 71 | + | ||
| 72 | +;; ------------------------------------------------------------------ identity | ||
| 73 | + | ||
| 74 | +(defn resolve-handle | ||
| 75 | + "A handle (alice.bsky.social) to its DID. A DID passes through untouched." | ||
| 76 | + [handle] | ||
| 77 | + (let [h (str/trim (or handle ""))] | ||
| 78 | + (if (str/starts-with? h "did:") | ||
| 79 | + h | ||
| 80 | + (let [body (request directory-host | ||
| 81 | + (str "/xrpc/com.atproto.identity.resolveHandle?handle=" h) | ||
| 82 | + nil)] | ||
| 83 | + (or (json-str body "did") | ||
| 84 | + (throw (ex-info (str "Could not resolve handle " h) {:handle h :body body}))))))) | ||
| 85 | + | ||
| 86 | +(defn pds-endpoint | ||
| 87 | + "The DID's PDS service endpoint, from its DID document. | ||
| 88 | + | ||
| 89 | + did:plc documents come from the PLC directory; did:web ones from the domain | ||
| 90 | + itself, which is the whole of what did:web means." | ||
| 91 | + [did] | ||
| 92 | + (let [doc (cond | ||
| 93 | + (str/starts-with? did "did:plc:") (request plc-host (str "/" did) nil) | ||
| 94 | + (str/starts-with? did "did:web:") | ||
| 95 | + (request (subs did (count "did:web:")) "/.well-known/did.json" nil) | ||
| 96 | + :else (throw (ex-info (str "Unsupported DID method: " did) {:did did}))) | ||
| 97 | + ;; The document lists several services; the PDS is the one whose entry | ||
| 98 | + ;; carries a serviceEndpoint next to type AtprotoPersonalDataServer. | ||
| 99 | + endpoint (or (second (re-find #"\"AtprotoPersonalDataServer\"\s*,\s*\"serviceEndpoint\"\s*:\s*\"([^\"]*)\"" doc)) | ||
| 100 | + (second (re-find #"\"serviceEndpoint\"\s*:\s*\"([^\"]*)\"[^}]*\"AtprotoPersonalDataServer\"" doc)) | ||
| 101 | + (json-str doc "serviceEndpoint"))] | ||
| 102 | + (or endpoint | ||
| 103 | + (throw (ex-info (str "No PDS endpoint for " did) {:did did}))))) | ||
| 104 | + | ||
| 105 | +(defn- host-of [url] | ||
| 106 | + (-> url (str/replace #"^https?://" "") (str/split #"/") first)) | ||
| 107 | + | ||
| 108 | +(defn create-session | ||
| 109 | + "Sign in to the PDS with an app password. Returns | ||
| 110 | + {:did :handle :access-jwt :pds}. | ||
| 111 | + | ||
| 112 | + The password goes to the user's own PDS and nowhere else — freeq never sees | ||
| 113 | + it, and verifies the token it gets by asking that same PDS." | ||
| 114 | + [identifier password] | ||
| 115 | + (let [did (resolve-handle identifier) | ||
| 116 | + pds (pds-endpoint did) | ||
| 117 | + body (request (host-of pds) | ||
| 118 | + "/xrpc/com.atproto.server.createSession" | ||
| 119 | + (json-object {"identifier" identifier "password" password})) | ||
| 120 | + jwt (json-str body "accessJwt")] | ||
| 121 | + (when-not jwt | ||
| 122 | + (throw (ex-info (or (json-str body "message") "Sign-in failed") {:body body}))) | ||
| 123 | + {:did (or (json-str body "did") did) | ||
| 124 | + :handle (or (json-str body "handle") identifier) | ||
| 125 | + :access-jwt jwt | ||
| 126 | + :pds pds})) | ||
| 127 | + | ||
| 128 | +;; ------------------------------------------------------------------ base64url | ||
| 129 | + | ||
| 130 | +(def ^:private alphabet | ||
| 131 | + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") | ||
| 132 | + | ||
| 133 | +(defn b64-encode | ||
| 134 | + "base64url of a string, unpadded — what SASL and freeq's challenge use. | ||
| 135 | + Hand-rolled rather than java.util.Base64: the host classes jolt registers are | ||
| 136 | + not all there in a cross-compiled boot image, and this is three lines." | ||
| 137 | + [s] | ||
| 138 | + (let [bs (mapv #(bit-and (int %) 0xff) (.getBytes s))] | ||
| 139 | + (apply str | ||
| 140 | + (for [group (partition-all 3 bs) | ||
| 141 | + :let [[a b c] group | ||
| 142 | + n (count group) | ||
| 143 | + v (+ (bit-shift-left a 16) | ||
| 144 | + (bit-shift-left (or b 0) 8) | ||
| 145 | + (or c 0))] | ||
| 146 | + i (range (inc n))] | ||
| 147 | + (nth alphabet (bit-and (bit-shift-right v (* 6 (- 3 i))) 0x3f)))))) | ||
| 148 | + | ||
| 149 | +(defn b64-decode | ||
| 150 | + "base64url back to a string. Padding is tolerated and ignored." | ||
| 151 | + [s] | ||
| 152 | + (let [idx (into {} (map-indexed (fn [i c] [c i]) alphabet)) | ||
| 153 | + vals (keep idx (remove #{\=} (seq (or s "")))) | ||
| 154 | + bytes (for [group (partition-all 4 vals) | ||
| 155 | + :let [n (count group) | ||
| 156 | + v (reduce (fn [acc x] (+ (bit-shift-left acc 6) x)) | ||
| 157 | + 0 | ||
| 158 | + (concat group (repeat (- 4 n) 0)))] | ||
| 159 | + i (range (dec n))] | ||
| 160 | + (bit-and (bit-shift-right v (* 8 (- 2 i))) 0xff))] | ||
| 161 | + (String. (byte-array (map unchecked-byte bytes))))) | ||
| 162 | + | ||
| 163 | +(defn sasl-response | ||
| 164 | + "The base64url SASL payload for a PDS session: the token, the DID it belongs | ||
| 165 | + to, its PDS, and the server's own nonce echoed back so the token cannot be | ||
| 166 | + replayed at another server." | ||
| 167 | + [session nonce] | ||
| 168 | + (b64-encode (json-object {"did" (:did session) | ||
| 169 | + "signature" (:access-jwt session) | ||
| 170 | + "method" "pds-session" | ||
| 171 | + "pds_url" (:pds session) | ||
| 172 | + "challenge_nonce" nonce}))) | ||
modified
src/frq/irc.jolt +59 -5 | @@ -15,6 +15,7 @@ | ||
| 15 | 15 | `connect!` takes an `on-msg` fn and returns a connection map that |
| 16 | 16 | `send-line!` and `close!` accept." |
| 17 | 17 | (:require [clojure.string :as str] |
| 18 | + [frq.atproto :as atproto] | |
| 18 | 19 | [jolt.ffi :as ffi] |
| 19 | 20 | [jolt.mvn-http :as tls] |
| 20 | 21 | [jolt.socket :as socket])) |
| @@ -122,16 +123,69 @@ | ||
| 122 | 123 | {:kind :plain :fd fd :buf (ffi/alloc buffer-size) |
| 123 | 124 | :lock (Object.) :nick nick}))) |
| 124 | 125 | |
| 126 | +(def ^:private sasl-chunk 400) | |
| 127 | + | |
| 128 | +(defn- authenticate! | |
| 129 | + "AUTHENTICATE takes at most 400 characters a line; a payload that lands on | |
| 130 | + the boundary is followed by a bare `+` so the server knows it ended." | |
| 131 | + [conn payload] | |
| 132 | + (loop [rest payload] | |
| 133 | + (if (> (count rest) sasl-chunk) | |
| 134 | + (do (send-line! conn (str "AUTHENTICATE " (subs rest 0 sasl-chunk))) | |
| 135 | + (recur (subs rest sasl-chunk))) | |
| 136 | + (do (send-line! conn (str "AUTHENTICATE " rest)) | |
| 137 | + (when (= sasl-chunk (count rest)) | |
| 138 | + (send-line! conn "AUTHENTICATE +")))))) | |
| 139 | + | |
| 140 | +(defn- sasl-step! | |
| 141 | + "Drive the SASL exchange from the lines the server sends during registration. | |
| 142 | + Returns the message unchanged, so the caller can go on handling it." | |
| 143 | + [conn session msg] | |
| 144 | + (let [{:keys [command params]} msg] | |
| 145 | + (case command | |
| 146 | + "CAP" (let [[_ sub caps] params] | |
| 147 | + (case sub | |
| 148 | + "LS" (if (str/includes? (or caps "") "sasl") | |
| 149 | + (send-line! conn "CAP REQ :sasl") | |
| 150 | + (send-line! conn "CAP END")) | |
| 151 | + "ACK" (if (str/includes? (or caps "") "sasl") | |
| 152 | + (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") | |
| 153 | + (send-line! conn "CAP END")) | |
| 154 | + "NAK" (send-line! conn "CAP END") | |
| 155 | + nil)) | |
| 156 | + ;; The challenge arrives as base64url JSON; the nonce inside it is what | |
| 157 | + ;; binds our PDS token to this connection. | |
| 158 | + "AUTHENTICATE" (let [challenge (first params)] | |
| 159 | + (when (and challenge (not= "+" challenge)) | |
| 160 | + (let [nonce (atproto/json-str | |
| 161 | + (atproto/b64-decode challenge) "nonce")] | |
| 162 | + (authenticate! conn (atproto/sasl-response session nonce))))) | |
| 163 | + ;; 903 logged in, 904/905/906 did not. | |
| 164 | + ("903" "904" "905" "906") (send-line! conn "CAP END") | |
| 165 | + nil)) | |
| 166 | + msg) | |
| 167 | + | |
| 125 | 168 | (defn connect! |
| 126 | - "Open a connection, register as a guest `nick`, and start the reader. | |
| 127 | - `tls?` defaults to true — freeq's TLS listener is :6697, plain is :6667." | |
| 128 | - ([host port nick on-msg] (connect! host port nick on-msg true)) | |
| 129 | - ([host port nick on-msg tls?] | |
| 130 | - (let [conn (open host port tls? nick)] | |
| 169 | + "Open a connection, register `nick`, and start the reader. | |
| 170 | + | |
| 171 | + With a `session` from `frq.atproto/create-session` the registration runs the | |
| 172 | + SASL exchange first and the connection is bound to that DID; without one it | |
| 173 | + is an ordinary guest. `tls?` defaults to true — freeq's TLS listener is | |
| 174 | + :6697, plain is :6667." | |
| 175 | + ([host port nick on-msg] (connect! host port nick on-msg true nil)) | |
| 176 | + ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil)) | |
| 177 | + ([host port nick on-msg tls? session] | |
| 178 | + (let [conn (open host port tls? nick) | |
| 179 | + on-msg (if session | |
| 180 | + (fn [msg] (on-msg (sasl-step! conn session msg))) | |
| 181 | + on-msg)] | |
| 131 | 182 | (future |
| 132 | 183 | (try (reader-loop! conn on-msg) |
| 133 | 184 | (catch Exception e |
| 134 | 185 | (on-msg {:command "*ERROR*" :params [(str e)]})))) |
| 186 | + ;; CAP first when authenticating: registration waits for CAP END, which | |
| 187 | + ;; the SASL exchange sends once it has an answer either way. | |
| 188 | + (when session (send-line! conn "CAP LS 302")) | |
| 135 | 189 | (send-line! conn (str "NICK " nick)) |
| 136 | 190 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
| 137 | 191 | conn))) |
| @@ -15,6 +15,7 @@ | |||
| 15 | `connect!` takes an `on-msg` fn and returns a connection map that | 15 | `connect!` takes an `on-msg` fn and returns a connection map that |
| 16 | `send-line!` and `close!` accept." | 16 | `send-line!` and `close!` accept." |
| 17 | (:require [clojure.string :as str] | 17 | (:require [clojure.string :as str] |
| 18 | + [frq.atproto :as atproto] | ||
| 18 | [jolt.ffi :as ffi] | 19 | [jolt.ffi :as ffi] |
| 19 | [jolt.mvn-http :as tls] | 20 | [jolt.mvn-http :as tls] |
| 20 | [jolt.socket :as socket])) | 21 | [jolt.socket :as socket])) |
| @@ -122,16 +123,69 @@ | |||
| 122 | {:kind :plain :fd fd :buf (ffi/alloc buffer-size) | 123 | {:kind :plain :fd fd :buf (ffi/alloc buffer-size) |
| 123 | :lock (Object.) :nick nick}))) | 124 | :lock (Object.) :nick nick}))) |
| 124 | 125 | ||
| 126 | +(def ^:private sasl-chunk 400) | ||
| 127 | + | ||
| 128 | +(defn- authenticate! | ||
| 129 | + "AUTHENTICATE takes at most 400 characters a line; a payload that lands on | ||
| 130 | + the boundary is followed by a bare `+` so the server knows it ended." | ||
| 131 | + [conn payload] | ||
| 132 | + (loop [rest payload] | ||
| 133 | + (if (> (count rest) sasl-chunk) | ||
| 134 | + (do (send-line! conn (str "AUTHENTICATE " (subs rest 0 sasl-chunk))) | ||
| 135 | + (recur (subs rest sasl-chunk))) | ||
| 136 | + (do (send-line! conn (str "AUTHENTICATE " rest)) | ||
| 137 | + (when (= sasl-chunk (count rest)) | ||
| 138 | + (send-line! conn "AUTHENTICATE +")))))) | ||
| 139 | + | ||
| 140 | +(defn- sasl-step! | ||
| 141 | + "Drive the SASL exchange from the lines the server sends during registration. | ||
| 142 | + Returns the message unchanged, so the caller can go on handling it." | ||
| 143 | + [conn session msg] | ||
| 144 | + (let [{:keys [command params]} msg] | ||
| 145 | + (case command | ||
| 146 | + "CAP" (let [[_ sub caps] params] | ||
| 147 | + (case sub | ||
| 148 | + "LS" (if (str/includes? (or caps "") "sasl") | ||
| 149 | + (send-line! conn "CAP REQ :sasl") | ||
| 150 | + (send-line! conn "CAP END")) | ||
| 151 | + "ACK" (if (str/includes? (or caps "") "sasl") | ||
| 152 | + (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") | ||
| 153 | + (send-line! conn "CAP END")) | ||
| 154 | + "NAK" (send-line! conn "CAP END") | ||
| 155 | + nil)) | ||
| 156 | + ;; The challenge arrives as base64url JSON; the nonce inside it is what | ||
| 157 | + ;; binds our PDS token to this connection. | ||
| 158 | + "AUTHENTICATE" (let [challenge (first params)] | ||
| 159 | + (when (and challenge (not= "+" challenge)) | ||
| 160 | + (let [nonce (atproto/json-str | ||
| 161 | + (atproto/b64-decode challenge) "nonce")] | ||
| 162 | + (authenticate! conn (atproto/sasl-response session nonce))))) | ||
| 163 | + ;; 903 logged in, 904/905/906 did not. | ||
| 164 | + ("903" "904" "905" "906") (send-line! conn "CAP END") | ||
| 165 | + nil)) | ||
| 166 | + msg) | ||
| 167 | + | ||
| 125 | (defn connect! | 168 | (defn connect! |
| 126 | - "Open a connection, register as a guest `nick`, and start the reader. | 169 | + "Open a connection, register `nick`, and start the reader. |
| 127 | - `tls?` defaults to true — freeq's TLS listener is :6697, plain is :6667." | 170 | + |
| 128 | - ([host port nick on-msg] (connect! host port nick on-msg true)) | 171 | + With a `session` from `frq.atproto/create-session` the registration runs the |
| 129 | - ([host port nick on-msg tls?] | 172 | + SASL exchange first and the connection is bound to that DID; without one it |
| 130 | - (let [conn (open host port tls? nick)] | 173 | + is an ordinary guest. `tls?` defaults to true — freeq's TLS listener is |
| 174 | + :6697, plain is :6667." | ||
| 175 | + ([host port nick on-msg] (connect! host port nick on-msg true nil)) | ||
| 176 | + ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil)) | ||
| 177 | + ([host port nick on-msg tls? session] | ||
| 178 | + (let [conn (open host port tls? nick) | ||
| 179 | + on-msg (if session | ||
| 180 | + (fn [msg] (on-msg (sasl-step! conn session msg))) | ||
| 181 | + on-msg)] | ||
| 131 | (future | 182 | (future |
| 132 | (try (reader-loop! conn on-msg) | 183 | (try (reader-loop! conn on-msg) |
| 133 | (catch Exception e | 184 | (catch Exception e |
| 134 | (on-msg {:command "*ERROR*" :params [(str e)]})))) | 185 | (on-msg {:command "*ERROR*" :params [(str e)]})))) |
| 186 | + ;; CAP first when authenticating: registration waits for CAP END, which | ||
| 187 | + ;; the SASL exchange sends once it has an answer either way. | ||
| 188 | + (when session (send-line! conn "CAP LS 302")) | ||
| 135 | (send-line! conn (str "NICK " nick)) | 189 | (send-line! conn (str "NICK " nick)) |
| 136 | (send-line! conn (str "USER " nick " 0 * :" nick)) | 190 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
| 137 | conn))) | 191 | conn))) |
modified
src/frq/state.jolt +39 -7 | @@ -6,6 +6,7 @@ | ||
| 6 | 6 | is the only place a wire message turns into UI state." |
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | + [frq.atproto :as atproto] | |
| 9 | 10 | [frq.irc :as irc])) |
| 10 | 11 | |
| 11 | 12 | (def default-host "irc.freeq.at") |
| @@ -32,6 +33,14 @@ | ||
| 32 | 33 | (defonce form-tls? (atom true)) |
| 33 | 34 | (defonce form-nick (atom "frq-guest")) |
| 34 | 35 | |
| 36 | +;; Bluesky sign-in. The app password reaches the user's own PDS and nothing | |
| 37 | +;; else: freeq is handed the session token it mints, and verifies that token by | |
| 38 | +;; asking the same PDS. It is never written to disk. | |
| 39 | +(defonce auth-mode (atom :guest)) ; :guest | :bluesky | |
| 40 | +(defonce form-handle (atom "")) | |
| 41 | +(defonce form-app-password (atom "")) | |
| 42 | +(defonce session (atom nil)) ; {:did :handle :access-jwt :pds} | |
| 43 | + | |
| 35 | 44 | ;; joined as soon as the server sends 001 |
| 36 | 45 | (def auto-join "#test") |
| 37 | 46 | |
| @@ -88,7 +97,9 @@ | ||
| 88 | 97 | (let [{:keys [command params prefix]} msg |
| 89 | 98 | from (irc/nick-of prefix)] |
| 90 | 99 | (case command |
| 91 | - "001" (do (reset! status (str "Connected as " @form-nick)) | |
| 100 | + "001" (do (reset! status (if @session | |
| 101 | + (str "Connected as " (:handle @session)) | |
| 102 | + (str "Connected as " @form-nick))) | |
| 92 | 103 | (reset! connecting? false) |
| 93 | 104 | (reset! screen :chats) |
| 94 | 105 | (join! auto-join)) |
| @@ -112,6 +123,10 @@ | ||
| 112 | 123 | (reset! status (or (last params) @status)) |
| 113 | 124 | ("473" "474" "475" "477" "403" "471") |
| 114 | 125 | (reset! error (str "Cannot join: " (last params))) |
| 126 | + "903" (reset! status (str "Signed in as " (:handle @session))) | |
| 127 | + ("904" "905" "906") (do (reset! session nil) | |
| 128 | + (reset! error (str "Bluesky sign-in refused: " | |
| 129 | + (or (last params) "no reason given")))) | |
| 115 | 130 | "*DISCONNECTED*" (do (reset! conn nil) |
| 116 | 131 | (reset! connecting? false) |
| 117 | 132 | (swap! channels |
| @@ -132,11 +147,11 @@ | ||
| 132 | 147 | data (ex-data e)] |
| 133 | 148 | (str (type e) ": " msg (when data (str " " (pr-str data)))))) |
| 134 | 149 | |
| 135 | -(defn- dial! [host port nick tls?] | |
| 150 | +(defn- dial! [host port nick tls? sess] | |
| 136 | 151 | ;; stderr is the only console on Android — this line lands in logcat. |
| 137 | 152 | (binding [*out* *err*] |
| 138 | 153 | (println "frq: dialing" host port (if tls? "tls" "plain"))) |
| 139 | - (reset! conn (irc/connect! host port nick apply-msg! tls?))) | |
| 154 | + (reset! conn (irc/connect! host port nick apply-msg! tls? sess))) | |
| 140 | 155 | |
| 141 | 156 | (defn connect! |
| 142 | 157 | "Dial, and fall back to the plain listener when TLS cannot be had. Android is |
| @@ -148,15 +163,29 @@ | ||
| 148 | 163 | (reset! status (str "Connecting to " @form-host ":" @form-port "…")) |
| 149 | 164 | (let [host @form-host |
| 150 | 165 | port (parse-long (str/trim @form-port)) |
| 151 | - nick (str/trim @form-nick)] | |
| 166 | + bluesky? (= :bluesky @auth-mode) | |
| 167 | + sess (when bluesky? | |
| 168 | + (reset! status (str "Signing in as " (str/trim @form-handle) "…")) | |
| 169 | + (let [s (atproto/create-session (str/trim @form-handle) | |
| 170 | + @form-app-password)] | |
| 171 | + (reset! session s) | |
| 172 | + ;; The password did its work at the PDS; do not keep it. | |
| 173 | + (reset! form-app-password "") | |
| 174 | + s)) | |
| 175 | + ;; An authenticated connection still needs a nick — the DID is the | |
| 176 | + ;; identity, the nick is only what the channel calls you. | |
| 177 | + nick (if bluesky? | |
| 178 | + (-> (:handle sess) (str/split #"\.") first) | |
| 179 | + (str/trim @form-nick))] | |
| 180 | + (when bluesky? (reset! form-nick nick)) | |
| 152 | 181 | (try |
| 153 | - (dial! host port nick @form-tls?) | |
| 182 | + (dial! host port nick @form-tls? sess) | |
| 154 | 183 | (catch Exception e |
| 155 | 184 | (binding [*out* *err*] (println "frq: dial failed:" (describe e))) |
| 156 | 185 | (if @form-tls? |
| 157 | 186 | (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…")) |
| 158 | 187 | (try |
| 159 | - (dial! host plain-port nick false) | |
| 188 | + (dial! host plain-port nick false sess) | |
| 160 | 189 | (reset! form-tls? false) |
| 161 | 190 | (reset! form-port (str plain-port)) |
| 162 | 191 | (catch Exception e2 |
| @@ -173,6 +202,7 @@ | ||
| 173 | 202 | (defn disconnect! [] |
| 174 | 203 | (when-let [c @conn] (irc/close! c)) |
| 175 | 204 | (reset! conn nil) |
| 205 | + (reset! session nil) | |
| 176 | 206 | ;; The buffers survive, the memberships do not — leaving `joined?` set would |
| 177 | 207 | ;; have the next Open show a channel nobody is in. |
| 178 | 208 | (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %)) |
| @@ -180,9 +210,11 @@ | ||
| 180 | 210 | (reset! screen :connect)) |
| 181 | 211 | |
| 182 | 212 | (defn join! [name] |
| 213 | + ;; Deliberately not clearing `error` here: joining is what follows a | |
| 214 | + ;; successful registration, and a SASL refusal that arrived moments earlier | |
| 215 | + ;; is the one thing the user most needs to still be on screen. | |
| 183 | 216 | (let [ch (normalize-channel name)] |
| 184 | 217 | (when (seq ch) |
| 185 | - (reset! error nil) | |
| 186 | 218 | (open-channel! ch)))) |
| 187 | 219 | |
| 188 | 220 | (defn send-draft! [] |
| @@ -6,6 +6,7 @@ | |||
| 6 | is the only place a wire message turns into UI state." | 6 | is the only place a wire message turns into UI state." |
| 7 | (:require [clojure.string :as str] | 7 | (:require [clojure.string :as str] |
| 8 | [glimmer.ratom :as r :refer [atom]] | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | + [frq.atproto :as atproto] | ||
| 9 | [frq.irc :as irc])) | 10 | [frq.irc :as irc])) |
| 10 | 11 | ||
| 11 | (def default-host "irc.freeq.at") | 12 | (def default-host "irc.freeq.at") |
| @@ -32,6 +33,14 @@ | |||
| 32 | (defonce form-tls? (atom true)) | 33 | (defonce form-tls? (atom true)) |
| 33 | (defonce form-nick (atom "frq-guest")) | 34 | (defonce form-nick (atom "frq-guest")) |
| 34 | 35 | ||
| 36 | +;; Bluesky sign-in. The app password reaches the user's own PDS and nothing | ||
| 37 | +;; else: freeq is handed the session token it mints, and verifies that token by | ||
| 38 | +;; asking the same PDS. It is never written to disk. | ||
| 39 | +(defonce auth-mode (atom :guest)) ; :guest | :bluesky | ||
| 40 | +(defonce form-handle (atom "")) | ||
| 41 | +(defonce form-app-password (atom "")) | ||
| 42 | +(defonce session (atom nil)) ; {:did :handle :access-jwt :pds} | ||
| 43 | + | ||
| 35 | ;; joined as soon as the server sends 001 | 44 | ;; joined as soon as the server sends 001 |
| 36 | (def auto-join "#test") | 45 | (def auto-join "#test") |
| 37 | 46 | ||
| @@ -88,7 +97,9 @@ | |||
| 88 | (let [{:keys [command params prefix]} msg | 97 | (let [{:keys [command params prefix]} msg |
| 89 | from (irc/nick-of prefix)] | 98 | from (irc/nick-of prefix)] |
| 90 | (case command | 99 | (case command |
| 91 | - "001" (do (reset! status (str "Connected as " @form-nick)) | 100 | + "001" (do (reset! status (if @session |
| 101 | + (str "Connected as " (:handle @session)) | ||
| 102 | + (str "Connected as " @form-nick))) | ||
| 92 | (reset! connecting? false) | 103 | (reset! connecting? false) |
| 93 | (reset! screen :chats) | 104 | (reset! screen :chats) |
| 94 | (join! auto-join)) | 105 | (join! auto-join)) |
| @@ -112,6 +123,10 @@ | |||
| 112 | (reset! status (or (last params) @status)) | 123 | (reset! status (or (last params) @status)) |
| 113 | ("473" "474" "475" "477" "403" "471") | 124 | ("473" "474" "475" "477" "403" "471") |
| 114 | (reset! error (str "Cannot join: " (last params))) | 125 | (reset! error (str "Cannot join: " (last params))) |
| 126 | + "903" (reset! status (str "Signed in as " (:handle @session))) | ||
| 127 | + ("904" "905" "906") (do (reset! session nil) | ||
| 128 | + (reset! error (str "Bluesky sign-in refused: " | ||
| 129 | + (or (last params) "no reason given")))) | ||
| 115 | "*DISCONNECTED*" (do (reset! conn nil) | 130 | "*DISCONNECTED*" (do (reset! conn nil) |
| 116 | (reset! connecting? false) | 131 | (reset! connecting? false) |
| 117 | (swap! channels | 132 | (swap! channels |
| @@ -132,11 +147,11 @@ | |||
| 132 | data (ex-data e)] | 147 | data (ex-data e)] |
| 133 | (str (type e) ": " msg (when data (str " " (pr-str data)))))) | 148 | (str (type e) ": " msg (when data (str " " (pr-str data)))))) |
| 134 | 149 | ||
| 135 | -(defn- dial! [host port nick tls?] | 150 | +(defn- dial! [host port nick tls? sess] |
| 136 | ;; stderr is the only console on Android — this line lands in logcat. | 151 | ;; stderr is the only console on Android — this line lands in logcat. |
| 137 | (binding [*out* *err*] | 152 | (binding [*out* *err*] |
| 138 | (println "frq: dialing" host port (if tls? "tls" "plain"))) | 153 | (println "frq: dialing" host port (if tls? "tls" "plain"))) |
| 139 | - (reset! conn (irc/connect! host port nick apply-msg! tls?))) | 154 | + (reset! conn (irc/connect! host port nick apply-msg! tls? sess))) |
| 140 | 155 | ||
| 141 | (defn connect! | 156 | (defn connect! |
| 142 | "Dial, and fall back to the plain listener when TLS cannot be had. Android is | 157 | "Dial, and fall back to the plain listener when TLS cannot be had. Android is |
| @@ -148,15 +163,29 @@ | |||
| 148 | (reset! status (str "Connecting to " @form-host ":" @form-port "…")) | 163 | (reset! status (str "Connecting to " @form-host ":" @form-port "…")) |
| 149 | (let [host @form-host | 164 | (let [host @form-host |
| 150 | port (parse-long (str/trim @form-port)) | 165 | port (parse-long (str/trim @form-port)) |
| 151 | - nick (str/trim @form-nick)] | 166 | + bluesky? (= :bluesky @auth-mode) |
| 167 | + sess (when bluesky? | ||
| 168 | + (reset! status (str "Signing in as " (str/trim @form-handle) "…")) | ||
| 169 | + (let [s (atproto/create-session (str/trim @form-handle) | ||
| 170 | + @form-app-password)] | ||
| 171 | + (reset! session s) | ||
| 172 | + ;; The password did its work at the PDS; do not keep it. | ||
| 173 | + (reset! form-app-password "") | ||
| 174 | + s)) | ||
| 175 | + ;; An authenticated connection still needs a nick — the DID is the | ||
| 176 | + ;; identity, the nick is only what the channel calls you. | ||
| 177 | + nick (if bluesky? | ||
| 178 | + (-> (:handle sess) (str/split #"\.") first) | ||
| 179 | + (str/trim @form-nick))] | ||
| 180 | + (when bluesky? (reset! form-nick nick)) | ||
| 152 | (try | 181 | (try |
| 153 | - (dial! host port nick @form-tls?) | 182 | + (dial! host port nick @form-tls? sess) |
| 154 | (catch Exception e | 183 | (catch Exception e |
| 155 | (binding [*out* *err*] (println "frq: dial failed:" (describe e))) | 184 | (binding [*out* *err*] (println "frq: dial failed:" (describe e))) |
| 156 | (if @form-tls? | 185 | (if @form-tls? |
| 157 | (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…")) | 186 | (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…")) |
| 158 | (try | 187 | (try |
| 159 | - (dial! host plain-port nick false) | 188 | + (dial! host plain-port nick false sess) |
| 160 | (reset! form-tls? false) | 189 | (reset! form-tls? false) |
| 161 | (reset! form-port (str plain-port)) | 190 | (reset! form-port (str plain-port)) |
| 162 | (catch Exception e2 | 191 | (catch Exception e2 |
| @@ -173,6 +202,7 @@ | |||
| 173 | (defn disconnect! [] | 202 | (defn disconnect! [] |
| 174 | (when-let [c @conn] (irc/close! c)) | 203 | (when-let [c @conn] (irc/close! c)) |
| 175 | (reset! conn nil) | 204 | (reset! conn nil) |
| 205 | + (reset! session nil) | ||
| 176 | ;; The buffers survive, the memberships do not — leaving `joined?` set would | 206 | ;; The buffers survive, the memberships do not — leaving `joined?` set would |
| 177 | ;; have the next Open show a channel nobody is in. | 207 | ;; have the next Open show a channel nobody is in. |
| 178 | (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %)) | 208 | (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %)) |
| @@ -180,9 +210,11 @@ | |||
| 180 | (reset! screen :connect)) | 210 | (reset! screen :connect)) |
| 181 | 211 | ||
| 182 | (defn join! [name] | 212 | (defn join! [name] |
| 213 | + ;; Deliberately not clearing `error` here: joining is what follows a | ||
| 214 | + ;; successful registration, and a SASL refusal that arrived moments earlier | ||
| 215 | + ;; is the one thing the user most needs to still be on screen. | ||
| 183 | (let [ch (normalize-channel name)] | 216 | (let [ch (normalize-channel name)] |
| 184 | (when (seq ch) | 217 | (when (seq ch) |
| 185 | - (reset! error nil) | ||
| 186 | (open-channel! ch)))) | 218 | (open-channel! ch)))) |
| 187 | 219 | ||
| 188 | (defn send-draft! [] | 220 | (defn send-draft! [] |