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

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

Sign 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>
nandi committed 2026-08-30T00:23:35-07:00 Browse files
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
1212 of an `AppState` struct.
1313
1414 ```
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
1617 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
1718 src/frq/app.jolt the screens
1819 ```
@@ -35,6 +36,25 @@ plain listener:
3536 cargo run --release --bin freeq-server # in the freeq checkout
3637 ```
3738
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+
3858 ## Android
3959
4060 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.
6888 ## Limits
6989
7090 * **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.
7393 * **No scrollback trimming, avatars, reactions, threads, or calls.**
7494 * 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/PING15+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 screens18 src/frq/app.jolt the screens
18 ```19 ```
@@ -35,6 +36,25 @@ plain listener:
35 cargo run --release --bin freeq-server # in the freeq checkout36 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 ## Android58 ## Android
39 59
40 An APK with two shared libraries and no Java: `libvidya.so` (vidya's Rust/egui60 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 ## Limits88 ## 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 @@
2727
2828 ;; ---------------------------------------------------------------- connect
2929
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+
3065 (defn connect-screen []
3166 [:page {:max-width 520}
3267 [: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."}]
3469 [error-note]
3570 [: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]
5795 [: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."}]])
6698
6799 ;; ---------------------------------------------------------------- chats
68100
@@ -169,6 +201,11 @@
169201 [:status {:label @s/status :live (s/connected?)}]
170202 [:label {:label (str "Server: " @s/form-host ":" @s/form-port)}]
171203 [: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."}])
172209 [:separator {}]
173210 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]
174211 [:card {}
@@ -27,42 +27,74 @@
27 27
28 ;; ---------------------------------------------------------------- connect28 ;; ---------------------------------------------------------------- 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-nick73+ [:vbox {:spacing 6}
39- :width-request 32074+ [: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-host79+ :placeholder "alice.bsky.social"
45- :width-request 22080+ :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-port83+ :width-request 320
49- :width-request 9084+ :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-port90+ [: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 ;; ---------------------------------------------------------------- chats99 ;; ---------------------------------------------------------------- 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 @@
1515 `connect!` takes an `on-msg` fn and returns a connection map that
1616 `send-line!` and `close!` accept."
1717 (:require [clojure.string :as str]
18+ [frq.atproto :as atproto]
1819 [jolt.ffi :as ffi]
1920 [jolt.mvn-http :as tls]
2021 [jolt.socket :as socket]))
@@ -122,16 +123,69 @@
122123 {:kind :plain :fd fd :buf (ffi/alloc buffer-size)
123124 :lock (Object.) :nick nick})))
124125
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+
125168 (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)]
131182 (future
132183 (try (reader-loop! conn on-msg)
133184 (catch Exception e
134185 (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"))
135189 (send-line! conn (str "NICK " nick))
136190 (send-line! conn (str "USER " nick " 0 * :" nick))
137191 conn)))
@@ -15,6 +15,7 @@
15 `connect!` takes an `on-msg` fn and returns a connection map that15 `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 (future182 (future
132 (try (reader-loop! conn on-msg)183 (try (reader-loop! conn on-msg)
133 (catch Exception e184 (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 @@
66 is the only place a wire message turns into UI state."
77 (:require [clojure.string :as str]
88 [glimmer.ratom :as r :refer [atom]]
9+ [frq.atproto :as atproto]
910 [frq.irc :as irc]))
1011
1112 (def default-host "irc.freeq.at")
@@ -32,6 +33,14 @@
3233 (defonce form-tls? (atom true))
3334 (defonce form-nick (atom "frq-guest"))
3435
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+
3544 ;; joined as soon as the server sends 001
3645 (def auto-join "#test")
3746
@@ -88,7 +97,9 @@
8897 (let [{:keys [command params prefix]} msg
8998 from (irc/nick-of prefix)]
9099 (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)))
92103 (reset! connecting? false)
93104 (reset! screen :chats)
94105 (join! auto-join))
@@ -112,6 +123,10 @@
112123 (reset! status (or (last params) @status))
113124 ("473" "474" "475" "477" "403" "471")
114125 (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"))))
115130 "*DISCONNECTED*" (do (reset! conn nil)
116131 (reset! connecting? false)
117132 (swap! channels
@@ -132,11 +147,11 @@
132147 data (ex-data e)]
133148 (str (type e) ": " msg (when data (str " " (pr-str data))))))
134149
135-(defn- dial! [host port nick tls?]
150+(defn- dial! [host port nick tls? sess]
136151 ;; stderr is the only console on Android this line lands in logcat.
137152 (binding [*out* *err*]
138153 (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)))
140155
141156 (defn connect!
142157 "Dial, and fall back to the plain listener when TLS cannot be had. Android is
@@ -148,15 +163,29 @@
148163 (reset! status (str "Connecting to " @form-host ":" @form-port ""))
149164 (let [host @form-host
150165 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))
152181 (try
153- (dial! host port nick @form-tls?)
182+ (dial! host port nick @form-tls? sess)
154183 (catch Exception e
155184 (binding [*out* *err*] (println "frq: dial failed:" (describe e)))
156185 (if @form-tls?
157186 (do (reset! status (str "TLS unavailable trying " host ":" plain-port ""))
158187 (try
159- (dial! host plain-port nick false)
188+ (dial! host plain-port nick false sess)
160189 (reset! form-tls? false)
161190 (reset! form-port (str plain-port))
162191 (catch Exception e2
@@ -173,6 +202,7 @@
173202 (defn disconnect! []
174203 (when-let [c @conn] (irc/close! c))
175204 (reset! conn nil)
205+ (reset! session nil)
176206 ;; The buffers survive, the memberships do not — leaving `joined?` set would
177207 ;; have the next Open show a channel nobody is in.
178208 (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %))
@@ -180,9 +210,11 @@
180210 (reset! screen :connect))
181211
182212 (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.
183216 (let [ch (normalize-channel name)]
184217 (when (seq ch)
185- (reset! error nil)
186218 (open-channel! ch))))
187219
188220 (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 00144 ;; 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]} msg97 (let [{:keys [command params prefix]} msg
89 from (irc/nick-of prefix)]98 from (irc/nick-of prefix)]
90 (case command99 (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! channels132 (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 is157 "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-host164 (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 (try181 (try
153- (dial! host port nick @form-tls?)182+ (dial! host port nick @form-tls? sess)
154 (catch Exception e183 (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 (try187 (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 e2191 (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 would206 ;; 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! []