nandi/frqpublic Fork 0
e50cbc7
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.

SASL, and a screen that stops lying about it

`frq.irc.handshake` and `frq.atproto.core` are Nim. An app-password sign-in
resolves the handle, reads the DID document, gets a session from the reader's
own PDS and answers freeq's ATPROTO-CHALLENGE with it — the password goes to
that PDS and nowhere else, and freeq is handed only the token it minted.

The Clojure's `-req`/`-parse` pairs are gone. They exist because ClojureDart
had no portable HTTP client and the host had to make the call in between; Nim
has one, so the round trips are here whole. Same for the JSON: the hand-rolled
string scanner in `atproto/core.cljc` is there because two compilers disagreed
about JSON and neither could be depended on. One language has one JSON.

Doing CAP properly is visible immediately: the guest connection now negotiates
message-tags, server-time, account-tag and echo-message, so the backlog
arrives tagged and the same room that rendered 0 reaction chips renders 200.
That was never a rendering bug — the tags were not being asked for.

Two things it now refuses to do quietly. A sign-in that fails stops the
connection instead of carrying on as a guest, because connecting anyway looks
like success and is not what was asked for. And the Bluesky tab says it is not
wired up rather than describing the finished thing: the broker flow needs a
browser and a loopback listener to catch the redirect, and neither is ported.

    ✓ oauth: ⚠ Bluesky OAuth is not wired up yet — use an app password…
    ✓ bad handle: ⚠ Could not resolve handle …xyz.invalid

The second of those was "⚠ 400 Bad Request" first, which is what the directory
answers for an unknown handle and tells the reader nothing. Every atproto call
now says what it was trying to do rather than what HTTP said about it.

203 Nim tests, 20 Dart, and the guest path still reaches #test with the real
handshake under it.

