Add SASL state machines (PLAIN, EXTERNAL, SCRAM-SHA-256)
(sigil irc sasl) — chunked AUTHENTICATE wire helpers and pure- value state machines for both client and server sides. Handles the IRCv3 400-byte base64 chunking spec including the empty-+ terminator for exact-multiple payloads.
Mechanism dispatch: PLAIN + EXTERNAL ship complete here.
SCRAM-SHA-256 dispatches to a pluggable driver via the
`scram-driver` slot on the state. Client API:
make-sasl-client-state mechanism: ... authcid: ... password: ...
sasl-client-start -> "AUTHENTICATE <mech>\r\n"
sasl-client-advance state msg -> outbound lines Server API:
make-sasl-server-state supported-mechanisms: ... verify: ...
sasl-server-advance state msg client-prefix: ... -> outbound lines result symbols: 'success / 'failure / 'aborted / 'too-long / 'already(sigil irc sasl-scram) — SCRAM-SHA-256 driver per RFC 5802 + RFC 7677. Both client and server flows.
Server side uses a `fetch` callback that returns a stored
scram-credentials record (salt-b64, iterations, stored-key,
server-key) — never touches plaintext passwords. Plaintext passwords are needed only at user registration
via `derive-scram-credentials password salt iterations`.
Persist the result; the password can then be discarded.Crypto dependencies (sigil-crypto v0.15.0+): hmac-sha256-bytes, pbkdf2-sha256, sha256-bytes, base64-encode, base64-decode, random-bytes.
The SCRAM end-to-end tests guard on crypto-primitive availability via runtime probe and skip gracefully when the running sigil binary doesn't yet bundle sigil-crypto v0.15.0 natives — avoiding a hard dependency on a particular sigil release while keeping the protocol layer ready.
src/sigil/irc/sasl-scram.sgl | 535 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/irc/sasl.sgl | 484 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-sasl-scram.sgl | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-sasl.sgl | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1392 insertions(+)src/sigil/irc/sasl-scram.sgladded
;;; (sigil irc sasl-scram) - SCRAM-SHA-256 mechanism for SASL;;;;;; Implements RFC 5802 + RFC 7677 (SCRAM with SHA-256). The wire dance;;; is layered inside SASL's AUTHENTICATE/chunked-base64 envelope:;;;;;; C: AUTHENTICATE SCRAM-SHA-256;;; S: AUTHENTICATE +;;; C: <client-first-message>;;; n,,n=alice,r=<client-nonce>;;; S: <server-first-message>;;; r=<combined-nonce>,s=<base64-salt>,i=<iterations>;;; C: <client-final-message>;;; c=<base64-channel-binding>,r=<combined-nonce>,p=<base64-proof>;;; S: <server-final-message>;;; v=<base64-server-signature>;;; S: 903 <client> :SASL authentication successful;;;;;; Where:;;; SaltedPassword = PBKDF2-SHA-256(password, salt, iterations, 32);;; ClientKey = HMAC-SHA-256(SaltedPassword, "Client Key");;; StoredKey = SHA-256(ClientKey);;; AuthMessage = client-first-bare + "," + server-first;;; + "," + client-final-no-proof;;; ClientSignature = HMAC-SHA-256(StoredKey, AuthMessage);;; ClientProof = ClientKey XOR ClientSignature;;; ServerKey = HMAC-SHA-256(SaltedPassword, "Server Key");;; ServerSignature = HMAC-SHA-256(ServerKey, AuthMessage);;;;;; This module exports two driver functions that plug into the core;;; (sigil irc sasl) state-machine via the `scram-driver` field. The;;; consumer constructs a sasl state with the SCRAM driver attached;;;; the core SASL machinery dispatches into the driver at the right;;; phases.;;;;;; Server-side `scram-fetch` callback:;;; (scram-fetch authcid) -> (list <salt-b64> <iterations> <stored-key-bv> <server-key-bv>);;; or #f for unknown user;;;;;; A consumer-supplied scram-credentials helper computes those four;;; values from a plaintext password (typically called once at user;;; registration; the salted password is stored, never the plaintext).(define-library (sigil irc sasl-scram) (import (sigil core) (sigil string) (sigil math) (sigil struct) (sigil crypto) (sigil irc message) (sigil irc sasl) (sigil irc numerics)) (export ;; Drivers — set on a sasl-{client,server}-state via scram-driver: scram-client-driver scram-server-driver ;; Make a fully-wired client SASL state for SCRAM-SHA-256 make-scram-client-state ;; Make a fully-wired server SASL state for SCRAM-SHA-256 make-scram-server-state ;; Credential derivation helper (server-side / registration) scram-credentials scram-credentials-salt-b64 scram-credentials-iterations scram-credentials-stored-key scram-credentials-server-key derive-scram-credentials) (begin ;; ============================================================ ;; Credential record (server-side persistence) ;; ============================================================ ;;; The server-side stored SCRAM verifier for one user. The plaintext ;;; password is NOT stored; only these four values, which are ;;; sufficient to verify a client's proof without the password. (define-struct scram-credentials (salt-b64) ; string — base64 of the per-user salt (iterations) ; integer — work factor for PBKDF2 (stored-key) ; bytevector — H(ClientKey), 32 bytes (server-key)) ; bytevector — HMAC(SaltedPassword, "Server Key"), 32 bytes ;;; Derive SCRAM credentials from a plaintext password + per-user ;;; salt + iteration count. Call this at user registration; persist ;;; the resulting record. The plaintext password is never needed ;;; again on the server side. ;;; ;;; ```scheme ;;; (define creds (derive-scram-credentials "alice-password" ;;; (random-bytes 16) ;;; 4096)) ;;; ;; persist (scram-credentials-salt-b64 creds), -iterations, ;;; ;; -stored-key, -server-key ;;; ``` (define (derive-scram-credentials password salt-bytes iterations) (: string? bytevector? integer? -> scram-credentials?) (let* ((salted (pbkdf2-sha256 password salt-bytes iterations 32)) (client-key (hmac-sha256-bytes salted "Client Key")) (stored-key (sha256-bytes client-key)) (server-key (hmac-sha256-bytes salted "Server Key"))) (scram-credentials salt-b64: (base64-encode salt-bytes) iterations: iterations stored-key: stored-key server-key: server-key))) ;; ============================================================ ;; Bytewise helpers (raw byte manipulation) ;; ============================================================ (define (string->bytevector s) (let* ((len (string-length s)) (bv (make-bytevector len 0))) (let loop ((i 0)) (cond ((>= i len) bv) (else (bytevector-u8-set! bv i (char->integer (string-ref s i))) (loop (+ i 1))))))) (define (bytevector=? a b) (let ((la (bytevector-length a)) (lb (bytevector-length b))) (and (= la lb) (let loop ((i 0)) (cond ((>= i la) #t) ((= (bytevector-u8-ref a i) (bytevector-u8-ref b i)) (loop (+ i 1))) (else #f)))))) (define (xor-bytes a b) (let* ((len (min (bytevector-length a) (bytevector-length b))) (out (make-bytevector len 0))) (let loop ((i 0)) (cond ((>= i len) out) (else (bytevector-u8-set! out i (bitwise-xor (bytevector-u8-ref a i) (bytevector-u8-ref b i))) (loop (+ i 1))))))) ;; ============================================================ ;; SCRAM message-format helpers ;; ============================================================ ;;; Parse an attribute string `k1=v1,k2=v2,...` into an alist. (define (parse-scram-attrs str) (map (lambda (entry) (let ((eq-pos (string-index entry (lambda (c) (char=? c #\=))))) (if eq-pos (cons (substring entry 0 eq-pos) (substring entry (+ eq-pos 1) (string-length entry))) (cons entry "")))) (string-split str ","))) (define (scram-attr-ref attrs key) (let loop ((xs attrs)) (cond ((null? xs) #f) ((equal? (caar xs) key) (cdar xs)) (else (loop (cdr xs)))))) ;; Generate a random nonce: 18 random bytes -> base64 -> strip ;; padding. Yields a 24-char URL-safe-ish nonce. Per RFC 5802 the ;; nonce should be a printable string excluding the `,` separator. (define (scram-make-nonce) (let* ((bytes (random-bytes 18)) (b64 (base64-encode bytes))) ;; base64 alphabet excludes ',', so no further sanitation needed. b64)) ;; Find the end of the gs2-header in a client-first message. ;; gs2-header is `n,,` or `n,a=user,` — ends after the second comma. (define (scram-find-gs2-end raw) (let ((len (string-length raw))) (let loop ((i 0) (commas 0)) (cond ((>= i len) len) ((>= commas 2) i) ((char=? (string-ref raw i) #\,) (loop (+ i 1) (+ commas 1))) (else (loop (+ i 1) commas)))))) ;; SASLprep stub: pass-through. Real SASLprep is RFC 4013 stringprep ;; (Unicode normalization + case-fold + bidi + prohibited-chars). ;; For the practical IRC username space (printable ASCII) the ;; identity mapping is sufficient; if Unicode usernames become ;; relevant, swap this for a real implementation. (define (scram-saslprep s) s) ;; ============================================================ ;; Client driver ;; ============================================================ ;; ;; The driver is invoked by (sigil irc sasl) at SCRAM-relevant ;; phases. It receives: ;; (driver state msg) ;; (driver state 'start) <- when client sends initial ;; ;; The state's `phase` is one of: ;; 'awaiting-server-+ -> we just sent AUTHENTICATE; got + back ;; 'awaiting-scram-r1 -> we sent client-first; awaiting server-first ;; 'awaiting-scram-r2 -> we sent client-final; awaiting server-final ;; ;; Returns a list of wire-format strings to send. ;;; Build a sasl-client-state pre-wired for SCRAM-SHA-256. (define (make-scram-client-state (keys: (authcid #f) (password #f) (authzid #f))) (: (authcid: string?) (password: string?) (authzid: any?) -> sasl-client-state?) (make-sasl-client-state mechanism: SASL-SCRAM-SHA-256 authcid: authcid password: password authzid: authzid scram-driver: scram-client-driver)) ;; Per-state SCRAM bookkeeping is held in a side table keyed by the ;; state object identity. Easier than extending sasl-client-state ;; with SCRAM-only fields. (define %client-scram-store '()) (define (client-scram-get state) (let loop ((xs %client-scram-store)) (cond ((null? xs) #f) ((eq? (caar xs) state) (cdar xs)) (else (loop (cdr xs)))))) (define (client-scram-set! state val) (set! %client-scram-store (cons (cons state val) (filter (lambda (e) (not (eq? (car e) state))) %client-scram-store)))) (define-struct scram-client-locals (nonce default: #f mutable: #t) (client-first-bare default: #f mutable: #t) (server-first default: #f mutable: #t) (server-signature-b64 default: #f mutable: #t)) (define (scram-client-driver state arg . rest) (cond ((eq? arg 'start) (client-send-client-first state)) ((irc-message? arg) (client-handle-message state arg)) (else '()))) (define (client-send-client-first state) (let* ((authcid (sasl-client-state-authcid state)) (nonce (scram-make-nonce)) (gs2-header "n,,") (client-first-bare (string-append "n=" (scram-saslprep authcid) ",r=" nonce)) (client-first (string-append gs2-header client-first-bare)) (locals (scram-client-locals nonce: nonce client-first-bare: client-first-bare))) (client-scram-set! state locals) (set-sasl-client-state-phase! state 'awaiting-scram-r1) (encode-authenticate-payload client-first))) (define (client-handle-message state msg) (let ((params (irc-message-params msg))) (cond ((eq? (irc-message-command msg) 'AUTHENTICATE) (let ((arg (and (pair? params) (car params)))) (cond ((eq? (sasl-client-state-phase state) 'awaiting-scram-r1) (client-process-server-first state arg)) ((eq? (sasl-client-state-phase state) 'awaiting-scram-r2) (client-process-server-final state arg)) (else '())))) (else '())))) (define (client-process-server-first state chunk) (let* ((server-first (decode-authenticate-chunks (list chunk))) (attrs (parse-scram-attrs server-first)) (server-nonce (scram-attr-ref attrs "r")) (salt-b64 (scram-attr-ref attrs "s")) (iter-str (scram-attr-ref attrs "i")) (iter (and iter-str (string->number iter-str))) (locals (client-scram-get state))) (cond ((or (not server-nonce) (not salt-b64) (not iter)) (client-fail state) (list "AUTHENTICATE *\r\n")) ((not (string-starts-with? server-nonce (scram-client-locals-nonce locals))) (client-fail state) (list "AUTHENTICATE *\r\n")) (else (set-scram-client-locals-server-first! locals server-first) (client-send-client-final state server-nonce salt-b64 iter))))) (define (client-send-client-final state server-nonce salt-b64 iter) (let* ((locals (client-scram-get state)) (password (sasl-client-state-password state)) (salt-decoded (base64-decode salt-b64)) (salt-bv (cond ((bytevector? salt-decoded) salt-decoded) ((string? salt-decoded) (string->bytevector salt-decoded)) (else (make-bytevector 0)))) (salted (pbkdf2-sha256 password salt-bv iter 32)) (client-key (hmac-sha256-bytes salted "Client Key")) (stored-key (sha256-bytes client-key)) (channel-binding-b64 (base64-encode "n,,")) (client-final-no-proof (string-append "c=" channel-binding-b64 ",r=" server-nonce)) (auth-message (string-append (scram-client-locals-client-first-bare locals) "," (scram-client-locals-server-first locals) "," client-final-no-proof)) (client-signature (hmac-sha256-bytes stored-key auth-message)) (client-proof (xor-bytes client-key client-signature)) (server-key (hmac-sha256-bytes salted "Server Key")) (server-signature (hmac-sha256-bytes server-key auth-message)) (server-signature-b64 (base64-encode server-signature)) (client-final (string-append client-final-no-proof ",p=" (base64-encode client-proof)))) (set-scram-client-locals-server-signature-b64! locals server-signature-b64) (set-sasl-client-state-phase! state 'awaiting-scram-r2) (encode-authenticate-payload client-final))) (define (client-process-server-final state chunk) (let* ((server-final (decode-authenticate-chunks (list chunk))) (attrs (parse-scram-attrs server-final)) (v (scram-attr-ref attrs "v")) (e (scram-attr-ref attrs "e")) (locals (client-scram-get state)) (expected (scram-client-locals-server-signature-b64 locals))) (cond (e (client-fail state) '()) ((and v (equal? v expected)) ;; Server is authenticated to the client. We're now waiting ;; for the IRCv3 numeric reply (903 / 904) to know how the ;; server sees us. Submit an empty AUTHENTICATE +. (set-sasl-client-state-phase! state 'awaiting-result) (list "AUTHENTICATE +\r\n")) (else ;; Server signature mismatch — server is not who they claim. (client-fail state) (list "AUTHENTICATE *\r\n"))))) (define (client-fail state) (set-sasl-client-state-result! state 'failure) (set-sasl-client-state-phase! state 'done)) ;; ============================================================ ;; Server driver ;; ============================================================ ;; ;; Dispatched as: ;; (driver state 'first raw client-prefix) ; got client-first ;; (driver state 'chunk chunk client-prefix) ; got client-final ;; ;; The server side stores per-state locals in a side table keyed by ;; state identity, mirroring the client side. (define %server-scram-store '()) (define (server-scram-get state) (let loop ((xs %server-scram-store)) (cond ((null? xs) #f) ((eq? (caar xs) state) (cdar xs)) (else (loop (cdr xs)))))) (define (server-scram-set! state val) (set! %server-scram-store (cons (cons state val) (filter (lambda (e) (not (eq? (car e) state))) %server-scram-store)))) (define-struct scram-server-locals (server-nonce default: #f mutable: #t) (client-first-bare default: #f mutable: #t) (server-first default: #f mutable: #t) (stored-key default: #f mutable: #t) (server-key default: #f mutable: #t)) ;;; Construct a sasl-server-state pre-wired for SCRAM-SHA-256. ;;; `fetch` is `(lambda (authcid) -> scram-credentials? or #f)`. (define (make-scram-server-state (keys: (server-name "*") (fetch #f) (additional-mechanisms '()))) (: (server-name: string?) (fetch: any?) (additional-mechanisms: list?) -> sasl-server-state?) (unless fetch (error "make-scram-server-state: fetch: callback is required")) (let* ((mechs (cons SASL-SCRAM-SHA-256 additional-mechanisms)) (state (make-sasl-server-state supported-mechanisms: mechs server-name: server-name scram-driver: scram-server-driver))) ;; Stash the fetch callback on the state via the side table. (server-scram-set! state (scram-server-locals)) ;; Also stash fetch in a parallel binding — we need it during ;; first-message handling. (scram-server-attach-fetch! state fetch) state)) (define %server-fetch-store '()) (define (scram-server-attach-fetch! state fetch) (set! %server-fetch-store (cons (cons state fetch) (filter (lambda (e) (not (eq? (car e) state))) %server-fetch-store)))) (define (scram-server-fetch state) (let loop ((xs %server-fetch-store)) (cond ((null? xs) #f) ((eq? (caar xs) state) (cdar xs)) (else (loop (cdr xs)))))) (define (scram-server-driver state phase . args) (cond ((eq? phase 'first) (let ((raw (car args)) (client-prefix (cadr args))) (server-process-client-first state raw client-prefix))) ((eq? phase 'chunk) (let ((chunk (car args)) (client-prefix (cadr args))) (server-process-client-final state chunk client-prefix))) (else '()))) (define (server-process-client-first state raw client-prefix) (let* ((gs2-end (scram-find-gs2-end raw)) (client-first-bare (substring raw gs2-end (string-length raw))) (attrs (parse-scram-attrs client-first-bare)) (n (scram-attr-ref attrs "n")) (r (scram-attr-ref attrs "r"))) (cond ((or (not n) (not r)) (server-fail state ERR-SASLFAIL "Malformed SCRAM client-first" client-prefix)) (else (set-sasl-server-state-authcid! state n) (let* ((fetch (scram-server-fetch state)) (creds (and fetch (fetch n)))) (cond ((not creds) (server-fail state ERR-SASLFAIL "Unknown user" client-prefix)) (else (let* ((server-nonce (string-append r (scram-make-nonce))) (server-first (string-append "r=" server-nonce ",s=" (scram-credentials-salt-b64 creds) ",i=" (number->string (scram-credentials-iterations creds)))) (locals (or (server-scram-get state) (scram-server-locals)))) (set-scram-server-locals-server-nonce! locals server-nonce) (set-scram-server-locals-client-first-bare! locals client-first-bare) (set-scram-server-locals-server-first! locals server-first) (set-scram-server-locals-stored-key! locals (scram-credentials-stored-key creds)) (set-scram-server-locals-server-key! locals (scram-credentials-server-key creds)) (server-scram-set! state locals) (set-sasl-server-state-phase! state 'awaiting-mech-step) (encode-authenticate-payload server-first))))))))) (define (server-process-client-final state chunk client-prefix) (let* ((client-final (decode-authenticate-chunks (list chunk))) (attrs (parse-scram-attrs client-final)) (proof-b64 (scram-attr-ref attrs "p")) (channel-binding (scram-attr-ref attrs "c")) (r (scram-attr-ref attrs "r")) (locals (server-scram-get state))) (cond ((or (not proof-b64) (not r)) (server-fail state ERR-SASLFAIL "Malformed SCRAM client-final" client-prefix)) ((not (equal? r (scram-server-locals-server-nonce locals))) (server-fail state ERR-SASLFAIL "SCRAM nonce mismatch" client-prefix)) (else (let* ((client-final-no-proof (string-append "c=" channel-binding ",r=" r)) (auth-message (string-append (scram-server-locals-client-first-bare locals) "," (scram-server-locals-server-first locals) "," client-final-no-proof)) (stored-key (scram-server-locals-stored-key locals)) (server-key (scram-server-locals-server-key locals)) (client-signature (hmac-sha256-bytes stored-key auth-message)) (proof-bytes (let ((d (base64-decode proof-b64))) (cond ((bytevector? d) d)Showing the first 500 of 536 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
src/sigil/irc/sasl.sgladded
;;; (sigil irc sasl) - SASL authentication state machines;;;;;; SASL is the AUTHENTICATE-based authentication protocol used by;;; IRCv3. The on-the-wire pattern, in both directions, is:;;;;;; <- AUTHENTICATE PLAIN (client picks mechanism);;; -> AUTHENTICATE + (server says: send payload);;; <- AUTHENTICATE <base64-chunk> (client sends payload, in;;; AUTHENTICATE <next-chunk> 400-byte base64 chunks);;; ...;;; AUTHENTICATE + (last chunk shorter than 400;;; OR an explicit empty `+`;;; when the payload was an;;; exact multiple of 400);;; -> 900 / 903 (success — RPL-LOGGEDIN /;;; RPL-SASLSUCCESS);;; OR 902/904/905/906/907 (failure);;;;;; Mechanisms supported here:;;;;;; PLAIN base64(authzid \0 authcid \0 password) — ships fully;;; EXTERNAL base64(authzid) (TLS client cert) — ships fully;;; SCRAM-SHA-256 RFC 5802 challenge-response — see (sigil irc sasl-scram);;;;;; The state machines are pure values: state + `advance` returning;;; outbound wire strings. The consumer owns I/O.(define-library (sigil irc sasl) (import (sigil core) (sigil math) (sigil string) (sigil struct) (sigil crypto) (sigil irc message) (sigil irc numerics)) (export ;; Mechanism names SASL-PLAIN SASL-EXTERNAL SASL-SCRAM-SHA-256 ;; Chunked AUTHENTICATE encoding encode-authenticate-payload decode-authenticate-chunks ;; PLAIN payload helpers plain-payload parse-plain-payload ;; Client SASL state sasl-client-state sasl-client-state? make-sasl-client-state sasl-client-state-mechanism sasl-client-state-phase sasl-client-state-result sasl-client-state-authcid sasl-client-start sasl-client-advance ;; Server SASL state sasl-server-state sasl-server-state? make-sasl-server-state sasl-server-state-mechanism sasl-server-state-phase sasl-server-state-result sasl-server-state-authcid sasl-server-advance) (begin (define SASL-PLAIN "PLAIN") (define SASL-EXTERNAL "EXTERNAL") (define SASL-SCRAM-SHA-256 "SCRAM-SHA-256") (define %nul (string #\null)) ;; ============================================================ ;; Chunked AUTHENTICATE payload encoding ;; ============================================================ ;;; Encode a raw payload (string) as a list of `AUTHENTICATE` lines ;;; per the IRCv3 SASL chunking rules. Each line carries up to 400 ;;; base64 chars; if the encoded payload length is an exact multiple ;;; of 400, an additional `AUTHENTICATE +\r\n` is appended as the ;;; explicit terminator. An empty payload produces a single ;;; `AUTHENTICATE +\r\n`. ;;; ;;; Returns a list of complete wire-format lines (each ending in ;;; CRLF). The consumer writes them in order. (define (encode-authenticate-payload payload) (: string? -> list?) (let ((encoded (base64-encode payload))) (if (string=? encoded "") (list "AUTHENTICATE +\r\n") (let* ((len (string-length encoded)) (chunk-size 400) (rem (remainder len chunk-size))) (let loop ((i 0) (acc '())) (cond ((>= i len) (if (= rem 0) (reverse (cons "AUTHENTICATE +\r\n" acc)) (reverse acc))) (else (let* ((end (min (+ i chunk-size) len)) (chunk (substring encoded i end)) (line (string-append "AUTHENTICATE " chunk "\r\n"))) (loop end (cons line acc)))))))))) ;;; Reassemble a list of incoming AUTHENTICATE chunk strings (just the ;;; base64 portion of each, without the `AUTHENTICATE` keyword) into ;;; the decoded raw payload. A trailing `+` chunk is the terminator ;;; and is stripped before decoding. A single `+` chunk by itself is ;;; treated as an empty payload signal. ;;; ;;; Returns a string. (sigil-crypto's `base64-decode` returns a ;;; bytevector for arbitrary inputs; we convert here so the payload ;;; is consumable by `string-split` etc.) (define (decode-authenticate-chunks chunks) (: list? -> string?) (let* ((stripped (if (and (pair? chunks) (equal? (car (reverse chunks)) "+")) (reverse (cdr (reverse chunks))) chunks)) (joined (apply string-append stripped))) (if (string=? joined "") "" (let ((decoded (base64-decode joined))) (cond ((string? decoded) decoded) ((bytevector? decoded) (bytevector->utf8-string decoded)) (else "")))))) ;; Convert a bytevector to a string, treating each byte as a Unicode ;; code point in the 0..255 range (latin-1-ish). For SASL PLAIN / ;; EXTERNAL payloads this is correct because the on-wire bytes are ;; UTF-8 already; for SCRAM, all interesting bytes are within ASCII. (define (bytevector->utf8-string bv) (let ((len (bytevector-length bv))) (let loop ((i 0) (acc '())) (cond ((>= i len) (apply string (reverse acc))) (else (loop (+ i 1) (cons (integer->char (bytevector-u8-ref bv i)) acc))))))) ;; ============================================================ ;; Mechanism payload helpers ;; ============================================================ ;;; Build the raw PLAIN payload (pre-base64): authzid \0 authcid \0 password. ;;; `authzid` may be `#f` (empty); `authcid` and `password` are required. (define (plain-payload authzid authcid password) (: any? string? string? -> string?) (string-append (or authzid "") %nul authcid %nul password)) ;;; Parse a raw PLAIN payload back into (authzid authcid password). ;;; Returns three values; if malformed, returns three #f values. (define (parse-plain-payload raw) (: string? -> any?) (let ((parts (string-split raw %nul))) (if (= (length parts) 3) (values (car parts) (cadr parts) (caddr parts)) (values #f #f #f)))) ;; ============================================================ ;; Client state ;; ============================================================ ;; ;; Phases: ;; 'init — call sasl-client-start to send mechanism ;; 'awaiting-server-+ — sent AUTHENTICATE <mech>; awaiting + ;; 'awaiting-result — sent payload; awaiting 900/903/904 etc. ;; 'done — terminal; check `result` ;; ;; `result` after 'done is one of: ;; 'success authentication succeeded (903 RPL-SASLSUCCESS) ;; 'failure server rejected (904) ;; 'aborted server aborted (906) ;; 'too-long payload too long (905) ;; 'already already authenticated (907) ;; 'mech-fail client could not produce payload (e.g. missing creds) (define-struct sasl-client-state (mechanism) ; string (authcid default: #f) ; username (PLAIN/SCRAM) (password default: #f) ; password (PLAIN/SCRAM) (authzid default: #f) ; optional impersonation id (phase default: 'init mutable: #t) (result default: #f mutable: #t) ;; Provided by (sigil irc sasl-scram) when SCRAM is in use. ;; The protocol layer holds a reference but does not call into it ;; directly so this module remains crypto-free for PLAIN/EXTERNAL. (scram-driver default: #f mutable: #t)) ;;; Construct a client SASL state. `mechanism` must be one of the ;;; SASL-* constants. For PLAIN/SCRAM-SHA-256 supply `authcid:` and ;;; `password:`; for EXTERNAL supply only `authzid:` if you want to ;;; assert a specific identity (otherwise empty = use the cert's CN). (define (make-sasl-client-state (keys: (mechanism #f) (authcid #f) (password #f) (authzid #f) (scram-driver #f))) (: (mechanism: string?) (authcid: any?) (password: any?) (authzid: any?) (scram-driver: any?) -> sasl-client-state?) (unless mechanism (error "make-sasl-client-state: mechanism: is required")) (sasl-client-state mechanism: mechanism authcid: authcid password: password authzid: authzid scram-driver: scram-driver)) ;;; Begin SASL: returns the `AUTHENTICATE <mech>\r\n` line to send, ;;; transitions to the `'awaiting-server-+` phase. The CAP layer is ;;; expected to have already negotiated `sasl`. (define (sasl-client-start state) (: sasl-client-state? -> string?) (set-sasl-client-state-phase! state 'awaiting-server-+) (string-append "AUTHENTICATE " (sasl-client-state-mechanism state) "\r\n")) ;;; Process one incoming message and advance the state. Returns a ;;; list of wire-format strings to send. (define (sasl-client-advance state msg) (: sasl-client-state? irc-message? -> list?) (let ((cmd (irc-message-command msg))) (cond ((eq? cmd 'AUTHENTICATE) (client-handle-authenticate state msg)) ((eq? cmd (string->symbol RPL-LOGGEDIN)) '()) ((eq? cmd (string->symbol RPL-LOGGEDOUT)) '()) ((eq? cmd (string->symbol RPL-SASLSUCCESS)) (set-sasl-client-state-result! state 'success) (set-sasl-client-state-phase! state 'done) '()) ((eq? cmd (string->symbol ERR-SASLFAIL)) (set-sasl-client-state-result! state 'failure) (set-sasl-client-state-phase! state 'done) '()) ((eq? cmd (string->symbol ERR-SASLTOOLONG)) (set-sasl-client-state-result! state 'too-long) (set-sasl-client-state-phase! state 'done) '()) ((eq? cmd (string->symbol ERR-SASLABORTED)) (set-sasl-client-state-result! state 'aborted) (set-sasl-client-state-phase! state 'done) '()) ((eq? cmd (string->symbol ERR-SASLALREADY)) (set-sasl-client-state-result! state 'already) (set-sasl-client-state-phase! state 'done) '()) (else '())))) (define (client-handle-authenticate state msg) (let* ((params (irc-message-params msg)) (arg (and (pair? params) (car params)))) (cond ((equal? arg "+") (client-send-initial-payload state)) ;; Mechanism-specific challenge handling (e.g. SCRAM): ;; delegate to the scram-driver if present. ((sasl-client-state-scram-driver state) (let ((handler (sasl-client-state-scram-driver state))) (handler state msg))) (else '())))) (define (client-send-initial-payload state) (let ((mech (sasl-client-state-mechanism state))) (cond ((string=? mech SASL-PLAIN) (let ((authcid (sasl-client-state-authcid state)) (password (sasl-client-state-password state))) (cond ((or (not authcid) (not password)) (set-sasl-client-state-result! state 'mech-fail) (set-sasl-client-state-phase! state 'done) (list "AUTHENTICATE *\r\n")) (else (set-sasl-client-state-phase! state 'awaiting-result) (encode-authenticate-payload (plain-payload (sasl-client-state-authzid state) authcid password)))))) ((string=? mech SASL-EXTERNAL) (set-sasl-client-state-phase! state 'awaiting-result) (encode-authenticate-payload (or (sasl-client-state-authzid state) ""))) ((string=? mech SASL-SCRAM-SHA-256) (cond ((sasl-client-state-scram-driver state) => (lambda (handler) (handler state 'start))) (else (set-sasl-client-state-result! state 'mech-fail) (set-sasl-client-state-phase! state 'done) (list "AUTHENTICATE *\r\n")))) (else (set-sasl-client-state-result! state 'mech-fail) (set-sasl-client-state-phase! state 'done) (list "AUTHENTICATE *\r\n"))))) ;; ============================================================ ;; Server state ;; ============================================================ ;; ;; Phases: ;; 'init — awaiting AUTHENTICATE <mechanism> ;; 'awaiting-payload — sent +; awaiting client's payload chunks ;; 'awaiting-mech-step — mechanism-specific intermediate (e.g. SCRAM) ;; 'done — terminal; check `result` ;; ;; The server is constructed with a `verify` callback that takes ;; (mechanism authzid authcid password-or-#f) and returns a result ;; symbol: 'success or 'failure. For SCRAM, the consumer wires up ;; (sigil irc sasl-scram) which provides its own server driver. (define-struct sasl-server-state (supported-mechanisms default: '("PLAIN")) ; list of allowed mechanism names (mechanism default: #f mutable: #t) ; selected by client (phase default: 'init mutable: #t) (result default: #f mutable: #t) (verify default: #f) ; (lambda (mech authzid authcid pw) ...) (chunks default: '() mutable: #t) (authcid default: #f mutable: #t) (server-name default: "*") ;; Provided by (sigil irc sasl-scram) when SCRAM is in use. (scram-driver default: #f mutable: #t)) (define (make-sasl-server-state (keys: (supported-mechanisms '("PLAIN")) (verify #f) (server-name "*") (scram-driver #f))) (: (supported-mechanisms: list?) (verify: any?) (server-name: string?) (scram-driver: any?) -> sasl-server-state?) (sasl-server-state supported-mechanisms: supported-mechanisms verify: verify server-name: server-name scram-driver: scram-driver)) ;;; Process one incoming AUTHENTICATE and advance the state. ;;; Returns a list of wire-format strings to send back. `client-prefix` ;;; is the client's nick (or `*` if unknown) used in numeric prefixes. (define (sasl-server-advance state msg (keys: (client-prefix "*"))) (: sasl-server-state? irc-message? (client-prefix: string?) -> list?) (let ((cmd (irc-message-command msg))) (cond ((eq? cmd 'AUTHENTICATE) (server-handle-authenticate state msg client-prefix)) (else '())))) (define (server-handle-authenticate state msg client-prefix) (let* ((params (irc-message-params msg)) (arg (and (pair? params) (car params)))) (cond ((not arg) '()) ((equal? arg "*") ;; Client abort (set-sasl-server-state-result! state 'aborted) (set-sasl-server-state-phase! state 'done) (list (server-numeric-line client-prefix ERR-SASLABORTED "SASL authentication aborted"))) ((eq? (sasl-server-state-phase state) 'init) (server-select-mechanism state arg client-prefix)) (else (server-receive-chunk state arg client-prefix))))) (define (server-select-mechanism state mech client-prefix) (cond ((not (member mech (sasl-server-state-supported-mechanisms state))) (set-sasl-server-state-result! state 'failure) (set-sasl-server-state-phase! state 'done) (list (server-numeric-line client-prefix ERR-SASLFAIL "SASL mechanism not supported"))) (else (set-sasl-server-state-mechanism! state mech) (set-sasl-server-state-phase! state 'awaiting-payload) (list "AUTHENTICATE +\r\n")))) (define (server-receive-chunk state chunk client-prefix) (cond ((eq? (sasl-server-state-phase state) 'awaiting-payload) (server-collect-and-process state chunk client-prefix)) ((eq? (sasl-server-state-phase state) 'awaiting-mech-step) ;; Delegate mid-flow chunks to the SCRAM driver if present. (cond ((sasl-server-state-scram-driver state) => (lambda (driver) (driver state 'chunk chunk client-prefix))) (else '()))) (else '()))) (define (server-collect-and-process state chunk client-prefix) (let* ((is-terminator (equal? chunk "+")) (is-final (or is-terminator (< (string-length chunk) 400))) (collected (append (sasl-server-state-chunks state) (list chunk)))) (set-sasl-server-state-chunks! state collected) (if (not is-final) '() (let ((raw (decode-authenticate-chunks collected))) (set-sasl-server-state-chunks! state '()) (server-process-payload state raw client-prefix))))) (define (server-process-payload state raw client-prefix) (let ((mech (sasl-server-state-mechanism state))) (cond ((string=? mech SASL-PLAIN) (server-process-plain state raw client-prefix)) ((string=? mech SASL-EXTERNAL) (server-process-external state raw client-prefix)) ((string=? mech SASL-SCRAM-SHA-256) (cond ((sasl-server-state-scram-driver state) => (lambda (driver) (driver state 'first raw client-prefix))) (else (set-sasl-server-state-result! state 'failure) (set-sasl-server-state-phase! state 'done) (list (server-numeric-line client-prefix ERR-SASLFAIL "SCRAM driver not configured"))))) (else (set-sasl-server-state-result! state 'failure) (set-sasl-server-state-phase! state 'done) (list (server-numeric-line client-prefix ERR-SASLFAIL "Unsupported SASL mechanism")))))) (define (server-process-plain state raw client-prefix) (let-values (((authzid authcid password) (parse-plain-payload raw))) (cond ((not authcid) (set-sasl-server-state-result! state 'failure) (set-sasl-server-state-phase! state 'done) (list (server-numeric-line client-prefix ERR-SASLFAIL "Malformed PLAIN payload"))) (else (set-sasl-server-state-authcid! state authcid) (let ((verdict (and (sasl-server-state-verify state) ((sasl-server-state-verify state) SASL-PLAIN authzid authcid password)))) (server-finish state verdict authcid client-prefix)))))) (define (server-process-external state raw client-prefix) ;; raw is the asserted authzid (possibly empty). Verifier resolves ;; the certificate identity and decides. (let* ((authzid raw)) (set-sasl-server-state-authcid! state authzid) (let ((verdict (and (sasl-server-state-verify state) ((sasl-server-state-verify state) SASL-EXTERNAL authzid authzid #f)))) (server-finish state verdict authzid client-prefix)))) (define (server-finish state verdict authcid client-prefix) (cond ((eq? verdict 'success) (set-sasl-server-state-result! state 'success) (set-sasl-server-state-phase! state 'done) (list (server-loggedin-line client-prefix authcid) (server-numeric-line client-prefix RPL-SASLSUCCESS "SASL authentication successful"))) (else (set-sasl-server-state-result! state 'failure) (set-sasl-server-state-phase! state 'done) (list (server-numeric-line client-prefix ERR-SASLFAIL "SASL authentication failed"))))) ;; ============================================================ ;; Wire-line helpers ;; ============================================================ (define (server-numeric-line client-prefix code text) (string-append code " " client-prefix " :" text "\r\n")) (define (server-loggedin-line client-prefix authcid) (string-append RPL-LOGGEDIN " " client-prefix " " client-prefix " " authcid " :You are now logged in as " authcid "\r\n")) ))test/test-sasl-scram.sgladded
;;; Tests for SCRAM-SHA-256 SASL mechanism (RFC 5802 / RFC 7677).;;;;;; The protocol-layer module loads regardless of crypto-primitive;;; availability. The end-to-end handshake tests require the;;; `pbkdf2-sha256` and `hmac-sha256-bytes` natives shipped in;;; sigil-crypto v0.15.0+; older runtimes skip those tests;;; gracefully. Once a sigil release that bundles sigil-crypto;;; v0.15.0 lands, the tests activate automatically.(import (sigil test) (sigil core) (sigil crypto) (sigil irc message) (sigil irc sasl) (sigil irc sasl-scram) (sigil irc numerics))(define (scram-natives-available?) ;; pbkdf2-sha256 + hmac-sha256-bytes are both v0.15.0 additions. ;; If either is unbound the bytecode compiler raised at this file's ;; load — but if we got here the symbols must be at least addressable. ;; Probe by attempting a minimal call inside a guard. (guard (exn (else #f)) (let ((bv (hmac-sha256-bytes "k" "m"))) (and (bytevector? bv) (= 32 (bytevector-length bv))))))(test-group "sasl-scram module loads" (test "scram-client-driver is a procedure" (assert-true (procedure? scram-client-driver))) (test "scram-server-driver is a procedure" (assert-true (procedure? scram-server-driver))) (test "make-scram-client-state is a procedure" (assert-true (procedure? make-scram-client-state))) (test "make-scram-server-state is a procedure" (assert-true (procedure? make-scram-server-state))) (test "derive-scram-credentials is a procedure" (assert-true (procedure? derive-scram-credentials))));; ============================================================;; Crypto-dependent tests — guarded;; ============================================================;;;; The remaining tests exercise the actual cryptographic verification;; flow. They require sigil-crypto v0.15.0+ (`pbkdf2-sha256` +;; `hmac-sha256-bytes`). When the running sigil binary doesn't;; bundle those natives, we mark the tests pending.(cond ((scram-natives-available?) (test-group "derive-scram-credentials" (test "produces all four fields" (let* ((salt (random-bytes 16)) (creds (derive-scram-credentials "password" salt 1000))) (assert-true (string? (scram-credentials-salt-b64 creds))) (assert-equal 1000 (scram-credentials-iterations creds)) (assert-true (bytevector? (scram-credentials-stored-key creds))) (assert-equal 32 (bytevector-length (scram-credentials-stored-key creds))) (assert-true (bytevector? (scram-credentials-server-key creds))) (assert-equal 32 (bytevector-length (scram-credentials-server-key creds))))) (test "deterministic for same inputs" (let* ((salt (random-bytes 16)) (a (derive-scram-credentials "password" salt 1000)) (b (derive-scram-credentials "password" salt 1000))) (assert-equal (scram-credentials-stored-key a) (scram-credentials-stored-key b)) (assert-equal (scram-credentials-server-key a) (scram-credentials-server-key b)))) (test "different password yields different keys" (let* ((salt (random-bytes 16)) (a (derive-scram-credentials "alice-pw" salt 1000)) (b (derive-scram-credentials "BAD-pw" salt 1000))) (assert-false (equal? (scram-credentials-stored-key a) (scram-credentials-stored-key b)))))) (test-group "SCRAM-SHA-256 end-to-end handshake" (test "correct password → both sides succeed" (let* ((password "correct horse battery staple") (salt (random-bytes 16)) (creds (derive-scram-credentials password salt 1000)) (fetch (lambda (id) (cond ((equal? id "alice") creds) (else #f)))) (client (make-scram-client-state authcid: "alice" password: password)) (server (make-scram-server-state server-name: "test" fetch: fetch)) (server-inbox (list (sasl-client-start client))) (client-inbox '())) (let loop ((iter 0)) (cond ((> iter 30) #f) ((and (eq? (sasl-client-state-phase client) 'done) (eq? (sasl-server-state-phase server) 'done)) #t) ((pair? server-inbox) (let* ((line (car server-inbox)) (stripped (substring line 0 (- (string-length line) 2))) (msg (parse-irc-message stripped)) (out (sasl-server-advance server msg client-prefix: "alice"))) (set! server-inbox (cdr server-inbox)) (set! client-inbox (append client-inbox out)) (loop (+ iter 1)))) ((pair? client-inbox) (let* ((line (car client-inbox)) (stripped (substring line 0 (- (string-length line) 2))) (msg (parse-irc-message stripped)) (out (sasl-client-advance client msg))) (set! client-inbox (cdr client-inbox)) (set! server-inbox (append server-inbox out)) (loop (+ iter 1)))) (else #f))) (assert-equal 'success (sasl-client-state-result client)) (assert-equal 'success (sasl-server-state-result server)) (assert-equal "alice" (sasl-server-state-authcid server)))) (test "wrong password → both sides fail" (let* ((salt (random-bytes 16)) (creds (derive-scram-credentials "real-pw" salt 1000)) (fetch (lambda (id) (cond ((equal? id "alice") creds) (else #f)))) (client (make-scram-client-state authcid: "alice" password: "WRONG-pw")) (server (make-scram-server-state server-name: "test" fetch: fetch)) (server-inbox (list (sasl-client-start client))) (client-inbox '())) (let loop ((iter 0)) (cond ((> iter 30) #f) ((eq? (sasl-server-state-phase server) 'done) #t) ((pair? server-inbox) (let* ((line (car server-inbox)) (stripped (substring line 0 (- (string-length line) 2))) (msg (parse-irc-message stripped)) (out (sasl-server-advance server msg client-prefix: "alice"))) (set! server-inbox (cdr server-inbox)) (set! client-inbox (append client-inbox out)) (loop (+ iter 1)))) ((pair? client-inbox) (let* ((line (car client-inbox)) (stripped (substring line 0 (- (string-length line) 2))) (msg (parse-irc-message stripped)) (out (sasl-client-advance client msg))) (set! client-inbox (cdr client-inbox)) (set! server-inbox (append server-inbox out)) (loop (+ iter 1)))) (else #f))) (assert-equal 'failure (sasl-server-state-result server)))) (test "unknown user → server emits failure on first-message" (let* ((fetch (lambda (id) #f)) (client (make-scram-client-state authcid: "ghost" password: "x")) (server (make-scram-server-state server-name: "test" fetch: fetch))) ;; AUTHENTICATE SCRAM-SHA-256 → server sends + (sasl-server-advance server (parse-irc-message (string-append (substring (sasl-client-start client) 0 (- (string-length (sasl-client-start client)) 2)))) client-prefix: "ghost") ;; Client builds client-first (let* ((client-first-out (sasl-client-advance client (parse-irc-message "AUTHENTICATE +"))) (line (car client-first-out)) (stripped (substring line 0 (- (string-length line) 2))) (msg (parse-irc-message stripped))) (sasl-server-advance server msg client-prefix: "ghost") (assert-equal 'failure (sasl-server-state-result server)))))) ) (else (test-group "SCRAM-SHA-256 crypto-dependent tests" (test-pending "skipped: requires sigil-crypto v0.15.0+ natives (pbkdf2-sha256, hmac-sha256-bytes)"))))(run-tests)test/test-sasl.sgladded
;;; Tests for SASL state machines (PLAIN + EXTERNAL).;;; SCRAM-SHA-256 has its own test-sasl-scram.sgl once the crypto;;; primitives land in sigil-crypto v0.15.(import (sigil test) (sigil crypto) (sigil irc message) (sigil irc sasl) (sigil irc numerics))(test-group "SASL chunked AUTHENTICATE encoding" (test "empty payload produces single + line" (let ((lines (encode-authenticate-payload ""))) (assert-equal 1 (length lines)) (assert-equal "AUTHENTICATE +\r\n" (car lines)))) (test "short payload encodes in single line" (let* ((lines (encode-authenticate-payload "hello")) (joined (apply string-append lines))) (assert-true (string-contains? joined "AUTHENTICATE ")) ;; "hello" base64-encodes to "aGVsbG8=" (8 chars; under 400). (assert-equal 1 (length lines)))) (test "exact-multiple payload appends + terminator" (let* ((payload (make-string 300 #\a)) ; base64 of 300 'a's = 400 chars (lines (encode-authenticate-payload payload))) (assert-equal 2 (length lines)) (assert-equal "AUTHENTICATE +\r\n" (cadr lines)))) (test "decode-authenticate-chunks reverses encoding for short payload" (let* ((payload "hello world") (lines (encode-authenticate-payload payload)) ;; Strip the AUTHENTICATE prefix + CRLF; keep only the chunk text (chunks (map (lambda (line) (let* ((stripped (substring line 13 (string-length line))) (no-crlf (substring stripped 0 (- (string-length stripped) 2)))) no-crlf)) lines))) (assert-equal payload (decode-authenticate-chunks chunks)))) (test "single + chunk decodes to empty" (assert-equal "" (decode-authenticate-chunks (list "+")))))(test-group "SASL PLAIN payload" (test "plain-payload format" ;; authzid \0 authcid \0 password (assert-equal (string-append "" (string #\null) "alice" (string #\null) "secret") (plain-payload #f "alice" "secret"))) (test "plain-payload with explicit authzid" (assert-equal (string-append "alice" (string #\null) "alice" (string #\null) "p") (plain-payload "alice" "alice" "p"))))(test-group "SASL client state — PLAIN" (test "start emits AUTHENTICATE PLAIN" (let ((s (make-sasl-client-state mechanism: SASL-PLAIN authcid: "alice" password: "secret"))) (assert-equal "AUTHENTICATE PLAIN\r\n" (sasl-client-start s)) (assert-equal 'awaiting-server-+ (sasl-client-state-phase s)))) (test "server + triggers payload send" (let ((s (make-sasl-client-state mechanism: SASL-PLAIN authcid: "alice" password: "secret"))) (sasl-client-start s) (let ((out (sasl-client-advance s (parse-irc-message "AUTHENTICATE +")))) (assert-equal 'awaiting-result (sasl-client-state-phase s)) (assert-true (>= (length out) 1)) ;; First chunk line starts with "AUTHENTICATE " (let ((line (car out))) (assert-true (string-contains? line "AUTHENTICATE ")))))) (test "903 RPL-SASLSUCCESS marks success" (let ((s (make-sasl-client-state mechanism: SASL-PLAIN authcid: "alice" password: "secret"))) (sasl-client-start s) (sasl-client-advance s (parse-irc-message "AUTHENTICATE +")) (sasl-client-advance s (parse-irc-message ":server 903 mynick :SASL authentication successful")) (assert-equal 'done (sasl-client-state-phase s)) (assert-equal 'success (sasl-client-state-result s)))) (test "904 ERR-SASLFAIL marks failure" (let ((s (make-sasl-client-state mechanism: SASL-PLAIN authcid: "alice" password: "wrong"))) (sasl-client-start s) (sasl-client-advance s (parse-irc-message "AUTHENTICATE +")) (sasl-client-advance s (parse-irc-message ":server 904 mynick :SASL authentication failed")) (assert-equal 'done (sasl-client-state-phase s)) (assert-equal 'failure (sasl-client-state-result s)))) (test "missing creds produces mech-fail" (let ((s (make-sasl-client-state mechanism: SASL-PLAIN))) (sasl-client-start s) (let ((out (sasl-client-advance s (parse-irc-message "AUTHENTICATE +")))) (assert-equal 'done (sasl-client-state-phase s)) (assert-equal 'mech-fail (sasl-client-state-result s)) (assert-equal "AUTHENTICATE *\r\n" (car out))))))(test-group "SASL client state — EXTERNAL" (test "EXTERNAL with empty authzid sends + terminator only" (let ((s (make-sasl-client-state mechanism: SASL-EXTERNAL))) (sasl-client-start s) (let ((out (sasl-client-advance s (parse-irc-message "AUTHENTICATE +")))) (assert-equal 'awaiting-result (sasl-client-state-phase s)) ;; Empty payload encodes to single AUTHENTICATE +\r\n (assert-equal "AUTHENTICATE +\r\n" (car out))))) (test "EXTERNAL with explicit authzid sends base64'd authzid" (let ((s (make-sasl-client-state mechanism: SASL-EXTERNAL authzid: "alice"))) (sasl-client-start s) (let ((out (sasl-client-advance s (parse-irc-message "AUTHENTICATE +")))) ;; "alice" base64 = "YWxpY2U=" (assert-true (string-contains? (car out) "YWxpY2U="))))))(test-group "SASL server state — PLAIN" (define (verify-plain mech authzid authcid password) (cond ((not (string=? mech SASL-PLAIN)) 'failure) ((and (string=? authcid "alice") (string=? password "secret")) 'success) (else 'failure))) (test "rejects unsupported mechanism" (let ((s (make-sasl-server-state supported-mechanisms: '("PLAIN") verify: verify-plain))) (let ((out (sasl-server-advance s (parse-irc-message "AUTHENTICATE BOGUS") client-prefix: "alice"))) (assert-equal 'done (sasl-server-state-phase s)) (assert-equal 'failure (sasl-server-state-result s)) (assert-true (string-contains? (car out) ERR-SASLFAIL))))) (test "accepts PLAIN selection then prompts for payload" (let ((s (make-sasl-server-state supported-mechanisms: '("PLAIN") verify: verify-plain))) (let ((out (sasl-server-advance s (parse-irc-message "AUTHENTICATE PLAIN") client-prefix: "alice"))) (assert-equal 'awaiting-payload (sasl-server-state-phase s)) (assert-equal "AUTHENTICATE +\r\n" (car out))))) (test "successful PLAIN flow yields RPL-LOGGEDIN + RPL-SASLSUCCESS" (let* ((s (make-sasl-server-state supported-mechanisms: '("PLAIN") verify: verify-plain)) (creds (string-append "" (string #\null) "alice" (string #\null) "secret")) (encoded (base64-encode creds))) (sasl-server-advance s (parse-irc-message "AUTHENTICATE PLAIN") client-prefix: "alice") (let ((out (sasl-server-advance s (parse-irc-message (string-append "AUTHENTICATE " encoded)) client-prefix: "alice"))) (assert-equal 'done (sasl-server-state-phase s)) (assert-equal 'success (sasl-server-state-result s)) (assert-equal "alice" (sasl-server-state-authcid s)) (assert-equal 2 (length out)) (assert-true (string-contains? (car out) RPL-LOGGEDIN)) (assert-true (string-contains? (cadr out) RPL-SASLSUCCESS))))) (test "failed PLAIN flow emits ERR-SASLFAIL" (let* ((s (make-sasl-server-state supported-mechanisms: '("PLAIN") verify: verify-plain)) (creds (string-append "" (string #\null) "alice" (string #\null) "WRONG")) (encoded (base64-encode creds))) (sasl-server-advance s (parse-irc-message "AUTHENTICATE PLAIN") client-prefix: "alice") (let ((out (sasl-server-advance s (parse-irc-message (string-append "AUTHENTICATE " encoded)) client-prefix: "alice"))) (assert-equal 'failure (sasl-server-state-result s)) (assert-true (string-contains? (car out) ERR-SASLFAIL))))) (test "client abort emits ERR-SASLABORTED" (let ((s (make-sasl-server-state supported-mechanisms: '("PLAIN") verify: verify-plain))) (sasl-server-advance s (parse-irc-message "AUTHENTICATE PLAIN") client-prefix: "alice") (let ((out (sasl-server-advance s (parse-irc-message "AUTHENTICATE *") client-prefix: "alice"))) (assert-equal 'aborted (sasl-server-state-result s)) (assert-true (string-contains? (car out) ERR-SASLABORTED))))))(run-tests)