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>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 @@ | ||
| 12 | 12 | ## for arguments that are always one string. |
| 13 | 13 | |
| 14 | 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 | 18 | import frq/conn as tr |
| 17 | 19 | |
| 18 | 20 | proc split2(id: string): (string, string) = |
| @@ -22,6 +24,12 @@ proc split2(id: string): (string, string) = | ||
| 22 | 24 | let i = id.find(':') |
| 23 | 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 | 33 | proc setError(msg: string) = |
| 26 | 34 | app.error = msg |
| 27 | 35 | app.hasError = true |
| @@ -41,13 +49,54 @@ proc openRoom(name: string) = | ||
| 41 | 49 | # Opening a room is reading it: the marker moves to the newest line here. |
| 42 | 50 | 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 | 92 | proc connectNow() = |
| 45 | 93 | if app.formHost.strip().len == 0: |
| 46 | 94 | 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 | 96 | setError("A nickname is required."); return |
| 49 | - app.connecting = true | |
| 50 | 97 | app.hasError = false |
| 98 | + if not signIn(): return | |
| 99 | + app.connecting = true | |
| 51 | 100 | app.status = "Connecting to " & app.formHost & ":" & app.formPort & |
| 52 | 101 | (if app.formTls: " over TLS" else: "") & "…" |
| 53 | 102 | let port = try: parseInt(app.formPort.strip()) |
| @@ -253,12 +302,7 @@ proc drain*() = | ||
| 253 | 302 | trace("status", e) |
| 254 | 303 | if e == "open": |
| 255 | 304 | # 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`. | |
| 262 | 306 | send("CAP LS 302") |
| 263 | 307 | send("NICK " & app.formNick) |
| 264 | 308 | send("USER " & app.formNick & " 0 * :frq") |
| @@ -287,14 +331,25 @@ proc drain*() = | ||
| 287 | 331 | let (v, ok2) = tagValue(p.tags, "msgid") |
| 288 | 332 | if ok2: v else: "" |
| 289 | 333 | |
| 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 | |
| 297 | 351 | |
| 352 | + case p.command | |
| 298 | 353 | of "001": |
| 299 | 354 | app.connecting = false |
| 300 | 355 | 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 tr | 18 | 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 = msg | 34 | app.error = msg |
| 27 | app.hasError = true | 35 | 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].markRead | 50 | 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."); return | 94 | 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."); return | 96 | setError("A nickname is required."); return |
| 49 | - app.connecting = true | ||
| 50 | app.hasError = false | 97 | 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 the | 304 | # 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.command | 334 | + # 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 — so | 336 | + # conversation rather than anything a screen reads. |
| 293 | - # the negotiation is ended immediately. A CAP LS with no END leaves the | 337 | + 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 = false | 354 | app.connecting = false |
| 300 | app.status = "Connected as " & app.formNick | 355 | app.status = "Connected as " & app.formNick |
modified
nim/src/frq/screens/connect.nim +6 -2 | @@ -52,8 +52,12 @@ func authFields(s: State): Node = | ||
| 52 | 52 | of amBluesky: |
| 53 | 53 | result = vbox(%*{"spacing": 6}, |
| 54 | 54 | 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."), | |
| 57 | 61 | label("Handle"), |
| 58 | 62 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", |
| 59 | 63 | 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": | ||
| 46 | 46 | check "handle" in t.keys("entry") |
| 47 | 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 | 54 | test "app-password asks for both, and says where to make one": |
| 50 | 55 | s.authMode = amAppPassword |
| 51 | 56 | 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 = amAppPassword | 55 | s.authMode = amAppPassword |
| 51 | let t = cs.connectScreen(s) | 56 | let t = cs.connectScreen(s) |