| SASL, and a screen that stops lying about it e50cbc7 nandi 16h ago | 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")) == "" |