Not tested against a real account: I have no credentials, so the pds-session
payload is checked against its own shape and the refusal paths are checked
end to end, but nobody has watched a 903 come back. The OAuth/web-token half
is written and unexercised for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T22:35:34-07:00 Browse files
e50cbc7 parent: 58e6c97
added nim/src/frq/atproto.nim +168 -0
new file mode 100644
@@ -0,0 +1,168 @@
1+## handle → DID → PDS → session, and the SASL payload freeq takes.
2+##
3+## From `common/frq/atproto/core.cljc`. That file is split into `-req` and
4+## `-parse` pairs because ClojureDart had no portable HTTP client and the host
5+## had to make the call in between; Nim has `std/httpclient`, so the round
6+## trips are here whole and the seam is gone.
7+##
8+## The JSON is `std/json` rather than the hand-rolled string scanner the
9+## Clojure uses. That scanner exists because two compilers disagreed about
10+## JSON and neither could be depended on; one language has one JSON.
11+
12+import std/[base64, httpclient, json, net, strutils]
13+import frq/trace
14+
15+const
16+ directoryHost* = "public.api.bsky.app"
17+ plcHost* = "plc.directory"
18+
19+type
20+ SessionKind* = enum
21+ skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token"
22+
23+ Session* = object
24+ kind*: SessionKind
25+ did*: string
26+ handle*: string
27+ accessJwt*: string
28+ pds*: string
29+ token*: string ## a web-token from the broker, where that is the kind
30+
31+ AtprotoError* = object of CatchableError
32+
33+proc b64url*(s: string): string =
34+ ## base64url, unpadded — what AUTHENTICATE carries.
35+ s.encode().replace("+", "-").replace("/", "_").replace("=", "")
36+
37+proc b64urlDecode*(s: string): string =
38+ var t = s.replace("-", "+").replace("_", "/")
39+ # `decode` wants the padding the wire form drops.
40+ while t.len mod 4 != 0: t.add '='
41+ try: decode(t) except CatchableError: ""
42+
43+proc newClient(): HttpClient =
44+ newHttpClient(timeout = 15_000,
45+ sslContext = newContext(verifyMode = CVerifyPeer))
46+
47+proc getJson(url, whatFor: string): JsonNode =
48+ ## `whatFor` is what the caller was trying to do, because the failure the
49+ ## reader sees should be about their handle rather than about HTTP. The
50+ ## directory answers an unknown handle with a bare 400, and "400 Bad
51+ ## Request" on the connect screen tells nobody anything.
52+ trace("atproto", "GET " & url)
53+ let c = newClient()
54+ try:
55+ parseJson(c.getContent(url))
56+ except JsonParsingError:
57+ raise newException(AtprotoError, whatFor & " — the server's answer was not JSON.")
58+ except CatchableError as e:
59+ trace("atproto", "!! " & e.msg)
60+ raise newException(AtprotoError, whatFor)
61+ finally:
62+ c.close()
63+
64+func hostOf*(url: string): string =
65+ var u = url
66+ for scheme in ["https://", "http://"]:
67+ if u.startsWith(scheme): u = u[scheme.len .. ^1]
68+ let i = u.find('/')
69+ if i < 0: u else: u[0 ..< i]
70+
71+proc resolveHandle*(handle: string): string =
72+ ## A handle to a DID. One that is already a DID needs no call and passes
73+ ## through, which is why this is not simply a request builder.
74+ let h = handle.strip()
75+ if h.startsWith("did:"): return h
76+ if h.len == 0: raise newException(AtprotoError, "A handle is required.")
77+ let doc = getJson("https://" & directoryHost &
78+ "/xrpc/com.atproto.identity.resolveHandle?handle=" & h,
79+ "Could not resolve handle " & h)
80+ let did = doc{"did"}.getStr()
81+ if did.len == 0:
82+ raise newException(AtprotoError, "Could not resolve handle " & h)
83+ did
84+
85+proc pdsFor*(did: string): string =
86+ ## The PDS endpoint out of the DID document.
87+ ##
88+ ## did:plc documents come from the PLC directory; did:web ones from the
89+ ## domain itself, which is the whole of what did:web means.
90+ var doc: JsonNode
91+ if did.startsWith("did:plc:"):
92+ doc = getJson("https://" & plcHost & "/" & did,
93+ "Could not read the DID document for " & did)
94+ elif did.startsWith("did:web:"):
95+ doc = getJson("https://" & did["did:web:".len .. ^1] & "/.well-known/did.json",
96+ "Could not read the DID document for " & did)
97+ else:
98+ raise newException(AtprotoError, "Unsupported DID method: " & did)
99+
100+ # The document lists several services; the PDS is the one whose type is
101+ # AtprotoPersonalDataServer.
102+ if doc.hasKey("service"):
103+ for svc in doc["service"]:
104+ if svc{"type"}.getStr() == "AtprotoPersonalDataServer":
105+ let ep = svc{"serviceEndpoint"}.getStr()
106+ if ep.len > 0: return ep
107+ raise newException(AtprotoError, "No PDS endpoint for " & did)
108+
109+proc createSession*(handle, password: string): Session =
110+ ## Sign in to the PDS with an app password.
111+ ##
112+ ## The password goes to the user's own PDS and nowhere else — freeq never
113+ ## sees it, and verifies the token it gets by asking that same PDS.
114+ let did = resolveHandle(handle)
115+ let pds = pdsFor(did)
116+ trace("atproto", "createSession at " & pds)
117+
118+ let c = newClient()
119+ try:
120+ c.headers = newHttpHeaders({"Content-Type": "application/json"})
121+ let payload = $(%*{"identifier": handle.strip(), "password": password})
122+ let res = c.request("https://" & hostOf(pds) &
123+ "/xrpc/com.atproto.server.createSession",
124+ httpMethod = HttpPost, body = payload)
125+ # Read the body whatever the status: the PDS puts the reason in it, and a
126+ # wrong app password is a 401 whose message is the useful part.
127+ let body = try: parseJson(res.body)
128+ except JsonParsingError:
129+ raise newException(AtprotoError,
130+ "Your PDS refused the sign-in (" & res.status & ").")
131+ let jwt = body{"accessJwt"}.getStr()
132+ if jwt.len == 0:
133+ let msg = body{"message"}.getStr()
134+ raise newException(AtprotoError,
135+ if msg.len > 0: msg else: "Sign-in failed")
136+ Session(kind: skPdsSession,
137+ did: (if body{"did"}.getStr().len > 0: body{"did"}.getStr() else: did),
138+ handle: (if body{"handle"}.getStr().len > 0: body{"handle"}.getStr()
139+ else: handle),
140+ accessJwt: jwt,
141+ pds: pds)
142+ finally:
143+ c.close()
144+
145+proc saslResponse*(s: Session, nonce: string): string =
146+ ## The base64url SASL payload, for either kind freeq takes.
147+ ##
148+ ## A pds-session carries the PDS token, the DID it belongs to, its PDS, and
149+ ## the server's own nonce echoed back so the token cannot be replayed at
150+ ## another server. A web-token from the broker carries only the token — the
151+ ## server looks the DID up in its own store, which is why the field is sent
152+ ## empty rather than guessed at.
153+ case s.kind
154+ of skWebToken:
155+ b64url($(%*{"did": "", "method": "web-token", "signature": s.token}))
156+ else:
157+ b64url($(%*{"did": s.did,
158+ "signature": s.accessJwt,
159+ "method": "pds-session",
160+ "pds_url": s.pds,
161+ "challenge_nonce": nonce}))
162+
163+proc nonceOf*(challenge: string): string =
164+ ## The nonce out of the server's AUTHENTICATE challenge, which is a
165+ ## base64url JSON object.
166+ let raw = b64urlDecode(challenge)
167+ if raw.len == 0: return ""
168+ try: parseJson(raw){"nonce"}.getStr() except CatchableError: ""
new file mode 100644
@@ -0,0 +1,168 @@
1+## handle → DID → PDS → session, and the SASL payload freeq takes.
2+##
3+## From `common/frq/atproto/core.cljc`. That file is split into `-req` and
4+## `-parse` pairs because ClojureDart had no portable HTTP client and the host
5+## had to make the call in between; Nim has `std/httpclient`, so the round
6+## trips are here whole and the seam is gone.
7+##
8+## The JSON is `std/json` rather than the hand-rolled string scanner the
9+## Clojure uses. That scanner exists because two compilers disagreed about
10+## JSON and neither could be depended on; one language has one JSON.
11+
12+import std/[base64, httpclient, json, net, strutils]
13+import frq/trace
14+
15+const
16+ directoryHost* = "public.api.bsky.app"
17+ plcHost* = "plc.directory"
18+
19+type
20+ SessionKind* = enum
21+ skNone = "none", skPdsSession = "pds-session", skWebToken = "web-token"
22+
23+ Session* = object
24+ kind*: SessionKind
25+ did*: string
26+ handle*: string
27+ accessJwt*: string
28+ pds*: string
29+ token*: string ## a web-token from the broker, where that is the kind
30+
31+ AtprotoError* = object of CatchableError
32+
33+proc b64url*(s: string): string =
34+ ## base64url, unpadded — what AUTHENTICATE carries.
35+ s.encode().replace("+", "-").replace("/", "_").replace("=", "")
36+
37+proc b64urlDecode*(s: string): string =
38+ var t = s.replace("-", "+").replace("_", "/")
39+ # `decode` wants the padding the wire form drops.
40+ while t.len mod 4 != 0: t.add '='
41+ try: decode(t) except CatchableError: ""
42+
43+proc newClient(): HttpClient =
44+ newHttpClient(timeout = 15_000,
45+ sslContext = newContext(verifyMode = CVerifyPeer))
46+
47+proc getJson(url, whatFor: string): JsonNode =
48+ ## `whatFor` is what the caller was trying to do, because the failure the
49+ ## reader sees should be about their handle rather than about HTTP. The
50+ ## directory answers an unknown handle with a bare 400, and "400 Bad
51+ ## Request" on the connect screen tells nobody anything.
52+ trace("atproto", "GET " & url)
53+ let c = newClient()
54+ try:
55+ parseJson(c.getContent(url))
56+ except JsonParsingError:
57+ raise newException(AtprotoError, whatFor & " — the server's answer was not JSON.")
58+ except CatchableError as e:
59+ trace("atproto", "!! " & e.msg)
60+ raise newException(AtprotoError, whatFor)
61+ finally:
62+ c.close()
63+
64+func hostOf*(url: string): string =
65+ var u = url
66+ for scheme in ["https://", "http://"]:
67+ if u.startsWith(scheme): u = u[scheme.len .. ^1]
68+ let i = u.find('/')
69+ if i < 0: u else: u[0 ..< i]
70+
71+proc resolveHandle*(handle: string): string =
72+ ## A handle to a DID. One that is already a DID needs no call and passes
73+ ## through, which is why this is not simply a request builder.
74+ let h = handle.strip()
75+ if h.startsWith("did:"): return h
76+ if h.len == 0: raise newException(AtprotoError, "A handle is required.")
77+ let doc = getJson("https://" & directoryHost &
78+ "/xrpc/com.atproto.identity.resolveHandle?handle=" & h,
79+ "Could not resolve handle " & h)
80+ let did = doc{"did"}.getStr()
81+ if did.len == 0:
82+ raise newException(AtprotoError, "Could not resolve handle " & h)
83+ did
84+
85+proc pdsFor*(did: string): string =
86+ ## The PDS endpoint out of the DID document.
87+ ##
88+ ## did:plc documents come from the PLC directory; did:web ones from the
89+ ## domain itself, which is the whole of what did:web means.
90+ var doc: JsonNode
91+ if did.startsWith("did:plc:"):
92+ doc = getJson("https://" & plcHost & "/" & did,
93+ "Could not read the DID document for " & did)
94+ elif did.startsWith("did:web:"):
95+ doc = getJson("https://" & did["did:web:".len .. ^1] & "/.well-known/did.json",
96+ "Could not read the DID document for " & did)
97+ else:
98+ raise newException(AtprotoError, "Unsupported DID method: " & did)
99+
100+ # The document lists several services; the PDS is the one whose type is
101+ # AtprotoPersonalDataServer.
102+ if doc.hasKey("service"):
103+ for svc in doc["service"]:
104+ if svc{"type"}.getStr() == "AtprotoPersonalDataServer":
105+ let ep = svc{"serviceEndpoint"}.getStr()
106+ if ep.len > 0: return ep
107+ raise newException(AtprotoError, "No PDS endpoint for " & did)
108+
109+proc createSession*(handle, password: string): Session =
110+ ## Sign in to the PDS with an app password.
111+ ##
112+ ## The password goes to the user's own PDS and nowhere else — freeq never
113+ ## sees it, and verifies the token it gets by asking that same PDS.
114+ let did = resolveHandle(handle)
115+ let pds = pdsFor(did)
116+ trace("atproto", "createSession at " & pds)
117+
118+ let c = newClient()
119+ try:
120+ c.headers = newHttpHeaders({"Content-Type": "application/json"})
121+ let payload = $(%*{"identifier": handle.strip(), "password": password})
122+ let res = c.request("https://" & hostOf(pds) &
123+ "/xrpc/com.atproto.server.createSession",
124+ httpMethod = HttpPost, body = payload)
125+ # Read the body whatever the status: the PDS puts the reason in it, and a
126+ # wrong app password is a 401 whose message is the useful part.
127+ let body = try: parseJson(res.body)
128+ except JsonParsingError:
129+ raise newException(AtprotoError,
130+ "Your PDS refused the sign-in (" & res.status & ").")
131+ let jwt = body{"accessJwt"}.getStr()
132+ if jwt.len == 0:
133+ let msg = body{"message"}.getStr()
134+ raise newException(AtprotoError,
135+ if msg.len > 0: msg else: "Sign-in failed")
136+ Session(kind: skPdsSession,
137+ did: (if body{"did"}.getStr().len > 0: body{"did"}.getStr() else: did),
138+ handle: (if body{"handle"}.getStr().len > 0: body{"handle"}.getStr()
139+ else: handle),
140+ accessJwt: jwt,
141+ pds: pds)
142+ finally:
143+ c.close()
144+
145+proc saslResponse*(s: Session, nonce: string): string =
146+ ## The base64url SASL payload, for either kind freeq takes.
147+ ##
148+ ## A pds-session carries the PDS token, the DID it belongs to, its PDS, and
149+ ## the server's own nonce echoed back so the token cannot be replayed at
150+ ## another server. A web-token from the broker carries only the token — the
151+ ## server looks the DID up in its own store, which is why the field is sent
152+ ## empty rather than guessed at.
153+ case s.kind
154+ of skWebToken:
155+ b64url($(%*{"did": "", "method": "web-token", "signature": s.token}))
156+ else:
157+ b64url($(%*{"did": s.did,
158+ "signature": s.accessJwt,
159+ "method": "pds-session",
160+ "pds_url": s.pds,
161+ "challenge_nonce": nonce}))
162+
163+proc nonceOf*(challenge: string): string =
164+ ## The nonce out of the server's AUTHENTICATE challenge, which is a
165+ ## base64url JSON object.
166+ let raw = b64urlDecode(challenge)
167+ if raw.len == 0: return ""
168+ try: parseJson(raw){"nonce"}.getStr() except CatchableError: ""
added nim/src/frq/handshake.nim +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+## CAP negotiation and the SASL exchange inside it, as lines to send.
2+##
3+## From `common/frq/irc/handshake.cljc`, and the shape is kept: every step is
4+## the same question — given what the server just said, what does this client
5+## say back — so it answers with lines and the caller writes them.
6+
7+import std/[sets, strutils]
8+import frq/[ircparse, atproto]
9+
10+const
11+ saslChunk* = 100_000
12+ ## How much of a SASL payload goes on one AUTHENTICATE line.
13+ ##
14+ ## 400 is the IRCv3 figure and it assumes something freeq does not do:
15+ ## that the server reassembles continuation lines. It does not —
16+ ## `handle_authenticate` base64-decodes the single param it was handed, so
17+ ## a split payload arrives as its own first 400 characters and comes back
18+ ## as `904 SASL authentication failed (bad response)`. What freeq wants is
19+ ## the whole thing on one line, which it can afford: a custom server
20+ ## reading lines off a WebSocket bridge rather than a 512-byte ircd.
21+
22+ wantedCaps* = ["message-tags", "server-time", "account-tag", "echo-message",
23+ "freeq.at/msgsig"]
24+ ## What this client can use, and why even a guest negotiates.
25+ ##
26+ ## `server-time`: without it a replayed backlog arrives untimed and every
27+ ## old line reads as just said. `account-tag`: the sender's DID, which is
28+ ## the only identity a client is given — a nick is whatever someone chose
29+ ## today. Both need `message-tags` beside them, since IRCv3 sends tags
30+ ## only to clients that asked for tags at all; either alone is ACKed and
31+ ## then nothing arrives. `echo-message`: our own lines come back, which is
32+ ## the only way this client learns the msgid of something it said —
33+ ## without it a reaction or reply aimed at one has nothing to name.
34+
35+type
36+ Step* = object
37+ send*: seq[string]
38+ caps*: HashSet[string]
39+
40+proc saslLines*(payload: string): seq[string] =
41+ ## A payload split the way AUTHENTICATE wants it. One that lands exactly on
42+ ## the boundary is followed by a bare `+`, so the server knows it ended
43+ ## rather than waiting for a continuation that is not coming.
44+ var rest = payload
45+ while rest.len > saslChunk:
46+ result.add "AUTHENTICATE " & rest[0 ..< saslChunk]
47+ rest = rest[saslChunk .. ^1]
48+ result.add "AUTHENTICATE " & rest
49+ if rest.len == saslChunk:
50+ result.add "AUTHENTICATE +"
51+
52+func dpopNonce*(m: IrcLine): string =
53+ ## The DPoP nonce freeq is relaying, or "".
54+ ##
55+ ## `NOTICE <target> :DPOP_NONCE <nonce>`, and it is not chatter: the server
56+ ## called the PDS's getSession with our proof, was answered `use_dpop_nonce`
57+ ## and is passing on the nonce the PDS wants. Only an OAuth session can do
58+ ## anything with it; the other methods carry no proof to re-mint.
59+ if m.command != "NOTICE" or m.params.len == 0: return ""
60+ let text = m.params[^1]
61+ if not text.startsWith("DPOP_NONCE "): return ""
62+ text["DPOP_NONCE ".len .. ^1].strip()
63+
64+proc step*(session: Session, caps: HashSet[string], m: IrcLine): Step =
65+ ## What to send in answer to `m`, and what it did to the acked set.
66+ ##
67+ ## `caps` comes back whether it changed or not, so a caller can keep it in
68+ ## whatever it keeps state in without this module holding any.
69+ result.caps = caps
70+
71+ case m.command
72+ of "CAP":
73+ if m.params.len < 2: return
74+ let sub = m.params[1]
75+ let offeredStr = if m.params.len >= 3: m.params[^1] else: ""
76+ var offered = initHashSet[string]()
77+ for c in offeredStr.split({' ', '\t'}):
78+ if c.len > 0: offered.incl c
79+
80+ case sub
81+ of "LS":
82+ var wanted: seq[string]
83+ for c in wantedCaps:
84+ if c in offered: wanted.add c
85+ # sasl only where there is something to authenticate with. A guest asks
86+ # for it and then has nothing to say.
87+ if session.kind != skNone and "sasl" in offered: wanted.add "sasl"
88+ result.send = @[if wanted.len > 0: "CAP REQ :" & wanted.join(" ")
89+ else: "CAP END"]
90+ of "ACK":
91+ for c in offeredStr.split({' ', '\t'}):
92+ if c.len > 0: result.caps.incl c
93+ result.send = @[if "sasl" in offeredStr: "AUTHENTICATE ATPROTO-CHALLENGE"
94+ else: "CAP END"]
95+ of "NAK":
96+ result.send = @["CAP END"]
97+ else: discard
98+
99+ of "AUTHENTICATE":
100+ let challenge = if m.params.len > 0: m.params[0] else: ""
101+ if challenge.len > 0 and challenge != "+":
102+ result.send = saslLines(saslResponse(session, nonceOf(challenge)))
103+
104+ of "903":
105+ # Authenticated. Registration proceeds once CAP is ended.
106+ result.send = @["CAP END"]
107+
108+ of "904", "905", "906":
109+ # Refused. End CAP anyway and carry on as a guest rather than hanging —
110+ # the caller reports what happened.
111+ result.send = @["CAP END"]
112+
113+ else: discard
new file mode 100644
@@ -0,0 +1,113 @@
1+## CAP negotiation and the SASL exchange inside it, as lines to send.
2+##
3+## From `common/frq/irc/handshake.cljc`, and the shape is kept: every step is
4+## the same question — given what the server just said, what does this client
5+## say back — so it answers with lines and the caller writes them.
6+
7+import std/[sets, strutils]
8+import frq/[ircparse, atproto]
9+
10+const
11+ saslChunk* = 100_000
12+ ## How much of a SASL payload goes on one AUTHENTICATE line.
13+ ##
14+ ## 400 is the IRCv3 figure and it assumes something freeq does not do:
15+ ## that the server reassembles continuation lines. It does not —
16+ ## `handle_authenticate` base64-decodes the single param it was handed, so
17+ ## a split payload arrives as its own first 400 characters and comes back
18+ ## as `904 SASL authentication failed (bad response)`. What freeq wants is
19+ ## the whole thing on one line, which it can afford: a custom server
20+ ## reading lines off a WebSocket bridge rather than a 512-byte ircd.
21+
22+ wantedCaps* = ["message-tags", "server-time", "account-tag", "echo-message",
23+ "freeq.at/msgsig"]
24+ ## What this client can use, and why even a guest negotiates.
25+ ##
26+ ## `server-time`: without it a replayed backlog arrives untimed and every
27+ ## old line reads as just said. `account-tag`: the sender's DID, which is
28+ ## the only identity a client is given — a nick is whatever someone chose
29+ ## today. Both need `message-tags` beside them, since IRCv3 sends tags
30+ ## only to clients that asked for tags at all; either alone is ACKed and
31+ ## then nothing arrives. `echo-message`: our own lines come back, which is
32+ ## the only way this client learns the msgid of something it said —
33+ ## without it a reaction or reply aimed at one has nothing to name.
34+
35+type
36+ Step* = object
37+ send*: seq[string]
38+ caps*: HashSet[string]
39+
40+proc saslLines*(payload: string): seq[string] =
41+ ## A payload split the way AUTHENTICATE wants it. One that lands exactly on
42+ ## the boundary is followed by a bare `+`, so the server knows it ended
43+ ## rather than waiting for a continuation that is not coming.
44+ var rest = payload
45+ while rest.len > saslChunk:
46+ result.add "AUTHENTICATE " & rest[0 ..< saslChunk]
47+ rest = rest[saslChunk .. ^1]
48+ result.add "AUTHENTICATE " & rest
49+ if rest.len == saslChunk:
50+ result.add "AUTHENTICATE +"
51+
52+func dpopNonce*(m: IrcLine): string =
53+ ## The DPoP nonce freeq is relaying, or "".
54+ ##
55+ ## `NOTICE <target> :DPOP_NONCE <nonce>`, and it is not chatter: the server
56+ ## called the PDS's getSession with our proof, was answered `use_dpop_nonce`
57+ ## and is passing on the nonce the PDS wants. Only an OAuth session can do
58+ ## anything with it; the other methods carry no proof to re-mint.
59+ if m.command != "NOTICE" or m.params.len == 0: return ""
60+ let text = m.params[^1]
61+ if not text.startsWith("DPOP_NONCE "): return ""
62+ text["DPOP_NONCE ".len .. ^1].strip()
63+
64+proc step*(session: Session, caps: HashSet[string], m: IrcLine): Step =
65+ ## What to send in answer to `m`, and what it did to the acked set.
66+ ##
67+ ## `caps` comes back whether it changed or not, so a caller can keep it in
68+ ## whatever it keeps state in without this module holding any.
69+ result.caps = caps
70+
71+ case m.command
72+ of "CAP":
73+ if m.params.len < 2: return
74+ let sub = m.params[1]
75+ let offeredStr = if m.params.len >= 3: m.params[^1] else: ""
76+ var offered = initHashSet[string]()
77+ for c in offeredStr.split({' ', '\t'}):
78+ if c.len > 0: offered.incl c
79+
80+ case sub
81+ of "LS":
82+ var wanted: seq[string]
83+ for c in wantedCaps:
84+ if c in offered: wanted.add c
85+ # sasl only where there is something to authenticate with. A guest asks
86+ # for it and then has nothing to say.
87+ if session.kind != skNone and "sasl" in offered: wanted.add "sasl"
88+ result.send = @[if wanted.len > 0: "CAP REQ :" & wanted.join(" ")
89+ else: "CAP END"]
90+ of "ACK":
91+ for c in offeredStr.split({' ', '\t'}):
92+ if c.len > 0: result.caps.incl c
93+ result.send = @[if "sasl" in offeredStr: "AUTHENTICATE ATPROTO-CHALLENGE"
94+ else: "CAP END"]
95+ of "NAK":
96+ result.send = @["CAP END"]
97+ else: discard
98+
99+ of "AUTHENTICATE":
100+ let challenge = if m.params.len > 0: m.params[0] else: ""
101+ if challenge.len > 0 and challenge != "+":
102+ result.send = saslLines(saslResponse(session, nonceOf(challenge)))
103+
104+ of "903":
105+ # Authenticated. Registration proceeds once CAP is ended.
106+ result.send = @["CAP END"]
107+
108+ of "904", "905", "906":
109+ # Refused. End CAP anyway and carry on as a guest rather than hanging —
110+ # the caller reports what happened.
111+ result.send = @["CAP END"]
112+
113+ else: discard
modified nim/src/frq/reducer.nim +71 -16
@@ -12,7 +12,9 @@
1212 ## for arguments that are always one string.
1313
1414 import std/[json, options, sequtils, strutils, tables]
15-import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock]
15+import std/sets
16+import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock,
17+ atproto, handshake]
1618 import frq/conn as tr
1719
1820 proc split2(id: string): (string, string) =
@@ -22,6 +24,12 @@ proc split2(id: string): (string, string) =
2224 let i = id.find(':')
2325 if i < 0: (id, "") else: (id[0 ..< i], id[i + 1 .. ^1])
2426
27+var
28+ session: Session
29+ ## What this connection is signing in as, settled before the socket opens.
30+ caps: HashSet[string]
31+ ## What the server has ACKed so far, threaded through `handshake.step`.
32+
2533 proc setError(msg: string) =
2634 app.error = msg
2735 app.hasError = true
@@ -41,13 +49,54 @@ proc openRoom(name: string) =
4149 # Opening a room is reading it: the marker moves to the newest line here.
4250 app.rooms[name] = app.rooms[name].markRead
4351
52+proc signIn(): bool =
53+ ## Whatever identity was asked for, settled before the socket opens.
54+ ##
55+ ## An app-password sign-in is an HTTPS round trip that has nothing to do
56+ ## with IRC, and a failure in it must stop here: connecting anyway lands us
57+ ## on the server as a guest, which looks like a success and is not the one
58+ ## that was asked for.
59+ session = Session()
60+ caps = initHashSet[string]()
61+ case app.authMode
62+ of amGuest:
63+ true
64+ of amAppPassword:
65+ if app.formHandle.strip().len == 0:
66+ setError("A handle is required."); return false
67+ if app.formAppPassword.len == 0:
68+ setError("An app password is required."); return false
69+ try:
70+ app.status = "Signing in to your PDS…"
71+ session = createSession(app.formHandle, app.formAppPassword)
72+ # The nick is what the channel calls us and the DID is the identity;
73+ # both halves have to agree, so the handle becomes the nick. Sending the
74+ # handle here and a different nick at registration is what once had the
75+ # channel calling us alice.bsky.social while the client thought it was
76+ # alice, so every "is this me?" test came back false.
77+ app.formNick = session.handle
78+ app.formHandle = session.handle
79+ trace("auth", "signed in as " & session.did)
80+ true
81+ except CatchableError as e:
82+ setError(e.msg)
83+ app.connecting = false
84+ false
85+ of amBluesky:
86+ # The broker flow needs a browser and a loopback listener to catch the
87+ # redirect, and neither is ported. Said plainly rather than connecting as
88+ # a guest and looking like it worked.
89+ setError("Bluesky OAuth is not wired up yet — use an app password, or connect as a guest.")
90+ false
91+
4492 proc connectNow() =
4593 if app.formHost.strip().len == 0:
4694 setError("A server is required."); return
47- if app.formNick.strip().len == 0:
95+ if app.formNick.strip().len == 0 and app.authMode == amGuest:
4896 setError("A nickname is required."); return
49- app.connecting = true
5097 app.hasError = false
98+ if not signIn(): return
99+ app.connecting = true
51100 app.status = "Connecting to " & app.formHost & ":" & app.formPort &
52101 (if app.formTls: " over TLS" else: "") & ""
53102 let port = try: parseInt(app.formPort.strip())
@@ -253,12 +302,7 @@ proc drain*() =
253302 trace("status", e)
254303 if e == "open":
255304 # The client speaks first in IRC. CAP before registration, the order the
256- # server expects and the order `frq.main` used.
257- #
258- # No SASL yet: this registers as a guest. The Bluesky handshake is
259- # `frq.irc.handshake` and has not been ported, so the two signed-in
260- # modes on the connect screen reach this point and land as guests —
261- # which the connect screen does not yet say, and should.
305+ # server expects. What comes back is answered by `handshake.step`.
262306 send("CAP LS 302")
263307 send("NICK " & app.formNick)
264308 send("USER " & app.formNick & " 0 * :frq")
@@ -287,14 +331,25 @@ proc drain*() =
287331 let (v, ok2) = tagValue(p.tags, "msgid")
288332 if ok2: v else: ""
289333
290- case p.command
291- of "CAP":
292- # Nothing is requested yet — no SASL, no message-tags of our own — so
293- # the negotiation is ended immediately. A CAP LS with no END leaves the
294- # server waiting and registration never completes.
295- if p.params.len >= 2 and p.params[1] == "LS":
296- send("CAP END")
334+ # CAP and the SASL exchange inside it, out of `handshake`. Answered before
335+ # the per-command handling below, because these are the transport's own
336+ # conversation rather than anything a screen reads.
337+ if p.command in ["CAP", "AUTHENTICATE", "903", "904", "905", "906"]:
338+ let st = step(session, caps, p)
339+ caps = st.caps
340+ for line in st.send: send(line)
341+ if p.command == "903":
342+ app.status = "Signed in as " & app.formNick
343+ trace("auth", "SASL accepted")
344+ elif p.command in ["904", "905", "906"]:
345+ # Refused. Registration carries on as a guest, which is freeq's own
346+ # behaviour — but it is said, because a silent downgrade is the thing
347+ # that makes a client look like it signed in when it did not.
348+ setError("Sign-in refused — connected as a guest.")
349+ trace("auth", "SASL refused: " & $p.params)
350+ continue
297351
352+ case p.command
298353 of "001":
299354 app.connecting = false
300355 app.status = "Connected as " & app.formNick
@@ -12,7 +12,9 @@
12 ## for arguments that are always one string.12 ## for arguments that are always one string.
13 13
14 import std/[json, options, sequtils, strutils, tables]14 import std/[json, options, sequtils, strutils, tables]
15-import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock]15+import std/sets
16+import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock,
17+ atproto, handshake]
16 import frq/conn as tr18 import frq/conn as tr
17 19
18 proc split2(id: string): (string, string) =20 proc split2(id: string): (string, string) =
@@ -22,6 +24,12 @@ proc split2(id: string): (string, string) =
22 let i = id.find(':')24 let i = id.find(':')
23 if i < 0: (id, "") else: (id[0 ..< i], id[i + 1 .. ^1])25 if i < 0: (id, "") else: (id[0 ..< i], id[i + 1 .. ^1])
24 26
27+var
28+ session: Session
29+ ## What this connection is signing in as, settled before the socket opens.
30+ caps: HashSet[string]
31+ ## What the server has ACKed so far, threaded through `handshake.step`.
32+
25 proc setError(msg: string) =33 proc setError(msg: string) =
26 app.error = msg34 app.error = msg
27 app.hasError = true35 app.hasError = true
@@ -41,13 +49,54 @@ proc openRoom(name: string) =
41 # Opening a room is reading it: the marker moves to the newest line here.49 # Opening a room is reading it: the marker moves to the newest line here.
42 app.rooms[name] = app.rooms[name].markRead50 app.rooms[name] = app.rooms[name].markRead
43 51
52+proc signIn(): bool =
53+ ## Whatever identity was asked for, settled before the socket opens.
54+ ##
55+ ## An app-password sign-in is an HTTPS round trip that has nothing to do
56+ ## with IRC, and a failure in it must stop here: connecting anyway lands us
57+ ## on the server as a guest, which looks like a success and is not the one
58+ ## that was asked for.
59+ session = Session()
60+ caps = initHashSet[string]()
61+ case app.authMode
62+ of amGuest:
63+ true
64+ of amAppPassword:
65+ if app.formHandle.strip().len == 0:
66+ setError("A handle is required."); return false
67+ if app.formAppPassword.len == 0:
68+ setError("An app password is required."); return false
69+ try:
70+ app.status = "Signing in to your PDS…"
71+ session = createSession(app.formHandle, app.formAppPassword)
72+ # The nick is what the channel calls us and the DID is the identity;
73+ # both halves have to agree, so the handle becomes the nick. Sending the
74+ # handle here and a different nick at registration is what once had the
75+ # channel calling us alice.bsky.social while the client thought it was
76+ # alice, so every "is this me?" test came back false.
77+ app.formNick = session.handle
78+ app.formHandle = session.handle
79+ trace("auth", "signed in as " & session.did)
80+ true
81+ except CatchableError as e:
82+ setError(e.msg)
83+ app.connecting = false
84+ false
85+ of amBluesky:
86+ # The broker flow needs a browser and a loopback listener to catch the
87+ # redirect, and neither is ported. Said plainly rather than connecting as
88+ # a guest and looking like it worked.
89+ setError("Bluesky OAuth is not wired up yet — use an app password, or connect as a guest.")
90+ false
91+
44 proc connectNow() =92 proc connectNow() =
45 if app.formHost.strip().len == 0:93 if app.formHost.strip().len == 0:
46 setError("A server is required."); return94 setError("A server is required."); return
47- if app.formNick.strip().len == 0:95+ if app.formNick.strip().len == 0 and app.authMode == amGuest:
48 setError("A nickname is required."); return96 setError("A nickname is required."); return
49- app.connecting = true
50 app.hasError = false97 app.hasError = false
98+ if not signIn(): return
99+ app.connecting = true
51 app.status = "Connecting to " & app.formHost & ":" & app.formPort &100 app.status = "Connecting to " & app.formHost & ":" & app.formPort &
52 (if app.formTls: " over TLS" else: "") & ""101 (if app.formTls: " over TLS" else: "") & ""
53 let port = try: parseInt(app.formPort.strip())102 let port = try: parseInt(app.formPort.strip())
@@ -253,12 +302,7 @@ proc drain*() =
253 trace("status", e)302 trace("status", e)
254 if e == "open":303 if e == "open":
255 # The client speaks first in IRC. CAP before registration, the order the304 # The client speaks first in IRC. CAP before registration, the order the
256- # server expects and the order `frq.main` used.305+ # server expects. What comes back is answered by `handshake.step`.
257- #
258- # No SASL yet: this registers as a guest. The Bluesky handshake is
259- # `frq.irc.handshake` and has not been ported, so the two signed-in
260- # modes on the connect screen reach this point and land as guests —
261- # which the connect screen does not yet say, and should.
262 send("CAP LS 302")306 send("CAP LS 302")
263 send("NICK " & app.formNick)307 send("NICK " & app.formNick)
264 send("USER " & app.formNick & " 0 * :frq")308 send("USER " & app.formNick & " 0 * :frq")
@@ -287,14 +331,25 @@ proc drain*() =
287 let (v, ok2) = tagValue(p.tags, "msgid")331 let (v, ok2) = tagValue(p.tags, "msgid")
288 if ok2: v else: ""332 if ok2: v else: ""
289 333
290- case p.command334+ # CAP and the SASL exchange inside it, out of `handshake`. Answered before
291- of "CAP":335+ # the per-command handling below, because these are the transport's own
292- # Nothing is requested yet — no SASL, no message-tags of our own — so336+ # conversation rather than anything a screen reads.
293- # the negotiation is ended immediately. A CAP LS with no END leaves the337+ if p.command in ["CAP", "AUTHENTICATE", "903", "904", "905", "906"]:
294- # server waiting and registration never completes.338+ let st = step(session, caps, p)
295- if p.params.len >= 2 and p.params[1] == "LS":339+ caps = st.caps
296- send("CAP END")340+ for line in st.send: send(line)
341+ if p.command == "903":
342+ app.status = "Signed in as " & app.formNick
343+ trace("auth", "SASL accepted")
344+ elif p.command in ["904", "905", "906"]:
345+ # Refused. Registration carries on as a guest, which is freeq's own
346+ # behaviour — but it is said, because a silent downgrade is the thing
347+ # that makes a client look like it signed in when it did not.
348+ setError("Sign-in refused — connected as a guest.")
349+ trace("auth", "SASL refused: " & $p.params)
350+ continue
297 351
352+ case p.command
298 of "001":353 of "001":
299 app.connecting = false354 app.connecting = false
300 app.status = "Connected as " & app.formNick355 app.status = "Connected as " & app.formNick
modified nim/src/frq/screens/connect.nim +6 -2
@@ -52,8 +52,12 @@ func authFields(s: State): Node =
5252 of amBluesky:
5353 result = vbox(%*{"spacing": 6},
5454 title2("Sign in with Bluesky"),
55- dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
56- "hands back a token; no password passes through frq."),
55+ # Says what it actually does today. The broker flow needs a browser and
56+ # a loopback listener to catch the redirect, and neither is ported — a
57+ # screen that describes the finished thing is a screen that lies.
58+ dimLabel("Not wired up yet: the broker flow needs a browser and a " &
59+ "loopback listener, and neither is ported. Use an app " &
60+ "password, or connect as a guest."),
5761 label("Handle"),
5862 entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
5963 width = 320))
@@ -52,8 +52,12 @@ func authFields(s: State): Node =
52 of amBluesky:52 of amBluesky:
53 result = vbox(%*{"spacing": 6},53 result = vbox(%*{"spacing": 6},
54 title2("Sign in with Bluesky"),54 title2("Sign in with Bluesky"),
55- dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &55+ # Says what it actually does today. The broker flow needs a browser and
56- "hands back a token; no password passes through frq."),56+ # a loopback listener to catch the redirect, and neither is ported — a
57+ # screen that describes the finished thing is a screen that lies.
58+ dimLabel("Not wired up yet: the broker flow needs a browser and a " &
59+ "loopback listener, and neither is ported. Use an app " &
60+ "password, or connect as a guest."),
57 label("Handle"),61 label("Handle"),
58 entry("handle", s.formHandle, "alice.bsky.social", "handle.change",62 entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
59 width = 320))63 width = 320))
added nim/tests/thandshake.nim +137 -0
new file mode 100644
@@ -0,0 +1,137 @@
1+## CAP negotiation and the SASL payload. No network: every case here is a
2+## line in and lines out.
3+
4+import std/[base64, json, sets, strutils, unittest]
5+import frq/[ircparse, atproto, handshake]
6+
7+proc caps0(): HashSet[string] = initHashSet[string]()
8+
9+proc guest(): Session = Session(kind: skNone)
10+proc signedIn(): Session =
11+ Session(kind: skPdsSession, did: "did:plc:abc", accessJwt: "jwt-123",
12+ pds: "https://pds.example")
13+
14+suite "base64url":
15+ test "unpadded, and URL-safe":
16+ check b64url("hello") == "aGVsbG8"
17+ check '=' notin b64url("any")
18+ check '+' notin b64url("\xfb\xff")
19+ check '/' notin b64url("\xfb\xff")
20+
21+ test "round-trips":
22+ for s in ["", "a", "ab", "abc", "{\"nonce\":\"x\"}", "😀"]:
23+ check b64urlDecode(b64url(s)) == s
24+
25+ test "garbage decodes to nothing rather than throwing":
26+ check b64urlDecode("!!!not base64!!!") == ""
27+
28+suite "nonceOf":
29+ test "the nonce out of a challenge":
30+ let challenge = b64url($(%*{"session_id": "s", "nonce": "N123"}))
31+ check nonceOf(challenge) == "N123"
32+ test "a challenge with no nonce":
33+ check nonceOf(b64url($(%*{"session_id": "s"}))) == ""
34+ test "a challenge that is not JSON":
35+ check nonceOf(b64url("not json")) == ""
36+
37+suite "saslResponse":
38+ test "a pds-session carries the DID, the token, the PDS and the nonce":
39+ let payload = parseJson(b64urlDecode(saslResponse(signedIn(), "N1")))
40+ check payload["method"].getStr() == "pds-session"
41+ check payload["did"].getStr() == "did:plc:abc"
42+ check payload["signature"].getStr() == "jwt-123"
43+ check payload["pds_url"].getStr() == "https://pds.example"
44+ # Echoed back so the token cannot be replayed at another server.
45+ check payload["challenge_nonce"].getStr() == "N1"
46+
47+ test "a web-token carries only the token, with the DID left empty":
48+ # The server looks the DID up in its own store; guessing it would be
49+ # wrong more often than not.
50+ let s = Session(kind: skWebToken, token: "tok")
51+ let payload = parseJson(b64urlDecode(saslResponse(s, "N1")))
52+ check payload["method"].getStr() == "web-token"
53+ check payload["signature"].getStr() == "tok"
54+ check payload["did"].getStr() == ""
55+
56+suite "saslLines":
57+ test "a short payload is one line":
58+ check saslLines("abc") == @["AUTHENTICATE abc"]
59+
60+ test "one that lands exactly on the boundary gets a bare + after it":
61+ # Or the server waits for a continuation that is not coming.
62+ let exact = "x".repeat(saslChunk)
63+ let got = saslLines(exact)
64+ check got.len == 2
65+ check got[1] == "AUTHENTICATE +"
66+
67+ test "a longer one is split":
68+ check saslLines("x".repeat(saslChunk + 5)).len == 2
69+
70+suite "step: CAP":
71+ test "LS asks for what it can use":
72+ let m = parseLine(":s CAP * LS :message-tags server-time account-tag echo-message")
73+ let got = step(guest(), caps0(), m)
74+ check got.send.len == 1
75+ check got.send[0].startsWith("CAP REQ :")
76+ for c in ["message-tags", "server-time", "account-tag", "echo-message"]:
77+ check c in got.send[0]
78+
79+ test "it asks only for what was offered":
80+ let m = parseLine(":s CAP * LS :server-time")
81+ check step(guest(), caps0(), m).send[0] == "CAP REQ :server-time"
82+
83+ test "nothing on offer ends CAP rather than requesting nothing":
84+ check step(guest(), caps0(), parseLine(":s CAP * LS :")).send == @["CAP END"]
85+
86+ test "a guest does not ask for sasl even when it is offered":
87+ # It would have nothing to answer the challenge with.
88+ let m = parseLine(":s CAP * LS :sasl server-time")
89+ check "sasl" notin step(guest(), caps0(), m).send[0]
90+
91+ test "a signed-in session does":
92+ let m = parseLine(":s CAP * LS :sasl server-time")
93+ check "sasl" in step(signedIn(), caps0(), m).send[0]
94+
95+ test "ACK with sasl starts the exchange":
96+ let m = parseLine(":s CAP nick ACK :sasl server-time")
97+ let got = step(signedIn(), caps0(), m)
98+ check got.send == @["AUTHENTICATE ATPROTO-CHALLENGE"]
99+ check "sasl" in got.caps
100+ check "server-time" in got.caps
101+
102+ test "ACK without sasl ends CAP":
103+ let m = parseLine(":s CAP nick ACK :server-time")
104+ check step(guest(), caps0(), m).send == @["CAP END"]
105+
106+ test "NAK ends CAP rather than hanging":
107+ check step(guest(), caps0(), parseLine(":s CAP nick NAK :sasl")).send ==
108+ @["CAP END"]
109+
110+suite "step: AUTHENTICATE":
111+ test "a challenge is answered with the payload":
112+ let challenge = b64url($(%*{"nonce": "N9"}))
113+ let got = step(signedIn(), caps0(), parseLine("AUTHENTICATE " & challenge))
114+ check got.send.len == 1
115+ let payload = parseJson(b64urlDecode(got.send[0]["AUTHENTICATE ".len .. ^1]))
116+ check payload["challenge_nonce"].getStr() == "N9"
117+
118+ test "a bare + is not a challenge":
119+ check step(signedIn(), caps0(), parseLine("AUTHENTICATE +")).send.len == 0
120+
121+suite "step: the outcome":
122+ test "903 ends CAP so registration can proceed":
123+ check step(signedIn(), caps0(), parseLine(":s 903 n :ok")).send == @["CAP END"]
124+
125+ test "904 ends CAP too rather than leaving the client hanging":
126+ # Refused is an outcome; the caller reports it and carries on as a guest.
127+ for code in ["904", "905", "906"]:
128+ check step(signedIn(), caps0(), parseLine(":s " & code & " n :no")).send ==
129+ @["CAP END"]
130+
131+suite "dpopNonce":
132+ test "the relayed nonce":
133+ check dpopNonce(parseLine(":s NOTICE n :DPOP_NONCE abc123")) == "abc123"
134+ test "an ordinary notice is not one":
135+ check dpopNonce(parseLine(":s NOTICE n :hello")) == ""
136+ test "nor is anything else":
137+ check dpopNonce(parseLine(":s PRIVMSG #c :DPOP_NONCE x")) == ""
new file mode 100644
@@ -0,0 +1,137 @@
1+## CAP negotiation and the SASL payload. No network: every case here is a
2+## line in and lines out.
3+
4+import std/[base64, json, sets, strutils, unittest]
5+import frq/[ircparse, atproto, handshake]
6+
7+proc caps0(): HashSet[string] = initHashSet[string]()
8+
9+proc guest(): Session = Session(kind: skNone)
10+proc signedIn(): Session =
11+ Session(kind: skPdsSession, did: "did:plc:abc", accessJwt: "jwt-123",
12+ pds: "https://pds.example")
13+
14+suite "base64url":
15+ test "unpadded, and URL-safe":
16+ check b64url("hello") == "aGVsbG8"
17+ check '=' notin b64url("any")
18+ check '+' notin b64url("\xfb\xff")
19+ check '/' notin b64url("\xfb\xff")
20+
21+ test "round-trips":
22+ for s in ["", "a", "ab", "abc", "{\"nonce\":\"x\"}", "😀"]:
23+ check b64urlDecode(b64url(s)) == s
24+
25+ test "garbage decodes to nothing rather than throwing":
26+ check b64urlDecode("!!!not base64!!!") == ""
27+
28+suite "nonceOf":
29+ test "the nonce out of a challenge":
30+ let challenge = b64url($(%*{"session_id": "s", "nonce": "N123"}))
31+ check nonceOf(challenge) == "N123"
32+ test "a challenge with no nonce":
33+ check nonceOf(b64url($(%*{"session_id": "s"}))) == ""
34+ test "a challenge that is not JSON":
35+ check nonceOf(b64url("not json")) == ""
36+
37+suite "saslResponse":
38+ test "a pds-session carries the DID, the token, the PDS and the nonce":
39+ let payload = parseJson(b64urlDecode(saslResponse(signedIn(), "N1")))
40+ check payload["method"].getStr() == "pds-session"
41+ check payload["did"].getStr() == "did:plc:abc"
42+ check payload["signature"].getStr() == "jwt-123"
43+ check payload["pds_url"].getStr() == "https://pds.example"
44+ # Echoed back so the token cannot be replayed at another server.
45+ check payload["challenge_nonce"].getStr() == "N1"
46+
47+ test "a web-token carries only the token, with the DID left empty":
48+ # The server looks the DID up in its own store; guessing it would be
49+ # wrong more often than not.
50+ let s = Session(kind: skWebToken, token: "tok")
51+ let payload = parseJson(b64urlDecode(saslResponse(s, "N1")))
52+ check payload["method"].getStr() == "web-token"
53+ check payload["signature"].getStr() == "tok"
54+ check payload["did"].getStr() == ""
55+
56+suite "saslLines":
57+ test "a short payload is one line":
58+ check saslLines("abc") == @["AUTHENTICATE abc"]
59+
60+ test "one that lands exactly on the boundary gets a bare + after it":
61+ # Or the server waits for a continuation that is not coming.
62+ let exact = "x".repeat(saslChunk)
63+ let got = saslLines(exact)
64+ check got.len == 2
65+ check got[1] == "AUTHENTICATE +"
66+
67+ test "a longer one is split":
68+ check saslLines("x".repeat(saslChunk + 5)).len == 2
69+
70+suite "step: CAP":
71+ test "LS asks for what it can use":
72+ let m = parseLine(":s CAP * LS :message-tags server-time account-tag echo-message")
73+ let got = step(guest(), caps0(), m)
74+ check got.send.len == 1
75+ check got.send[0].startsWith("CAP REQ :")
76+ for c in ["message-tags", "server-time", "account-tag", "echo-message"]:
77+ check c in got.send[0]
78+
79+ test "it asks only for what was offered":
80+ let m = parseLine(":s CAP * LS :server-time")
81+ check step(guest(), caps0(), m).send[0] == "CAP REQ :server-time"
82+
83+ test "nothing on offer ends CAP rather than requesting nothing":
84+ check step(guest(), caps0(), parseLine(":s CAP * LS :")).send == @["CAP END"]
85+
86+ test "a guest does not ask for sasl even when it is offered":
87+ # It would have nothing to answer the challenge with.
88+ let m = parseLine(":s CAP * LS :sasl server-time")
89+ check "sasl" notin step(guest(), caps0(), m).send[0]
90+
91+ test "a signed-in session does":
92+ let m = parseLine(":s CAP * LS :sasl server-time")
93+ check "sasl" in step(signedIn(), caps0(), m).send[0]
94+
95+ test "ACK with sasl starts the exchange":
96+ let m = parseLine(":s CAP nick ACK :sasl server-time")
97+ let got = step(signedIn(), caps0(), m)
98+ check got.send == @["AUTHENTICATE ATPROTO-CHALLENGE"]
99+ check "sasl" in got.caps
100+ check "server-time" in got.caps
101+
102+ test "ACK without sasl ends CAP":
103+ let m = parseLine(":s CAP nick ACK :server-time")
104+ check step(guest(), caps0(), m).send == @["CAP END"]
105+
106+ test "NAK ends CAP rather than hanging":
107+ check step(guest(), caps0(), parseLine(":s CAP nick NAK :sasl")).send ==
108+ @["CAP END"]
109+
110+suite "step: AUTHENTICATE":
111+ test "a challenge is answered with the payload":
112+ let challenge = b64url($(%*{"nonce": "N9"}))
113+ let got = step(signedIn(), caps0(), parseLine("AUTHENTICATE " & challenge))
114+ check got.send.len == 1
115+ let payload = parseJson(b64urlDecode(got.send[0]["AUTHENTICATE ".len .. ^1]))
116+ check payload["challenge_nonce"].getStr() == "N9"
117+
118+ test "a bare + is not a challenge":
119+ check step(signedIn(), caps0(), parseLine("AUTHENTICATE +")).send.len == 0
120+
121+suite "step: the outcome":
122+ test "903 ends CAP so registration can proceed":
123+ check step(signedIn(), caps0(), parseLine(":s 903 n :ok")).send == @["CAP END"]
124+
125+ test "904 ends CAP too rather than leaving the client hanging":
126+ # Refused is an outcome; the caller reports it and carries on as a guest.
127+ for code in ["904", "905", "906"]:
128+ check step(signedIn(), caps0(), parseLine(":s " & code & " n :no")).send ==
129+ @["CAP END"]
130+
131+suite "dpopNonce":
132+ test "the relayed nonce":
133+ check dpopNonce(parseLine(":s NOTICE n :DPOP_NONCE abc123")) == "abc123"
134+ test "an ordinary notice is not one":
135+ check dpopNonce(parseLine(":s NOTICE n :hello")) == ""
136+ test "nor is anything else":
137+ check dpopNonce(parseLine(":s PRIVMSG #c :DPOP_NONCE x")) == ""
modified nim/tests/tscreens.nim +5 -0
@@ -46,6 +46,11 @@ suite "the connect screen":
4646 check "handle" in t.keys("entry")
4747 check "nick" notin t.keys("entry")
4848
49+ test "and admits the broker flow is not wired up":
50+ # A screen that describes the finished thing is a screen that lies.
51+ s.authMode = amBluesky
52+ check cs.connectScreen(s).labels("dim-label").anyIt("Not wired up yet" in it)
53+
4954 test "app-password asks for both, and says where to make one":
5055 s.authMode = amAppPassword
5156 let t = cs.connectScreen(s)
@@ -46,6 +46,11 @@ suite "the connect screen":
46 check "handle" in t.keys("entry")46 check "handle" in t.keys("entry")
47 check "nick" notin t.keys("entry")47 check "nick" notin t.keys("entry")
48 48
49+ test "and admits the broker flow is not wired up":
50+ # A screen that describes the finished thing is a screen that lies.
51+ s.authMode = amBluesky
52+ check cs.connectScreen(s).labels("dim-label").anyIt("Not wired up yet" in it)
53+
49 test "app-password asks for both, and says where to make one":54 test "app-password asks for both, and says where to make one":
50 s.authMode = amAppPassword55 s.authMode = amAppPassword
51 let t = cs.connectScreen(s)56 let t = cs.connectScreen(s)