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

thandshake.nim · 137 lines · 5.4 KBNim Blame HistoryRaw
SASL, and a screen that stops lying about it e50cbc7 nandi 14h ago1## CAP negotiation and the SASL payload. No network: every case here is a
2## line in and lines out.
3
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 13h ago4import std/[json, sets, strutils, unittest]
SASL, and a screen that stops lying about it e50cbc7 nandi 14h ago5import frq/[ircparse, atproto, handshake]
6
7proc caps0(): HashSet[string] = initHashSet[string]()
8
9proc guest(): Session = Session(kind: skNone)
10proc signedIn(): Session =
11 Session(kind: skPdsSession, did: "did:plc:abc", accessJwt: "jwt-123",
12 pds: "https://pds.example")
13
14suite "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
28suite "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
37suite "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
56suite "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
70suite "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
110suite "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
121suite "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
131suite "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")) == ""